23c127dbfdbfc345ac29a0ff470cf555ef2ced8a
[pyutils.git] / examples / reminder / reminder.py
1 #!/usr/bin/env python3
2
3 """
4 Reminders for upcoming important dates.
5 """
6
7 import datetime
8 import logging
9 import re
10 import sys
11 from collections import defaultdict
12 from typing import Dict, List, Optional
13
14 from pyutils import argparse_utils, bootstrap, config, persistent, string_utils
15 from pyutils.ansi import fg, reset
16 from pyutils.datetimez import dateparse_utils as dateparse
17 from pyutils.files import file_utils
18
19 logger = logging.getLogger(__name__)
20 cfg = config.add_commandline_args(
21     f"Main ({__file__})", "Reminder of events, birthdays and anniversaries."
22 )
23 cfg.add_argument(
24     "--reminder_filename",
25     type=argparse_utils.valid_filename,
26     default='.reminder',
27     metavar='FILENAME',
28     help="Override the .reminder filepath",
29 )
30 cfg.add_argument(
31     '--reminder_cache_file',
32     type=str,
33     default='.reminder_cache',
34     metavar='FILENAME',
35     help='Override the .reminder cache location',
36 )
37 cfg.add_argument(
38     "-n", "--count", type=int, metavar='COUNT', help="How many events to remind about"
39 )
40 cfg.add_argument(
41     "--days_ahead",
42     type=int,
43     metavar='#DAYS',
44     help="How many days ahead to remind about",
45 )
46 cfg.add_argument(
47     "-d",
48     "--date",
49     "--dates",
50     action="store_true",
51     help="Also include the date along with the n day countdown",
52 )
53 cfg.add_argument(
54     "--override_timestamp",
55     nargs=1,
56     type=argparse_utils.valid_datetime,
57     help="Don't use the current datetime, use this one instead.",
58     metavar="DATE/TIME STRING",
59     default=None,
60 )
61
62
63 # This decorator handles caching this object's state on disk and feeding the
64 # state back to new instances of this object at initialization time.  It also
65 # makes sure that this object is a global singleton in the program.
66 @persistent.persistent_autoloaded_singleton()
67 class Reminder(object):
68     MODE_EVENT = 0
69     MODE_BIRTHDAY = 1
70     MODE_ANNIVERSARY = 2
71
72     def __init__(
73         self, cached_state: Optional[Dict[datetime.date, List[str]]] = None
74     ) -> None:
75         if not config.config['override_timestamp']:
76             self.today = datetime.date.today()
77         else:
78             self.today = config.config['override_timestamp'][0].date()
79             logger.debug(
80                 'Overriding "now" with %s because of commandline argument.',
81                 self.today,
82             )
83         if cached_state is not None:
84             self.label_by_date = cached_state
85             return
86         self.label_by_date: Dict[datetime.date, List[str]] = defaultdict(list)
87         self.read_file(config.config['reminder_filename'])
88
89     def handle_event_by_adjusting_year_to_now(
90         self,
91         parsing_mode: int,
92         orig_date: datetime.date,
93         orig_label: str,
94         saw_overt_year: bool,
95     ) -> None:
96         for year in (self.today.year, self.today.year + 1):
97             label = orig_label
98             if saw_overt_year:
99                 delta = year - orig_date.year
100                 if parsing_mode == Reminder.MODE_BIRTHDAY:
101                     if delta != 0:
102                         label += f" ({delta} year{string_utils.pluralize(delta)} old)"
103                 elif parsing_mode == Reminder.MODE_ANNIVERSARY:
104                     if delta != 0:
105                         label += f" ({delta}{string_utils.thify(delta)} anniversary)"
106             dt = datetime.date(
107                 year=year,
108                 month=orig_date.month,
109                 day=orig_date.day,
110             )
111             logger.debug('Date in %d: %s', year, dt)
112             self.label_by_date[dt].append(label)
113             logger.debug('%s => %s', dt, label)
114
115     def handle_event_with_fixed_year(
116         self,
117         orig_date: datetime.date,
118         orig_label: str,
119     ) -> None:
120         logger.debug('Fixed date event...')
121         self.label_by_date[orig_date].append(orig_label)
122         logger.debug('%s => %s', orig_date, orig_label)
123
124     def read_file(self, filename: str) -> None:
125         logger.debug('Reading %s:', filename)
126         date_parser = dateparse.DateParser()
127         parsing_mode = Reminder.MODE_EVENT
128         with open(filename) as f:
129             lines = f.readlines()
130         for line in lines:
131             line = line.strip()
132             line = re.sub(r"#.*$", "", line)
133             if re.match(r"^ *$", line) is not None:
134                 continue
135             logger.debug('> %s', line)
136             try:
137                 if "=" in line:
138                     label, date = line.split("=")
139                 else:
140                     print(f"Skipping unparsable line: {line}", file=sys.stderr)
141                     logger.error('Skipping malformed line: %s', line)
142                     continue
143
144                 if label == "type":
145                     if "event" in date:
146                         parsing_mode = Reminder.MODE_EVENT
147                         logger.debug('--- EVENT MODE ---')
148                     elif "birthday" in date:
149                         parsing_mode = Reminder.MODE_BIRTHDAY
150                         logger.debug('--- BIRTHDAY MODE ---')
151                     elif "anniversary" in date:
152                         parsing_mode = Reminder.MODE_ANNIVERSARY
153                         logger.debug('--- ANNIVERSARY MODE ---')
154                 else:
155                     date_parser.parse(date)
156                     orig_date = date_parser.get_date()
157                     if orig_date is None:
158                         print(f"Skipping unparsable date: {line}", file=sys.stderr)
159                         logger.error('Skipping line with unparsable date')
160                         continue
161                     logger.debug('Original date: %s', orig_date)
162
163                     overt_year = date_parser.saw_overt_year
164                     if parsing_mode in (
165                         Reminder.MODE_BIRTHDAY,
166                         Reminder.MODE_ANNIVERSARY,
167                     ) or (parsing_mode == Reminder.MODE_EVENT and not overt_year):
168                         self.handle_event_by_adjusting_year_to_now(
169                             parsing_mode, orig_date, label, overt_year
170                         )
171                     else:
172                         self.handle_event_with_fixed_year(orig_date, label)
173
174             except Exception as e:
175                 print(f"Skipping unparsable line: {line}", file=sys.stderr)
176                 logger.error('Skipping malformed line: %s', line)
177                 logger.exception(e)
178
179     def remind(
180         self, count: Optional[int], days_ahead: Optional[int], say_date: bool
181     ) -> None:
182         need_both = False
183         if count is not None and days_ahead is not None:
184             need_both = True
185             seen = 0
186
187         for date in sorted(self.label_by_date.keys()):
188             delta = date - self.today
189             d = delta.days
190             if d >= 0:
191                 if days_ahead is not None:
192                     if d > days_ahead:
193                         if not need_both:
194                             return
195                         seen |= 1
196                         if seen == 3:
197                             return
198                 labels = self.label_by_date[date]
199                 for label in labels:
200                     if d > 1:
201                         if d <= 3:
202                             msg = f"{fg('blaze orange')}{d} days{reset()} until {label}"
203                         elif d <= 7:
204                             msg = f"{fg('peach orange')}{d} days{reset()} until {label}"
205                         else:
206                             msg = f"{d} days until {label}"
207                     elif d == 1:
208                         msg = f"{fg('outrageous orange')}Tomorrow{reset()} is {label}"
209                     else:
210                         assert d == 0
211                         msg = f"{fg('red')}Today{reset()} is {label}"
212                     if say_date:
213                         msg += f" {fg('battleship gray')}on {date.strftime('%A, %B %-d')}{reset()}"
214                     print(msg)
215                     if count is not None:
216                         count -= 1
217                         if count <= 0:
218                             if not need_both:
219                                 return
220                             seen |= 2
221                             if seen == 3:
222                                 return
223
224     @classmethod
225     def load(cls):
226         if not config.config['override_timestamp']:
227             now = datetime.datetime.now()
228         else:
229             now = config.config['override_timestamp'][0]
230             logger.debug(
231                 'Overriding "now" with %s because of commandline argument.', now
232             )
233
234         cache_ts = file_utils.get_file_mtime_as_datetime(
235             config.config['reminder_cache_file']
236         )
237         if cache_ts is None:
238             return None
239
240         # If the cache was already written today...
241         if (
242             now.day == cache_ts.day
243             and now.month == cache_ts.month
244             and now.year == cache_ts.year
245         ):
246             reminder_ts = file_utils.get_file_mtime_as_datetime(
247                 config.config['reminder_filename']
248             )
249
250             # ...and the .reminder file wasn't updated since the cache write...
251             if reminder_ts <= cache_ts:
252                 import pickle
253
254                 with open(config.config['reminder_cache_file'], 'rb') as rf:
255                     reminder_data = pickle.load(rf)
256                     return cls(reminder_data)
257         return None
258
259     def save(self):
260         import pickle
261
262         with open(config.config['reminder_cache_file'], 'wb') as wf:
263             pickle.dump(
264                 self.label_by_date,
265                 wf,
266                 pickle.HIGHEST_PROTOCOL,
267             )
268
269
270 @bootstrap.initialize
271 def main() -> None:
272     reminder = Reminder()
273     count = config.config['count']
274     days_ahead = config.config['days_ahead']
275     reminder.remind(count, days_ahead, config.config['date'])
276     return None
277
278
279 if __name__ == "__main__":
280     main()