4 Reminders for upcoming important dates.
11 from collections import defaultdict
12 from typing import Dict, List, Optional
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
19 logger = logging.getLogger(__name__)
20 cfg = config.add_commandline_args(
21 f"Main ({__file__})", "Reminder of events, birthdays and anniversaries."
24 "--reminder_filename",
25 type=argparse_utils.valid_filename,
28 help="Override the .reminder filepath",
31 '--reminder_cache_file',
33 default='.reminder_cache',
35 help='Override the .reminder cache location',
38 "-n", "--count", type=int, metavar='COUNT', help="How many events to remind about"
44 help="How many days ahead to remind about",
51 help="Also include the date along with the n day countdown",
54 "--override_timestamp",
56 type=argparse_utils.valid_datetime,
57 help="Don't use the current datetime, use this one instead.",
58 metavar="DATE/TIME STRING",
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):
73 self, cached_state: Optional[Dict[datetime.date, List[str]]] = None
75 if not config.config['override_timestamp']:
76 self.today = datetime.date.today()
78 self.today = config.config['override_timestamp'][0].date()
80 'Overriding "now" with %s because of commandline argument.',
83 if cached_state is not None:
84 self.label_by_date = cached_state
86 self.label_by_date: Dict[datetime.date, List[str]] = defaultdict(list)
87 self.read_file(config.config['reminder_filename'])
89 def handle_event_by_adjusting_year_to_now(
92 orig_date: datetime.date,
96 for year in (self.today.year, self.today.year + 1):
99 delta = year - orig_date.year
100 if parsing_mode == Reminder.MODE_BIRTHDAY:
102 label += f" ({delta} year{string_utils.pluralize(delta)} old)"
103 elif parsing_mode == Reminder.MODE_ANNIVERSARY:
105 label += f" ({delta}{string_utils.thify(delta)} anniversary)"
108 month=orig_date.month,
111 logger.debug('Date in %d: %s', year, dt)
112 self.label_by_date[dt].append(label)
113 logger.debug('%s => %s', dt, label)
115 def handle_event_with_fixed_year(
117 orig_date: datetime.date,
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)
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()
132 line = re.sub(r"#.*$", "", line)
133 if re.match(r"^ *$", line) is not None:
135 logger.debug('> %s', line)
138 label, date = line.split("=")
140 print(f"Skipping unparsable line: {line}", file=sys.stderr)
141 logger.error('Skipping malformed line: %s', line)
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 ---')
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')
161 logger.debug('Original date: %s', orig_date)
163 overt_year = date_parser.saw_overt_year
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
172 self.handle_event_with_fixed_year(orig_date, label)
174 except Exception as e:
175 print(f"Skipping unparsable line: {line}", file=sys.stderr)
176 logger.error('Skipping malformed line: %s', line)
180 self, count: Optional[int], days_ahead: Optional[int], say_date: bool
183 if count is not None and days_ahead is not None:
187 for date in sorted(self.label_by_date.keys()):
188 delta = date - self.today
191 if days_ahead is not None:
198 labels = self.label_by_date[date]
202 msg = f"{fg('blaze orange')}{d} days{reset()} until {label}"
204 msg = f"{fg('peach orange')}{d} days{reset()} until {label}"
206 msg = f"{d} days until {label}"
208 msg = f"{fg('outrageous orange')}Tomorrow{reset()} is {label}"
211 msg = f"{fg('red')}Today{reset()} is {label}"
213 msg += f" {fg('battleship gray')}on {date.strftime('%A, %B %-d')}{reset()}"
215 if count is not None:
226 if not config.config['override_timestamp']:
227 now = datetime.datetime.now()
229 now = config.config['override_timestamp'][0]
231 'Overriding "now" with %s because of commandline argument.', now
234 cache_ts = file_utils.get_file_mtime_as_datetime(
235 config.config['reminder_cache_file']
240 # If the cache was already written today...
242 now.day == cache_ts.day
243 and now.month == cache_ts.month
244 and now.year == cache_ts.year
246 reminder_ts = file_utils.get_file_mtime_as_datetime(
247 config.config['reminder_filename']
250 # ...and the .reminder file wasn't updated since the cache write...
251 if reminder_ts <= cache_ts:
254 with open(config.config['reminder_cache_file'], 'rb') as rf:
255 reminder_data = pickle.load(rf)
256 return cls(reminder_data)
262 with open(config.config['reminder_cache_file'], 'wb') as wf:
266 pickle.HIGHEST_PROTOCOL,
270 @bootstrap.initialize
272 reminder = Reminder()
273 count = config.config['count']
274 days_ahead = config.config['days_ahead']
275 reminder.remind(count, days_ahead, config.config['date'])
279 if __name__ == "__main__":