Fix docs.
[pyutils.git] / src / pyutils / persistent.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2022, Scott Gasch
4
5 """
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.
10
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`.
18
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`.
22
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.
28 """
29
30 import atexit
31 import datetime
32 import enum
33 import functools
34 import logging
35 import re
36 from abc import ABC, abstractmethod
37 from typing import Any, Optional
38
39 from overrides import overrides
40
41 from pyutils.files import file_utils
42
43 logger = logging.getLogger(__name__)
44
45
46 class Persistent(ABC):
47     """
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.
51     """
52
53     @abstractmethod
54     def save(self) -> bool:
55         """
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.
58         """
59         pass
60
61     @classmethod
62     @abstractmethod
63     def load(cls) -> Any:
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.
67
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::
70
71             @classmethod
72             def load_from_somewhere(cls, somewhere):
73                 # Note: __new__ does not call __init__.
74                 obj = cls.__new__(cls)
75
76                 # Don't forget to call any polymorphic base class initializers
77                 super(MyClass, obj).__init__()
78
79                 # Load the piece(s) of obj that you want to from somewhere.
80                 obj._state = load_from_somewhere(somewhere)
81                 return obj
82         """
83         pass
84
85
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
89     saved/loaded.
90     """
91
92     @staticmethod
93     @abstractmethod
94     def get_filename() -> str:
95         """
96         Returns:
97             The full path of the file in which we are saving/loading data.
98         """
99         pass
100
101     @staticmethod
102     @abstractmethod
103     def should_we_save_data(filename: str) -> bool:
104         """
105         Returns:
106             True if we should save our state now or False otherwise.
107         """
108         pass
109
110     @staticmethod
111     @abstractmethod
112     def should_we_load_data(filename: str) -> bool:
113         """
114         Returns:
115             True if we should load persisted state now or False otherwise.
116         """
117         pass
118
119     @abstractmethod
120     def get_persistent_data(self) -> Any:
121         """
122         Returns:
123             The raw state data read from the filesystem.  Can be any format.
124         """
125         pass
126
127
128 class PicklingFileBasedPersistent(FileBasedPersistent):
129     """
130     A class that stores its state in a file as pickled Python objects.
131
132     Example usage::
133
134         import persistent
135
136         @persistent.persistent_autoloaded_singleton()
137         class MyClass(persistent.PicklingFileBasedPersistent):
138             def __init__(self, data: Optional[Whatever]):
139                 if data:
140                     # initialize state from data
141                 else:
142                     # if desired, initialize an "empty" object with new state.
143
144             @staticmethod
145             @overrides
146             def get_filename() -> str:
147                 return "/path/to/where/you/want/to/save/data.bin"
148
149             @staticmethod
150             @overrides
151             def should_we_save_data(filename: str) -> bool:
152                 return true_if_we_should_save_the_data_this_time()
153
154             @staticmethod
155             @overrides
156             def should_we_load_data(filename: str) -> bool:
157                 return persistent.was_file_written_within_n_seconds(whatever)
158
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.
163         c = MyClass()
164
165     """
166
167     @classmethod
168     @overrides
169     def load(cls) -> Optional[Any]:
170         filename = cls.get_filename()
171         if cls.should_we_load_data(filename):
172             logger.debug("Attempting to load state from %s", filename)
173             assert file_utils.file_is_readable(filename)
174
175             import pickle
176
177             try:
178                 with open(filename, "rb") as rf:
179                     data = pickle.load(rf)
180                     return cls(data)
181
182             except Exception as e:
183                 raise Exception(f"Failed to load {filename}.") from e
184         return None
185
186     @overrides
187     def save(self) -> bool:
188         filename = self.get_filename()
189         if self.should_we_save_data(filename):
190             logger.debug("Trying to save state in %s", filename)
191             try:
192                 import pickle
193
194                 with open(filename, "wb") as wf:
195                     pickle.dump(self.get_persistent_data(), wf, pickle.HIGHEST_PROTOCOL)
196                 return True
197             except Exception as e:
198                 raise Exception(f"Failed to save to {filename}.") from e
199         return False
200
201
202 class JsonFileBasedPersistent(FileBasedPersistent):
203     """A class that stores its state in a JSON format file.
204
205     Example usage::
206
207         import persistent
208
209         @persistent.persistent_autoloaded_singleton()
210         class MyClass(persistent.JsonFileBasedPersistent):
211             def __init__(self, data: Optional[dict[str, Any]]):
212                 # load already deserialized the JSON data for you; it's
213                 # a "cooked" JSON dict of string -> values, lists, dicts,
214                 # etc...
215                 if data:
216                     #initialize youself from data...
217                 else:
218                     # if desired, initialize an empty state object
219                     # when json_data isn't provided.
220
221             @staticmethod
222             @overrides
223             def get_filename() -> str:
224                 return "/path/to/where/you/want/to/save/data.json"
225
226             @staticmethod
227             @overrides
228             def should_we_save_data(filename: str) -> bool:
229                 return true_if_we_should_save_the_data_this_time()
230
231             @staticmethod
232             @overrides
233             def should_we_load_data(filename: str) -> bool:
234                 return persistent.was_file_written_within_n_seconds(whatever)
235
236         # Persistent will handle the plumbing to instantiate your
237         # class from its persisted state iff the
238         # :meth:`should_we_load_data` says it's ok to.  It will also
239         # persist the current in memory state to disk at program exit
240         # iff the :meth:`should_we_save_data methods` says to.
241         c = MyClass()
242     """
243
244     @classmethod
245     @overrides
246     def load(cls) -> Any:
247         filename = cls.get_filename()
248         if cls.should_we_load_data(filename):
249             logger.debug("Trying to load state from %s", filename)
250             import json
251
252             try:
253                 with open(filename, "r") as rf:
254                     lines = rf.readlines()
255
256                 # This is probably bad... but I like comments
257                 # in config files and JSON doesn't support them.  So
258                 # pre-process the buffer to remove comments thus
259                 # allowing people to add them.
260                 buf = ""
261                 for line in lines:
262                     line = re.sub(r"#.*$", "", line)
263                     buf += line
264                 json_dict = json.loads(buf)
265                 return cls(json_dict)
266
267             except Exception as e:
268                 logger.exception(e)
269                 raise Exception(f"Failed to load {filename}.") from e
270         return None
271
272     @overrides
273     def save(self) -> bool:
274         filename = self.get_filename()
275         if self.should_we_save_data(filename):
276             logger.debug("Trying to save state in %s", filename)
277             try:
278                 import json
279
280                 json_blob = json.dumps(self.get_persistent_data())
281                 with open(filename, "w") as wf:
282                     wf.writelines(json_blob)
283                 return True
284             except Exception as e:
285                 raise Exception(f"Failed to save to {filename}.") from e
286         return False
287
288
289 def was_file_written_today(filename: str) -> bool:
290     """Convenience wrapper around :meth:`was_file_written_within_n_seconds`.
291
292     Args:
293         filename: path / filename to check
294
295     Returns:
296         True if filename was written today.
297
298     >>> import os
299     >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
300     >>> os.system(f'touch {filename}')
301     0
302     >>> was_file_written_today(filename)
303     True
304     >>> os.system(f'touch -d 1974-04-15T01:02:03.99 {filename}')
305     0
306     >>> was_file_written_today(filename)
307     False
308     >>> os.system(f'/bin/rm -f {filename}')
309     0
310     >>> was_file_written_today(filename)
311     False
312     """
313     if not file_utils.does_file_exist(filename):
314         return False
315
316     mtime = file_utils.get_file_mtime_as_datetime(filename)
317     assert mtime is not None
318     now = datetime.datetime.now()
319     return mtime.month == now.month and mtime.day == now.day and mtime.year == now.year
320
321
322 def was_file_written_within_n_seconds(
323     filename: str,
324     limit_seconds: int,
325 ) -> bool:
326     """Helper for determining persisted state staleness.
327
328     Args:
329         filename: the filename to check
330         limit_seconds: how fresh, in seconds, it must be
331
332     Returns:
333         True if filename was written within the past limit_seconds
334         or False otherwise (or on error).
335
336     >>> import os
337     >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
338     >>> os.system(f'touch {filename}')
339     0
340     >>> was_file_written_within_n_seconds(filename, 60)
341     True
342     >>> import time
343     >>> time.sleep(2.0)
344     >>> was_file_written_within_n_seconds(filename, 2)
345     False
346     >>> os.system(f'/bin/rm -f {filename}')
347     0
348     >>> was_file_written_within_n_seconds(filename, 60)
349     False
350     """
351
352     if not file_utils.does_file_exist(filename):
353         return False
354
355     mtime = file_utils.get_file_mtime_as_datetime(filename)
356     assert mtime is not None
357     now = datetime.datetime.now()
358     return (now - mtime).total_seconds() <= limit_seconds
359
360
361 class PersistAtShutdown(enum.Enum):
362     """
363     An enum to describe the conditions under which state is persisted
364     to disk.  This is passed as an argument to the decorator below and
365     is used to indicate when to call :meth:`save` on a :class:`Persistent`
366     subclass.
367
368     * NEVER: never call :meth:`save`
369     * IF_NOT_LOADED: call :meth:`save` as long as we did not successfully
370       :meth:`load` its state.
371     * ALWAYS: always call :meth:`save`
372     """
373
374     NEVER = (0,)
375     IF_NOT_LOADED = (1,)
376     ALWAYS = (2,)
377
378
379 class persistent_autoloaded_singleton(object):
380     """A decorator that can be applied to a :class:`Persistent` subclass
381     (i.e.  a class with :meth:`save` and :meth:`load` methods.  The
382     decorator will intercept attempts to instantiate the class via
383     it's c'tor and, instead, invoke the class' :meth:`load` to give it a
384     chance to read state from somewhere persistent (disk, db,
385     whatever).  Subsequent calls to construt instances of the wrapped
386     class will return a single, global instance (i.e. the wrapped
387     class is must be a singleton).
388
389     If :meth:`load` fails (returns None), the c'tor is invoked with the
390     original args as a fallback.
391
392     Based upon the value of the optional argument
393     :code:`persist_at_shutdown` argument, (NEVER, IF_NOT_LOADED,
394     ALWAYS), the :meth:`save` method of the class will be invoked just
395     before program shutdown to give the class a chance to save its
396     state somewhere.
397
398     .. note::
399         The implementations of :meth:`save` and :meth:`load` and where the
400         class persists its state are details left to the :class:`Persistent`
401         implementation.  Essentially this decorator just handles the
402         plumbing of calling your save/load and appropriate times and
403         creates a transparent global singleton whose state can be
404         persisted between runs.
405
406     """
407
408     def __init__(
409         self,
410         *,
411         persist_at_shutdown: PersistAtShutdown = PersistAtShutdown.IF_NOT_LOADED,
412     ):
413         self.persist_at_shutdown = persist_at_shutdown
414         self.instance = None
415
416     def __call__(self, cls: Persistent):
417         @functools.wraps(cls)  # type: ignore
418         def _load(*args, **kwargs):
419
420             # If class has already been loaded, act like a singleton
421             # and return a reference to the one and only instance in
422             # memory.
423             if self.instance is not None:
424                 logger.debug(
425                     "Returning already instantiated singleton instance of %s.",
426                     cls.__name__,
427                 )
428                 return self.instance
429
430             # Otherwise, try to load it from persisted state.
431             was_loaded = False
432             logger.debug("Attempting to load %s from persisted state.", cls.__name__)
433             self.instance = cls.load()
434             if not self.instance:
435                 msg = "Loading from cache failed."
436                 logger.warning(msg)
437                 logger.debug("Attempting to instantiate %s directly.", cls.__name__)
438                 self.instance = cls(*args, **kwargs)
439             else:
440                 logger.debug(
441                     "Class %s was loaded from persisted state successfully.",
442                     cls.__name__,
443                 )
444                 was_loaded = True
445
446             assert self.instance is not None
447
448             if self.persist_at_shutdown is PersistAtShutdown.ALWAYS or (
449                 not was_loaded
450                 and self.persist_at_shutdown is PersistAtShutdown.IF_NOT_LOADED
451             ):
452                 logger.debug(
453                     "Scheduling a deferred called to save at process shutdown time."
454                 )
455                 atexit.register(self.instance.save)
456             return self.instance
457
458         return _load
459
460
461 if __name__ == "__main__":
462     import doctest
463
464     doctest.testmod()