4 Reminders for upcoming important dates.
12 from collections import defaultdict
13 from typing import Dict, List, Optional
15 from pyutils import argparse_utils, bootstrap, config, string_utils
16 from pyutils.ansi import fg, reset
17 from pyutils.datetimes import dateparse_utils as dateparse
18 from pyutils.files import file_utils
19 from pyutils.typez import persistent
21 logger = logging.getLogger(__name__)
22 cfg = config.add_commandline_args(
23 f"Main ({__file__})", "Reminder of events, birthdays and anniversaries."
26 "--reminder_filename",
27 type=argparse_utils.valid_filename,
30 help="Override the .reminder filepath",
33 "--reminder_cache_file",
35 default=f'{os.environ["HOME"]}/.reminder_cache',
37 help="Override the .reminder cache location",
40 "-n", "--count", type=int, metavar="COUNT", help="How many events to remind about"
46 help="How many days ahead to remind about",
53 help="Also include the date along with the n day countdown",
56 "--override_timestamp",
58 type=argparse_utils.valid_datetime,
59 help="Don't use the current datetime, use this one instead.",
60 metavar="DATE/TIME STRING",
65 # This decorator handles caching this object's state on disk and feeding the
66 # state back to new instances of this object at initialization time. It also
67 # makes sure that this object is a global singleton in the program.
68 @persistent.persistent_autoloaded_singleton()
69 class Reminder(object):
75 self, cached_state: Optional[Dict[datetime.date, List[str]]] = None
77 if not config.config["override_timestamp"]:
78 self.today = datetime.date.today()
80 self.today = config.config["override_timestamp"][0].date()
82 'Overriding "now" with %s because of commandline argument.',
85 if cached_state is not None:
86 self.label_by_date = cached_state
88 self.label_by_date: Dict[datetime.date, List[str]] = defaultdict(list)
89 self.read_file(config.config["reminder_filename"])
91 def handle_event_by_adjusting_year_to_now(
94 orig_date: datetime.date,
98 for year in (self.today.year, self.today.year + 1):
101 delta = year - orig_date.year
102 if parsing_mode == Reminder.MODE_BIRTHDAY:
104 label += f" ({delta} year{string_utils.pluralize(delta)} old)"
105 elif parsing_mode == Reminder.MODE_ANNIVERSARY:
107 label += f" ({delta}{string_utils.thify(delta)} anniversary)"
110 month=orig_date.month,
113 logger.debug("Date in %d: %s", year, dt)
114 self.label_by_date[dt].append(label)
115 logger.debug("%s => %s", dt, label)
117 def handle_event_with_fixed_year(
119 orig_date: datetime.date,
122 logger.debug("Fixed date event...")
123 self.label_by_date[orig_date].append(orig_label)
124 logger.debug("%s => %s", orig_date, orig_label)
126 def read_file(self, filename: str) -> None:
127 logger.debug("Reading %s:", filename)
128 date_parser = dateparse.DateParser()
129 parsing_mode = Reminder.MODE_EVENT
130 with open(filename) as f:
131 lines = f.readlines()
134 line = re.sub(r"#.*$", "", line)
135 if re.match(r"^ *$", line) is not None:
137 logger.debug("> %s", line)
140 label, date = line.split("=")
142 print(f"Skipping unparsable line: {line}", file=sys.stderr)
143 logger.error("Skipping malformed line: %s", line)
148 parsing_mode = Reminder.MODE_EVENT
149 logger.debug("--- EVENT MODE ---")
150 elif "birthday" in date:
151 parsing_mode = Reminder.MODE_BIRTHDAY
152 logger.debug("--- BIRTHDAY MODE ---")
153 elif "anniversary" in date:
154 parsing_mode = Reminder.MODE_ANNIVERSARY
155 logger.debug("--- ANNIVERSARY MODE ---")
157 date_parser.parse(date)
158 orig_date = date_parser.get_date()
159 if orig_date is None:
160 print(f"Skipping unparsable date: {line}", file=sys.stderr)
161 logger.error("Skipping line with unparsable date")
163 logger.debug("Original date: %s", orig_date)
165 overt_year = date_parser.saw_overt_year
167 Reminder.MODE_BIRTHDAY,
168 Reminder.MODE_ANNIVERSARY,
169 ) or (parsing_mode == Reminder.MODE_EVENT and not overt_year):
170 self.handle_event_by_adjusting_year_to_now(
171 parsing_mode, orig_date, label, overt_year
174 self.handle_event_with_fixed_year(orig_date, label)
177 logger.exception("Skipping malformed line: %s", line)
178 print(f"Skipping unparsable line: {line}", file=sys.stderr)
181 self, count: Optional[int], days_ahead: Optional[int], say_date: bool
184 if count is not None and days_ahead is not None:
188 for date in sorted(self.label_by_date.keys()):
189 delta = date - self.today
192 if days_ahead is not None:
199 labels = self.label_by_date[date]
203 msg = f"{fg('blaze orange')}{d} days{reset()} until {label}"
205 msg = f"{fg('peach orange')}{d} days{reset()} until {label}"
207 msg = f"{d} days until {label}"
209 msg = f"{fg('outrageous orange')}Tomorrow{reset()} is {label}"
212 msg = f"{fg('red')}Today{reset()} is {label}"
214 msg += f" {fg('battleship gray')}on {date.strftime('%A, %B %-d')}{reset()}"
216 if count is not None:
227 if not config.config["override_timestamp"]:
228 now = datetime.datetime.now()
230 now = config.config["override_timestamp"][0]
232 'Overriding "now" with %s because of commandline argument.', now
235 cache_ts = file_utils.get_file_mtime_as_datetime(
236 config.config["reminder_cache_file"]
241 # If the cache was already written today...
243 now.day == cache_ts.day
244 and now.month == cache_ts.month
245 and now.year == cache_ts.year
247 reminder_ts = file_utils.get_file_mtime_as_datetime(
248 config.config["reminder_filename"]
251 # ...and the .reminder file wasn't updated since the cache write...
252 if reminder_ts <= cache_ts:
255 with open(config.config["reminder_cache_file"], "rb") as rf:
256 reminder_data = pickle.load(rf)
257 return cls(reminder_data)
263 with file_utils.CreateFileWithMode(
264 config.config["reminder_cache_file"], filesystem_mode=0o600, open_mode="wb"
269 pickle.HIGHEST_PROTOCOL,
273 @bootstrap.initialize
275 reminder = Reminder()
276 count = config.config["count"]
277 days_ahead = config.config["days_ahead"]
278 reminder.remind(count, days_ahead, config.config["date"])
282 if __name__ == "__main__":