X-Git-Url: https://wannabe.guru.org/gitweb/?a=blobdiff_plain;f=src%2Fpyutils%2Fpersistent.py;h=c7e224b561beb3e19ec2eb5f3b6f7afbcc6abbae;hb=72e52644458a9c231832f11c41a66d9b726bfe86;hp=13de4728e57e0391862a1b6b885594650e76ca1d;hpb=6dfab2d5ff870d8fc303ecfb70cdbb7384b2a9db;p=pyutils.git diff --git a/src/pyutils/persistent.py b/src/pyutils/persistent.py index 13de472..c7e224b 100644 --- a/src/pyutils/persistent.py +++ b/src/pyutils/persistent.py @@ -2,9 +2,30 @@ # © Copyright 2021-2022, Scott Gasch -"""A :class:`Persistent` is just a class with a load and save method. This -module defines the :class:`Persistent` base and a decorator that can be used to -create a persistent singleton that autoloads and autosaves.""" +""" +This module defines a class hierarchy (base class :class:`Persistent`) and +a decorator (`@persistent_autoloaded_singleton`) that can be used to create +objects that load and save their state from some external storage location +automatically, optionally and conditionally. + +A :class:`Persistent` is just a class with a :meth:`Persistent.load` and +:meth:`Persistent.save` method. Various subclasses such as +:class:`JsonFileBasedPersistent` and :class:`PicklingFileBasedPersistent` +define these methods to, save data in a particular format. The details +of where and whether to save are left to your code to decide by implementing +interface methods like :meth:`FileBasedPersistent.get_filename` and +:meth:`FileBasedPersistent.should_we_load_data`. + +This module inculdes some helpers to make deciding whether to load persisted +state easier such as :meth:`was_file_written_today` and +:meth:`was_file_written_within_n_seconds`. + +:class:`Persistent` classes are good for things backed by persisted +state that is loaded all or most of the time. For example, the high +score list of a game, the configuration settings of a tool, +etc... Really anything that wants to save/load state from storage and +not bother with the plumbing to do so. +""" import atexit import datetime @@ -63,33 +84,50 @@ class Persistent(ABC): class FileBasedPersistent(Persistent): - """A Persistent that uses a file to save/load data and knows the conditions - under which the state should be saved/loaded.""" + """A :class:`Persistent` subclass that uses a file to save/load + data and knows the conditions under which the state should be + saved/loaded. + """ @staticmethod @abstractmethod def get_filename() -> str: - """Since this class saves/loads to/from a file, what's its full path?""" + """ + Returns: + The full path of the file in which we are saving/loading data. + """ pass @staticmethod @abstractmethod def should_we_save_data(filename: str) -> bool: + """ + Returns: + True if we should save our state now or False otherwise. + """ pass @staticmethod @abstractmethod def should_we_load_data(filename: str) -> bool: + """ + Returns: + True if we should load persisted state now or False otherwise. + """ pass @abstractmethod def get_persistent_data(self) -> Any: + """ + Returns: + The raw state data read from the filesystem. Can be any format. + """ pass class PicklingFileBasedPersistent(FileBasedPersistent): """ - A class that stores its state in a file as pickled python objects. + A class that stores its state in a file as pickled Python objects. Example usage:: @@ -97,8 +135,11 @@ class PicklingFileBasedPersistent(FileBasedPersistent): @persistent.persistent_autoloaded_singleton() class MyClass(persistent.PicklingFileBasedPersistent): - def __init__(self, data: Whatever): - #initialize youself from data + def __init__(self, data: Optional[Whatever]): + if data: + # initialize state from data + else: + # if desired, initialize an "empty" object with new state. @staticmethod @overrides @@ -116,9 +157,9 @@ class PicklingFileBasedPersistent(FileBasedPersistent): return persistent.was_file_written_within_n_seconds(whatever) # Persistent will handle the plumbing to instantiate your class from its - # persisted state iff the should_we_load_data says it's ok to. It will - # also persist the current in memory state to disk at program exit iff - # the should_we_save_data methods says to. + # persisted state iff the :meth:`should_we_load_data` says it's ok to. It + # will also persist the current in-memory state to disk at program exit iff + # the :meth:`should_we_save_data` methods says to. c = MyClass() """ @@ -128,39 +169,38 @@ class PicklingFileBasedPersistent(FileBasedPersistent): def load(cls) -> Optional[Any]: filename = cls.get_filename() if cls.should_we_load_data(filename): - logger.debug('Attempting to load state from %s', filename) + logger.debug("Attempting to load state from %s", filename) assert file_utils.file_is_readable(filename) import pickle try: - with open(filename, 'rb') as rf: + with open(filename, "rb") as rf: data = pickle.load(rf) return cls(data) except Exception as e: - raise Exception(f'Failed to load {filename}.') from e + raise Exception(f"Failed to load {filename}.") from e return None @overrides def save(self) -> bool: filename = self.get_filename() if self.should_we_save_data(filename): - logger.debug('Trying to save state in %s', filename) + logger.debug("Trying to save state in %s", filename) try: import pickle - with open(filename, 'wb') as wf: + with open(filename, "wb") as wf: pickle.dump(self.get_persistent_data(), wf, pickle.HIGHEST_PROTOCOL) return True except Exception as e: - raise Exception(f'Failed to save to {filename}.') from e + raise Exception(f"Failed to save to {filename}.") from e return False class JsonFileBasedPersistent(FileBasedPersistent): - """ - A class that stores its state in a JSON format file. + """A class that stores its state in a JSON format file. Example usage:: @@ -168,8 +208,15 @@ class JsonFileBasedPersistent(FileBasedPersistent): @persistent.persistent_autoloaded_singleton() class MyClass(persistent.JsonFileBasedPersistent): - def __init__(self, data: Whatever): - #initialize youself from data + def __init__(self, data: Optional[dict[str, Any]]): + # load already deserialized the JSON data for you; it's + # a "cooked" JSON dict of string -> values, lists, dicts, + # etc... + if data: + #initialize youself from data... + else: + # if desired, initialize an empty state object + # when json_data isn't provided. @staticmethod @overrides @@ -186,12 +233,12 @@ class JsonFileBasedPersistent(FileBasedPersistent): def should_we_load_data(filename: str) -> bool: return persistent.was_file_written_within_n_seconds(whatever) - # Persistent will handle the plumbing to instantiate your class from its - # persisted state iff the should_we_load_data says it's ok to. It will - # also persist the current in memory state to disk at program exit iff - # the should_we_save_data methods says to. + # Persistent will handle the plumbing to instantiate your + # class from its persisted state iff the + # :meth:`should_we_load_data` says it's ok to. It will also + # persist the current in memory state to disk at program exit + # iff the :meth:`should_we_save_data methods` says to. c = MyClass() - """ @classmethod @@ -199,44 +246,43 @@ class JsonFileBasedPersistent(FileBasedPersistent): def load(cls) -> Any: filename = cls.get_filename() if cls.should_we_load_data(filename): - logger.debug('Trying to load state from %s', filename) + logger.debug("Trying to load state from %s", filename) import json try: - with open(filename, 'r') as rf: + with open(filename, "r") as rf: lines = rf.readlines() # This is probably bad... but I like comments # in config files and JSON doesn't support them. So # pre-process the buffer to remove comments thus # allowing people to add them. - buf = '' + buf = "" for line in lines: - line = re.sub(r'#.*$', '', line) + line = re.sub(r"#.*$", "", line) buf += line - json_dict = json.loads(buf) return cls(json_dict) except Exception as e: logger.exception(e) - raise Exception(f'Failed to load {filename}.') from e + raise Exception(f"Failed to load {filename}.") from e return None @overrides def save(self) -> bool: filename = self.get_filename() if self.should_we_save_data(filename): - logger.debug('Trying to save state in %s', filename) + logger.debug("Trying to save state in %s", filename) try: import json json_blob = json.dumps(self.get_persistent_data()) - with open(filename, 'w') as wf: + with open(filename, "w") as wf: wf.writelines(json_blob) return True except Exception as e: - raise Exception(f'Failed to save to {filename}.') from e + raise Exception(f"Failed to save to {filename}.") from e return False @@ -244,7 +290,7 @@ def was_file_written_today(filename: str) -> bool: """Convenience wrapper around :meth:`was_file_written_within_n_seconds`. Args: - filename: filename to check + filename: path / filename to check Returns: True if filename was written today. @@ -264,7 +310,6 @@ def was_file_written_today(filename: str) -> bool: >>> was_file_written_today(filename) False """ - if not file_utils.does_file_exist(filename): return False @@ -339,7 +384,7 @@ class persistent_autoloaded_singleton(object): chance to read state from somewhere persistent (disk, db, whatever). Subsequent calls to construt instances of the wrapped class will return a single, global instance (i.e. the wrapped - class is a singleton). + class is must be a singleton). If :meth:`load` fails (returns None), the c'tor is invoked with the original args as a fallback. @@ -377,23 +422,23 @@ class persistent_autoloaded_singleton(object): # memory. if self.instance is not None: logger.debug( - 'Returning already instantiated singleton instance of %s.', + "Returning already instantiated singleton instance of %s.", cls.__name__, ) return self.instance # Otherwise, try to load it from persisted state. was_loaded = False - logger.debug('Attempting to load %s from persisted state.', cls.__name__) + logger.debug("Attempting to load %s from persisted state.", cls.__name__) self.instance = cls.load() if not self.instance: - msg = 'Loading from cache failed.' + msg = "Loading from cache failed." logger.warning(msg) - logger.debug('Attempting to instantiate %s directly.', cls.__name__) + logger.debug("Attempting to instantiate %s directly.", cls.__name__) self.instance = cls(*args, **kwargs) else: logger.debug( - 'Class %s was loaded from persisted state successfully.', + "Class %s was loaded from persisted state successfully.", cls.__name__, ) was_loaded = True @@ -405,7 +450,7 @@ class persistent_autoloaded_singleton(object): and self.persist_at_shutdown is PersistAtShutdown.IF_NOT_LOADED ): logger.debug( - 'Scheduling a deferred called to save at process shutdown time.' + "Scheduling a deferred called to save at process shutdown time." ) atexit.register(self.instance.save) return self.instance @@ -413,7 +458,7 @@ class persistent_autoloaded_singleton(object): return _load -if __name__ == '__main__': +if __name__ == "__main__": import doctest doctest.testmod()