3 # © Copyright 2021-2023, Scott Gasch
6 This module defines a class hierarchy (base class :class:`Persistent`) and
7 a decorator (`@persistent_autoloaded_singleton`) that can be used to create
8 objects that load and save their state from some external storage location
9 automatically, optionally and conditionally.
11 A :class:`Persistent` is just a class with a :meth:`Persistent.load` and
12 :meth:`Persistent.save` method. Various subclasses such as
13 :class:`JsonFileBasedPersistent` and :class:`PicklingFileBasedPersistent`
14 define these methods to, save data in a particular format. The details
15 of where and whether to save are left to your code to decide by implementing
16 interface methods like :meth:`FileBasedPersistent.get_filename` and
17 :meth:`FileBasedPersistent.should_we_load_data`.
19 This module inculdes some helpers to make deciding whether to load persisted
20 state easier such as :meth:`was_file_written_today` and
21 :meth:`was_file_written_within_n_seconds`.
23 :class:`Persistent` classes are good for things backed by persisted
24 state that is loaded all or most of the time. For example, the high
25 score list of a game, the configuration settings of a tool,
26 etc... Really anything that wants to save/load state from storage and
27 not bother with the plumbing to do so.
36 from abc import ABC, abstractmethod
37 from typing import Any, Optional
39 from overrides import overrides
41 from pyutils.files import file_utils
43 logger = logging.getLogger(__name__)
46 class Persistent(ABC):
48 A base class of an object with a load/save method. Classes that are
49 decorated with :code:`@persistent_autoloaded_singleton` should subclass
50 this and implement their :meth:`save` and :meth:`load` methods.
54 def save(self) -> bool:
56 Save this thing somewhere that you'll remember when someone calls
57 :meth:`load` later on in a way that makes sense to your code.
64 """Load this thing from somewhere and give back an instance which
65 will become the global singleton and which may (see
66 below) be saved (via :meth:`save`) at program exit time.
68 Oh, in case this is handy, here's a reminder how to write a
69 factory method that doesn't call the c'tor in python::
72 def load_from_somewhere(cls, somewhere):
73 # Note: __new__ does not call __init__.
74 obj = cls.__new__(cls)
76 # Don't forget to call any polymorphic base class initializers
77 super(MyClass, obj).__init__()
79 # Load the piece(s) of obj that you want to from somewhere.
80 obj._state = load_from_somewhere(somewhere)
86 class FileBasedPersistent(Persistent):
87 """A :class:`Persistent` subclass that uses a file to save/load
88 data and knows the conditions under which the state should be
94 def get_filename() -> str:
97 The full path of the file in which we are saving/loading data.
103 def should_we_save_data(filename: str) -> bool:
106 True if we should save our state now or False otherwise.
112 def should_we_load_data(filename: str) -> bool:
115 True if we should load persisted state now or False otherwise.
120 def get_persistent_data(self) -> Any:
123 The raw state data read from the filesystem. Can be any format.
128 class PicklingFileBasedPersistent(FileBasedPersistent):
130 A class that stores its state in a file as pickled Python objects.
136 @persistent.persistent_autoloaded_singleton()
137 class MyClass(persistent.PicklingFileBasedPersistent):
138 def __init__(self, data: Optional[Whatever]):
140 # initialize state from data
142 # if desired, initialize an "empty" object with new state.
146 def get_filename() -> str:
147 return "/path/to/where/you/want/to/save/data.bin"
151 def should_we_save_data(filename: str) -> bool:
152 return true_if_we_should_save_the_data_this_time()
156 def should_we_load_data(filename: str) -> bool:
157 return persistent.was_file_written_within_n_seconds(whatever)
159 # Persistent will handle the plumbing to instantiate your class from its
160 # persisted state iff the :meth:`should_we_load_data` says it's ok to. It
161 # will also persist the current in-memory state to disk at program exit iff
162 # the :meth:`should_we_save_data` methods says to.
168 def __init__(self, data: Optional[Any] = None):
169 """You should override this."""
174 def load(cls) -> Optional[Any]:
175 filename = cls.get_filename()
176 if cls.should_we_load_data(filename):
177 logger.debug("Attempting to load state from %s", filename)
178 assert file_utils.is_readable(filename)
183 with open(filename, "rb") as rf:
184 data = pickle.load(rf)
187 except Exception as e:
188 raise Exception(f"Failed to load {filename}.") from e
192 def save(self) -> bool:
193 filename = self.get_filename()
194 if self.should_we_save_data(filename):
195 logger.debug("Trying to save state in %s", filename)
199 with file_utils.CreateFileWithMode(filename, 0o600, "wb") as wf:
200 pickle.dump(self.get_persistent_data(), wf, pickle.HIGHEST_PROTOCOL)
202 except Exception as e:
203 raise Exception(f"Failed to save to {filename}.") from e
207 class JsonFileBasedPersistent(FileBasedPersistent):
208 """A class that stores its state in a JSON format file.
214 @persistent.persistent_autoloaded_singleton()
215 class MyClass(persistent.JsonFileBasedPersistent):
216 def __init__(self, data: Optional[dict[str, Any]]):
217 # load already deserialized the JSON data for you; it's
218 # a "cooked" JSON dict of string -> values, lists, dicts,
221 #initialize youself from data...
223 # if desired, initialize an empty state object
224 # when json_data isn't provided.
228 def get_filename() -> str:
229 return "/path/to/where/you/want/to/save/data.json"
233 def should_we_save_data(filename: str) -> bool:
234 return true_if_we_should_save_the_data_this_time()
238 def should_we_load_data(filename: str) -> bool:
239 return persistent.was_file_written_within_n_seconds(whatever)
241 # Persistent will handle the plumbing to instantiate your
242 # class from its persisted state iff the
243 # :meth:`should_we_load_data` says it's ok to. It will also
244 # persist the current in memory state to disk at program exit
245 # iff the :meth:`should_we_save_data methods` says to.
250 def __init__(self, data: Optional[Any]):
251 """You should override this."""
256 def load(cls) -> Any:
257 filename = cls.get_filename()
258 if cls.should_we_load_data(filename):
259 logger.debug("Trying to load state from %s", filename)
263 with open(filename, "r") as rf:
264 lines = rf.readlines()
266 # This is probably bad... but I like comments
267 # in config files and JSON doesn't support them. So
268 # pre-process the buffer to remove comments thus
269 # allowing people to add them.
272 line = re.sub(r"#.*$", "", line)
274 json_dict = json.loads(buf)
275 return cls(json_dict)
277 except Exception as e:
279 "Failed to load path %s; raising an exception", filename
281 raise Exception(f"Failed to load {filename}.") from e
285 def save(self) -> bool:
286 filename = self.get_filename()
287 if self.should_we_save_data(filename):
288 logger.debug("Trying to save state in %s", filename)
292 json_blob = json.dumps(self.get_persistent_data())
293 with open(filename, "w") as wf:
294 wf.writelines(json_blob)
296 except Exception as e:
297 raise Exception(f"Failed to save to {filename}.") from e
301 def was_file_written_today(filename: str) -> bool:
302 """Convenience wrapper around :meth:`was_file_written_within_n_seconds`.
305 filename: path / filename to check
308 True if filename was written today.
311 >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
312 >>> os.system(f'touch {filename}')
314 >>> was_file_written_today(filename)
316 >>> os.system(f'touch -d 1974-04-15T01:02:03.99 {filename}')
318 >>> was_file_written_today(filename)
320 >>> os.system(f'/bin/rm -f {filename}')
322 >>> was_file_written_today(filename)
325 if not file_utils.does_file_exist(filename):
328 mtime = file_utils.get_file_mtime_as_datetime(filename)
329 assert mtime is not None
330 now = datetime.datetime.now()
331 return mtime.month == now.month and mtime.day == now.day and mtime.year == now.year
334 def was_file_written_within_n_seconds(
338 """Helper for determining persisted state staleness.
341 filename: the filename to check
342 limit_seconds: how fresh, in seconds, it must be
345 True if filename was written within the past limit_seconds
346 or False otherwise (or on error).
349 >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
350 >>> os.system(f'touch {filename}')
352 >>> was_file_written_within_n_seconds(filename, 60)
356 >>> was_file_written_within_n_seconds(filename, 2)
358 >>> os.system(f'/bin/rm -f {filename}')
360 >>> was_file_written_within_n_seconds(filename, 60)
364 if not file_utils.does_file_exist(filename):
367 mtime = file_utils.get_file_mtime_as_datetime(filename)
368 assert mtime is not None
369 now = datetime.datetime.now()
370 return (now - mtime).total_seconds() <= limit_seconds
373 class PersistAtShutdown(enum.Enum):
375 An enum to describe the conditions under which state is persisted
376 to disk. This is passed as an argument to the decorator below and
377 is used to indicate when to call :meth:`save` on a :class:`Persistent`
380 * NEVER: never call :meth:`save`
381 * IF_NOT_LOADED: call :meth:`save` as long as we did not successfully
382 :meth:`load` its state.
383 * ALWAYS: always call :meth:`save`
391 class persistent_autoloaded_singleton(object):
392 """A decorator that can be applied to a :class:`Persistent` subclass
393 (i.e. a class with :meth:`save` and :meth:`load` methods. The
394 decorator will intercept attempts to instantiate the class via
395 it's c'tor and, instead, invoke the class' :meth:`load` to give it a
396 chance to read state from somewhere persistent (disk, db,
397 whatever). Subsequent calls to construt instances of the wrapped
398 class will return a single, global instance (i.e. the wrapped
399 class is must be a singleton).
401 If :meth:`load` fails (returns None), the c'tor is invoked with the
402 original args as a fallback.
404 Based upon the value of the optional argument
405 :code:`persist_at_shutdown` argument, (NEVER, IF_NOT_LOADED,
406 ALWAYS), the :meth:`save` method of the class will be invoked just
407 before program shutdown to give the class a chance to save its
411 The implementations of :meth:`save` and :meth:`load` and where the
412 class persists its state are details left to the :class:`Persistent`
413 implementation. Essentially this decorator just handles the
414 plumbing of calling your save/load and appropriate times and
415 creates a transparent global singleton whose state can be
416 persisted between runs.
423 persist_at_shutdown: PersistAtShutdown = PersistAtShutdown.IF_NOT_LOADED,
425 self.persist_at_shutdown = persist_at_shutdown
428 def __call__(self, cls: Persistent):
429 @functools.wraps(cls) # type: ignore
430 def _load(*args, **kwargs):
432 # If class has already been loaded, act like a singleton
433 # and return a reference to the one and only instance in
435 if self.instance is not None:
437 "Returning already instantiated singleton instance of %s.",
442 # Otherwise, try to load it from persisted state.
444 logger.debug("Attempting to load %s from persisted state.", cls.__name__)
445 self.instance = cls.load()
446 if not self.instance:
447 msg = "Loading from cache failed."
449 logger.debug("Attempting to instantiate %s directly.", cls.__name__)
450 self.instance = cls(*args, **kwargs)
453 "Class %s was loaded from persisted state successfully.",
458 assert self.instance is not None
460 if self.persist_at_shutdown is PersistAtShutdown.ALWAYS or (
462 and self.persist_at_shutdown is PersistAtShutdown.IF_NOT_LOADED
465 "Scheduling a deferred called to save at process shutdown time."
467 atexit.register(self.instance.save)
473 if __name__ == "__main__":