c2d530f172c61d1e01a97faa79add88adeaf774f
[pyutils.git] / src / pyutils / persistent.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2023, 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.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(
269                     "Failed to load path %s; raising an exception", filename
270                 )
271                 raise Exception(f"Failed to load {filename}.") from e
272         return None
273
274     @overrides
275     def save(self) -> bool:
276         filename = self.get_filename()
277         if self.should_we_save_data(filename):
278             logger.debug("Trying to save state in %s", filename)
279             try:
280                 import json
281
282                 json_blob = json.dumps(self.get_persistent_data())
283                 with open(filename, "w") as wf:
284                     wf.writelines(json_blob)
285                 return True
286             except Exception as e:
287                 raise Exception(f"Failed to save to {filename}.") from e
288         return False
289
290
291 def was_file_written_today(filename: str) -> bool:
292     """Convenience wrapper around :meth:`was_file_written_within_n_seconds`.
293
294     Args:
295         filename: path / filename to check
296
297     Returns:
298         True if filename was written today.
299
300     >>> import os
301     >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
302     >>> os.system(f'touch {filename}')
303     0
304     >>> was_file_written_today(filename)
305     True
306     >>> os.system(f'touch -d 1974-04-15T01:02:03.99 {filename}')
307     0
308     >>> was_file_written_today(filename)
309     False
310     >>> os.system(f'/bin/rm -f {filename}')
311     0
312     >>> was_file_written_today(filename)
313     False
314     """
315     if not file_utils.does_file_exist(filename):
316         return False
317
318     mtime = file_utils.get_file_mtime_as_datetime(filename)
319     assert mtime is not None
320     now = datetime.datetime.now()
321     return mtime.month == now.month and mtime.day == now.day and mtime.year == now.year
322
323
324 def was_file_written_within_n_seconds(
325     filename: str,
326     limit_seconds: int,
327 ) -> bool:
328     """Helper for determining persisted state staleness.
329
330     Args:
331         filename: the filename to check
332         limit_seconds: how fresh, in seconds, it must be
333
334     Returns:
335         True if filename was written within the past limit_seconds
336         or False otherwise (or on error).
337
338     >>> import os
339     >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
340     >>> os.system(f'touch {filename}')
341     0
342     >>> was_file_written_within_n_seconds(filename, 60)
343     True
344     >>> import time
345     >>> time.sleep(2.0)
346     >>> was_file_written_within_n_seconds(filename, 2)
347     False
348     >>> os.system(f'/bin/rm -f {filename}')
349     0
350     >>> was_file_written_within_n_seconds(filename, 60)
351     False
352     """
353
354     if not file_utils.does_file_exist(filename):
355         return False
356
357     mtime = file_utils.get_file_mtime_as_datetime(filename)
358     assert mtime is not None
359     now = datetime.datetime.now()
360     return (now - mtime).total_seconds() <= limit_seconds
361
362
363 class PersistAtShutdown(enum.Enum):
364     """
365     An enum to describe the conditions under which state is persisted
366     to disk.  This is passed as an argument to the decorator below and
367     is used to indicate when to call :meth:`save` on a :class:`Persistent`
368     subclass.
369
370     * NEVER: never call :meth:`save`
371     * IF_NOT_LOADED: call :meth:`save` as long as we did not successfully
372       :meth:`load` its state.
373     * ALWAYS: always call :meth:`save`
374     """
375
376     NEVER = (0,)
377     IF_NOT_LOADED = (1,)
378     ALWAYS = (2,)
379
380
381 class persistent_autoloaded_singleton(object):
382     """A decorator that can be applied to a :class:`Persistent` subclass
383     (i.e.  a class with :meth:`save` and :meth:`load` methods.  The
384     decorator will intercept attempts to instantiate the class via
385     it's c'tor and, instead, invoke the class' :meth:`load` to give it a
386     chance to read state from somewhere persistent (disk, db,
387     whatever).  Subsequent calls to construt instances of the wrapped
388     class will return a single, global instance (i.e. the wrapped
389     class is must be a singleton).
390
391     If :meth:`load` fails (returns None), the c'tor is invoked with the
392     original args as a fallback.
393
394     Based upon the value of the optional argument
395     :code:`persist_at_shutdown` argument, (NEVER, IF_NOT_LOADED,
396     ALWAYS), the :meth:`save` method of the class will be invoked just
397     before program shutdown to give the class a chance to save its
398     state somewhere.
399
400     .. note::
401         The implementations of :meth:`save` and :meth:`load` and where the
402         class persists its state are details left to the :class:`Persistent`
403         implementation.  Essentially this decorator just handles the
404         plumbing of calling your save/load and appropriate times and
405         creates a transparent global singleton whose state can be
406         persisted between runs.
407
408     """
409
410     def __init__(
411         self,
412         *,
413         persist_at_shutdown: PersistAtShutdown = PersistAtShutdown.IF_NOT_LOADED,
414     ):
415         self.persist_at_shutdown = persist_at_shutdown
416         self.instance = None
417
418     def __call__(self, cls: Persistent):
419         @functools.wraps(cls)  # type: ignore
420         def _load(*args, **kwargs):
421
422             # If class has already been loaded, act like a singleton
423             # and return a reference to the one and only instance in
424             # memory.
425             if self.instance is not None:
426                 logger.debug(
427                     "Returning already instantiated singleton instance of %s.",
428                     cls.__name__,
429                 )
430                 return self.instance
431
432             # Otherwise, try to load it from persisted state.
433             was_loaded = False
434             logger.debug("Attempting to load %s from persisted state.", cls.__name__)
435             self.instance = cls.load()
436             if not self.instance:
437                 msg = "Loading from cache failed."
438                 logger.warning(msg)
439                 logger.debug("Attempting to instantiate %s directly.", cls.__name__)
440                 self.instance = cls(*args, **kwargs)
441             else:
442                 logger.debug(
443                     "Class %s was loaded from persisted state successfully.",
444                     cls.__name__,
445                 )
446                 was_loaded = True
447
448             assert self.instance is not None
449
450             if self.persist_at_shutdown is PersistAtShutdown.ALWAYS or (
451                 not was_loaded
452                 and self.persist_at_shutdown is PersistAtShutdown.IF_NOT_LOADED
453             ):
454                 logger.debug(
455                     "Scheduling a deferred called to save at process shutdown time."
456                 )
457                 atexit.register(self.instance.save)
458             return self.instance
459
460         return _load
461
462
463 if __name__ == "__main__":
464     import doctest
465
466     doctest.testmod()