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
20 from pyutils.files import file_utils
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)
97 assert file_utils.file_is_readable(filename)
102 with open(filename, 'rb') as rf:
103 data = pickle.load(rf)
106 except Exception as e:
107 raise Exception(f'Failed to load {filename}.') from e
111 def save(self) -> bool:
112 filename = self.get_filename()
113 if self.should_we_save_data(filename):
114 logger.debug('Trying to save state in %s', filename)
118 with open(filename, 'wb') as wf:
119 pickle.dump(self.get_persistent_data(), wf, pickle.HIGHEST_PROTOCOL)
121 except Exception as e:
122 raise Exception(f'Failed to save to {filename}.') from e
126 class JsonFileBasedPersistent(FileBasedPersistent):
129 def load(cls) -> Any:
130 filename = cls.get_filename()
131 if cls.should_we_load_data(filename):
132 logger.debug('Trying to load state from %s', filename)
136 with open(filename, 'r') as rf:
137 lines = rf.readlines()
139 # This is probably bad... but I like comments
140 # in config files and JSON doesn't support them. So
141 # pre-process the buffer to remove comments thus
142 # allowing people to add them.
145 line = re.sub(r'#.*$', '', line)
148 json_dict = json.loads(buf)
149 return cls(json_dict)
151 except Exception as e:
153 raise Exception(f'Failed to load {filename}.') from e
157 def save(self) -> bool:
158 filename = self.get_filename()
159 if self.should_we_save_data(filename):
160 logger.debug('Trying to save state in %s', filename)
164 json_blob = json.dumps(self.get_persistent_data())
165 with open(filename, 'w') as wf:
166 wf.writelines(json_blob)
168 except Exception as e:
169 raise Exception(f'Failed to save to {filename}.') from e
173 def was_file_written_today(filename: str) -> bool:
174 """Convenience wrapper around :meth:`was_file_written_within_n_seconds`.
177 filename: filename to check
180 True if filename was written today.
183 >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
184 >>> os.system(f'touch {filename}')
186 >>> was_file_written_today(filename)
188 >>> os.system(f'touch -d 1974-04-15T01:02:03.99 {filename}')
190 >>> was_file_written_today(filename)
192 >>> os.system(f'/bin/rm -f {filename}')
194 >>> was_file_written_today(filename)
198 if not file_utils.does_file_exist(filename):
201 mtime = file_utils.get_file_mtime_as_datetime(filename)
202 assert mtime is not None
203 now = datetime.datetime.now()
204 return mtime.month == now.month and mtime.day == now.day and mtime.year == now.year
207 def was_file_written_within_n_seconds(
211 """Helper for determining persisted state staleness.
214 filename: the filename to check
215 limit_seconds: how fresh, in seconds, it must be
218 True if filename was written within the past limit_seconds
219 or False otherwise (or on error).
222 >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
223 >>> os.system(f'touch {filename}')
225 >>> was_file_written_within_n_seconds(filename, 60)
229 >>> was_file_written_within_n_seconds(filename, 2)
231 >>> os.system(f'/bin/rm -f {filename}')
233 >>> was_file_written_within_n_seconds(filename, 60)
237 if not file_utils.does_file_exist(filename):
240 mtime = file_utils.get_file_mtime_as_datetime(filename)
241 assert mtime is not None
242 now = datetime.datetime.now()
243 return (now - mtime).total_seconds() <= limit_seconds
246 class PersistAtShutdown(enum.Enum):
248 An enum to describe the conditions under which state is persisted
249 to disk. This is passed as an argument to the decorator below and
250 is used to indicate when to call :meth:`save` on a :class:`Persistent`
253 * NEVER: never call :meth:`save`
254 * IF_NOT_LOADED: call :meth:`save` as long as we did not successfully
255 :meth:`load` its state.
256 * ALWAYS: always call :meth:`save`
264 class persistent_autoloaded_singleton(object):
265 """A decorator that can be applied to a :class:`Persistent` subclass
266 (i.e. a class with :meth:`save` and :meth:`load` methods. The
267 decorator will intercept attempts to instantiate the class via
268 it's c'tor and, instead, invoke the class' :meth:`load` to give it a
269 chance to read state from somewhere persistent (disk, db,
270 whatever). Subsequent calls to construt instances of the wrapped
271 class will return a single, global instance (i.e. the wrapped
272 class is a singleton).
274 If :meth:`load` fails (returns None), the c'tor is invoked with the
275 original args as a fallback.
277 Based upon the value of the optional argument
278 :code:`persist_at_shutdown` argument, (NEVER, IF_NOT_LOADED,
279 ALWAYS), the :meth:`save` method of the class will be invoked just
280 before program shutdown to give the class a chance to save its
284 The implementations of :meth:`save` and :meth:`load` and where the
285 class persists its state are details left to the :class:`Persistent`
286 implementation. Essentially this decorator just handles the
287 plumbing of calling your save/load and appropriate times and
288 creates a transparent global singleton whose state can be
289 persisted between runs.
296 persist_at_shutdown: PersistAtShutdown = PersistAtShutdown.IF_NOT_LOADED,
298 self.persist_at_shutdown = persist_at_shutdown
301 def __call__(self, cls: Persistent):
302 @functools.wraps(cls) # type: ignore
303 def _load(*args, **kwargs):
305 # If class has already been loaded, act like a singleton
306 # and return a reference to the one and only instance in
308 if self.instance is not None:
310 'Returning already instantiated singleton instance of %s.',
315 # Otherwise, try to load it from persisted state.
317 logger.debug('Attempting to load %s from persisted state.', cls.__name__)
318 self.instance = cls.load()
319 if not self.instance:
320 msg = 'Loading from cache failed.'
322 logger.debug('Attempting to instantiate %s directly.', cls.__name__)
323 self.instance = cls(*args, **kwargs)
326 'Class %s was loaded from persisted state successfully.',
331 assert self.instance is not None
333 if self.persist_at_shutdown is PersistAtShutdown.ALWAYS or (
335 and self.persist_at_shutdown is PersistAtShutdown.IF_NOT_LOADED
338 'Scheduling a deferred called to save at process shutdown time.'
340 atexit.register(self.instance.save)
346 if __name__ == '__main__':