Fix docs.
[pyutils.git] / src / pyutils / persistent.py
index 2b03ea6f4e29e496f6dfd9a75d92bd33047cd6c3..c7e224b561beb3e19ec2eb5f3b6f7afbcc6abbae 100644 (file)
@@ -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,110 +84,205 @@ 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.
+
+    Example usage::
+
+        import persistent
+
+        @persistent.persistent_autoloaded_singleton()
+        class MyClass(persistent.PicklingFileBasedPersistent):
+            def __init__(self, data: Optional[Whatever]):
+                if data:
+                    # initialize state from data
+                else:
+                    # if desired, initialize an "empty" object with new state.
+
+            @staticmethod
+            @overrides
+            def get_filename() -> str:
+                return "/path/to/where/you/want/to/save/data.bin"
+
+            @staticmethod
+            @overrides
+            def should_we_save_data(filename: str) -> bool:
+                return true_if_we_should_save_the_data_this_time()
+
+            @staticmethod
+            @overrides
+            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 :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
     @overrides
     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.
+
+    Example usage::
+
+        import persistent
+
+        @persistent.persistent_autoloaded_singleton()
+        class MyClass(persistent.JsonFileBasedPersistent):
+            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
+            def get_filename() -> str:
+                return "/path/to/where/you/want/to/save/data.json"
+
+            @staticmethod
+            @overrides
+            def should_we_save_data(filename: str) -> bool:
+                return true_if_we_should_save_the_data_this_time()
+
+            @staticmethod
+            @overrides
+            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
+        # :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
     @overrides
     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
 
 
@@ -174,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.
@@ -194,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
 
@@ -269,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.
@@ -307,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
@@ -335,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
@@ -343,7 +458,7 @@ class persistent_autoloaded_singleton(object):
         return _load
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     import doctest
 
     doctest.testmod()