3 # © Copyright 2021-2022, Scott Gasch
5 """A :class:`Persistent` is just a class with a load and save method. This
6 module defines the :class:`Persistent` base and a decorator that can be used to
7 create a persistent singleton that autoloads and autosaves."""
15 from abc import ABC, abstractmethod
16 from typing import Any, Optional
18 from overrides import overrides
22 logger = logging.getLogger(__name__)
25 class Persistent(ABC):
27 A base class of an object with a load/save method. Classes that are
28 decorated with :code:`@persistent_autoloaded_singleton` should subclass
29 this and implement their :meth:`save` and :meth:`load` methods.
33 def save(self) -> bool:
35 Save this thing somewhere that you'll remember when someone calls
36 :meth:`load` later on in a way that makes sense to your code.
43 """Load this thing from somewhere and give back an instance which
44 will become the global singleton and which may (see
45 below) be saved (via :meth:`save`) at program exit time.
47 Oh, in case this is handy, here's a reminder how to write a
48 factory method that doesn't call the c'tor in python::
51 def load_from_somewhere(cls, somewhere):
52 # Note: __new__ does not call __init__.
53 obj = cls.__new__(cls)
55 # Don't forget to call any polymorphic base class initializers
56 super(MyClass, obj).__init__()
58 # Load the piece(s) of obj that you want to from somewhere.
59 obj._state = load_from_somewhere(somewhere)
65 class FileBasedPersistent(Persistent):
66 """A Persistent that uses a file to save/load data and knows the conditions
67 under which the state should be saved/loaded."""
71 def get_filename() -> str:
72 """Since this class saves/loads to/from a file, what's its full path?"""
77 def should_we_save_data(filename: str) -> bool:
82 def should_we_load_data(filename: str) -> bool:
86 def get_persistent_data(self) -> Any:
90 class PicklingFileBasedPersistent(FileBasedPersistent):
93 def load(cls) -> Optional[Any]:
94 filename = cls.get_filename()
95 if cls.should_we_load_data(filename):
96 logger.debug('Attempting to load state from %s', filename)
101 with open(filename, 'rb') as rf:
102 data = pickle.load(rf)
105 except Exception as e:
106 raise Exception(f'Failed to load {filename}.') from e
110 def save(self) -> bool:
111 filename = self.get_filename()
112 if self.should_we_save_data(filename):
113 logger.debug('Trying to save state in %s', filename)
117 with open(filename, 'wb') as wf:
118 pickle.dump(self.get_persistent_data(), wf, pickle.HIGHEST_PROTOCOL)
120 except Exception as e:
121 raise Exception(f'Failed to save to {filename}.') from e
125 class JsonFileBasedPersistent(FileBasedPersistent):
128 def load(cls) -> Any:
129 filename = cls.get_filename()
130 if cls.should_we_load_data(filename):
131 logger.debug('Trying to load state from %s', filename)
135 with open(filename, 'r') as rf:
136 lines = rf.readlines()
138 # This is probably bad... but I like comments
139 # in config files and JSON doesn't support them. So
140 # pre-process the buffer to remove comments thus
141 # allowing people to add them.
144 line = re.sub(r'#.*$', '', line)
147 json_dict = json.loads(buf)
148 return cls(json_dict)
150 except Exception as e:
152 raise Exception(f'Failed to load {filename}.') from e
156 def save(self) -> bool:
157 filename = self.get_filename()
158 if self.should_we_save_data(filename):
159 logger.debug('Trying to save state in %s', filename)
163 json_blob = json.dumps(self.get_persistent_data())
164 with open(filename, 'w') as wf:
165 wf.writelines(json_blob)
167 except Exception as e:
168 raise Exception(f'Failed to save to {filename}.') from e
172 def was_file_written_today(filename: str) -> bool:
173 """Convenience wrapper around :meth:`was_file_written_within_n_seconds`.
176 filename: filename to check
179 True if filename was written today.
182 >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
183 >>> os.system(f'touch {filename}')
185 >>> was_file_written_today(filename)
187 >>> os.system(f'touch -d 1974-04-15T01:02:03.99 {filename}')
189 >>> was_file_written_today(filename)
191 >>> os.system(f'/bin/rm -f {filename}')
193 >>> was_file_written_today(filename)
197 if not file_utils.does_file_exist(filename):
200 mtime = file_utils.get_file_mtime_as_datetime(filename)
201 assert mtime is not None
202 now = datetime.datetime.now()
203 return mtime.month == now.month and mtime.day == now.day and mtime.year == now.year
206 def was_file_written_within_n_seconds(
210 """Helper for determining persisted state staleness.
213 filename: the filename to check
214 limit_seconds: how fresh, in seconds, it must be
217 True if filename was written within the past limit_seconds
218 or False otherwise (or on error).
221 >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
222 >>> os.system(f'touch {filename}')
224 >>> was_file_written_within_n_seconds(filename, 60)
228 >>> was_file_written_within_n_seconds(filename, 2)
230 >>> os.system(f'/bin/rm -f {filename}')
232 >>> was_file_written_within_n_seconds(filename, 60)
236 if not file_utils.does_file_exist(filename):
239 mtime = file_utils.get_file_mtime_as_datetime(filename)
240 assert mtime is not None
241 now = datetime.datetime.now()
242 return (now - mtime).total_seconds() <= limit_seconds
245 class PersistAtShutdown(enum.Enum):
247 An enum to describe the conditions under which state is persisted
248 to disk. This is passed as an argument to the decorator below and
249 is used to indicate when to call :meth:`save` on a :class:`Persistent`
252 * NEVER: never call :meth:`save`
253 * IF_NOT_LOADED: call :meth:`save` as long as we did not successfully
254 :meth:`load` its state.
255 * ALWAYS: always call :meth:`save`
263 class persistent_autoloaded_singleton(object):
264 """A decorator that can be applied to a :class:`Persistent` subclass
265 (i.e. a class with :meth:`save` and :meth:`load` methods. The
266 decorator will intercept attempts to instantiate the class via
267 it's c'tor and, instead, invoke the class' :meth:`load` to give it a
268 chance to read state from somewhere persistent (disk, db,
269 whatever). Subsequent calls to construt instances of the wrapped
270 class will return a single, global instance (i.e. the wrapped
271 class is a singleton).
273 If :meth:`load` fails (returns None), the c'tor is invoked with the
274 original args as a fallback.
276 Based upon the value of the optional argument
277 :code:`persist_at_shutdown` argument, (NEVER, IF_NOT_LOADED,
278 ALWAYS), the :meth:`save` method of the class will be invoked just
279 before program shutdown to give the class a chance to save its
283 The implementations of :meth:`save` and :meth:`load` and where the
284 class persists its state are details left to the :class:`Persistent`
285 implementation. Essentially this decorator just handles the
286 plumbing of calling your save/load and appropriate times and
287 creates a transparent global singleton whose state can be
288 persisted between runs.
295 persist_at_shutdown: PersistAtShutdown = PersistAtShutdown.IF_NOT_LOADED,
297 self.persist_at_shutdown = persist_at_shutdown
300 def __call__(self, cls: Persistent):
301 @functools.wraps(cls) # type: ignore
302 def _load(*args, **kwargs):
304 # If class has already been loaded, act like a singleton
305 # and return a reference to the one and only instance in
307 if self.instance is not None:
309 'Returning already instantiated singleton instance of %s.', cls.__name__
313 # Otherwise, try to load it from persisted state.
315 logger.debug('Attempting to load %s from persisted state.', cls.__name__)
316 self.instance = cls.load()
317 if not self.instance:
318 msg = 'Loading from cache failed.'
320 logger.debug('Attempting to instantiate %s directly.', cls.__name__)
321 self.instance = cls(*args, **kwargs)
323 logger.debug('Class %s was loaded from persisted state successfully.', cls.__name__)
326 assert self.instance is not None
328 if self.persist_at_shutdown is PersistAtShutdown.ALWAYS or (
329 not was_loaded and self.persist_at_shutdown is PersistAtShutdown.IF_NOT_LOADED
331 logger.debug('Scheduling a deferred called to save at process shutdown time.')
332 atexit.register(self.instance.save)
338 if __name__ == '__main__':