Easier and more self documenting patterns for loading/saving Persistent
[python_utils.git] / persistent.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2022, Scott Gasch
4
5 """A :class:`Persistent` is just a class with a load and save method.  This
6 module defines the :class:`Persistent` base and a decorator that can be used to
7 create a persistent singleton that autoloads and autosaves."""
8
9 import atexit
10 import datetime
11 import enum
12 import functools
13 import logging
14 import re
15 from abc import ABC, abstractmethod
16 from typing import Any, Optional
17
18 from overrides import overrides
19
20 import file_utils
21
22 logger = logging.getLogger(__name__)
23
24
25 class Persistent(ABC):
26     """
27     A base class of an object with a load/save method.  Classes that are
28     decorated with :code:`@persistent_autoloaded_singleton` should subclass
29     this and implement their :meth:`save` and :meth:`load` methods.
30     """
31
32     @abstractmethod
33     def save(self) -> bool:
34         """
35         Save this thing somewhere that you'll remember when someone calls
36         :meth:`load` later on in a way that makes sense to your code.
37         """
38         pass
39
40     @classmethod
41     @abstractmethod
42     def load(cls) -> Any:
43         """Load this thing from somewhere and give back an instance which
44         will become the global singleton and which may (see
45         below) be saved (via :meth:`save`) at program exit time.
46
47         Oh, in case this is handy, here's a reminder how to write a
48         factory method that doesn't call the c'tor in python::
49
50             @classmethod
51             def load_from_somewhere(cls, somewhere):
52                 # Note: __new__ does not call __init__.
53                 obj = cls.__new__(cls)
54
55                 # Don't forget to call any polymorphic base class initializers
56                 super(MyClass, obj).__init__()
57
58                 # Load the piece(s) of obj that you want to from somewhere.
59                 obj._state = load_from_somewhere(somewhere)
60                 return obj
61         """
62         pass
63
64
65 class FileBasedPersistent(Persistent):
66     """A Persistent that uses a file to save/load data and knows the conditions
67     under which the state should be saved/loaded."""
68
69     @staticmethod
70     @abstractmethod
71     def get_filename() -> str:
72         """Since this class saves/loads to/from a file, what's its full path?"""
73         pass
74
75     @staticmethod
76     @abstractmethod
77     def should_we_save_data(filename: str) -> bool:
78         pass
79
80     @staticmethod
81     @abstractmethod
82     def should_we_load_data(filename: str) -> bool:
83         pass
84
85     @abstractmethod
86     def get_persistent_data(self) -> Any:
87         pass
88
89
90 class PicklingFileBasedPersistent(FileBasedPersistent):
91     @classmethod
92     @overrides
93     def load(cls) -> Optional[Any]:
94         filename = cls.get_filename()
95         if cls.should_we_load_data(filename):
96             logger.debug('Attempting to load state from %s', filename)
97
98             import pickle
99
100             try:
101                 with open(filename, 'rb') as rf:
102                     data = pickle.load(rf)
103                     return cls(data)
104
105             except Exception as e:
106                 raise Exception(f'Failed to load {filename}.') from e
107         return None
108
109     @overrides
110     def save(self) -> bool:
111         filename = self.get_filename()
112         if self.should_we_save_data(filename):
113             logger.debug('Trying to save state in %s', filename)
114             try:
115                 import pickle
116
117                 with open(filename, 'wb') as wf:
118                     pickle.dump(self.get_persistent_data(), wf, pickle.HIGHEST_PROTOCOL)
119                 return True
120             except Exception as e:
121                 raise Exception(f'Failed to save to {filename}.') from e
122         return False
123
124
125 class JsonFileBasedPersistent(FileBasedPersistent):
126     @classmethod
127     @overrides
128     def load(cls) -> Any:
129         filename = cls.get_filename()
130         if cls.should_we_load_data(filename):
131             logger.debug('Trying to load state from %s', filename)
132             import json
133
134             try:
135                 with open(filename, 'r') as rf:
136                     lines = rf.readlines()
137
138                 # This is probably bad... but I like comments
139                 # in config files and JSON doesn't support them.  So
140                 # pre-process the buffer to remove comments thus
141                 # allowing people to add them.
142                 buf = ''
143                 for line in lines:
144                     line = re.sub(r'#.*$', '', line)
145                     buf += line
146
147                 json_dict = json.loads(buf)
148                 return cls(json_dict)
149
150             except Exception as e:
151                 logger.exception(e)
152                 raise Exception(f'Failed to load {filename}.') from e
153         return None
154
155     @overrides
156     def save(self) -> bool:
157         filename = self.get_filename()
158         if self.should_we_save_data(filename):
159             logger.debug('Trying to save state in %s', filename)
160             try:
161                 import json
162
163                 json_blob = json.dumps(self.get_persistent_data())
164                 with open(filename, 'w') as wf:
165                     wf.writelines(json_blob)
166                 return True
167             except Exception as e:
168                 raise Exception(f'Failed to save to {filename}.') from e
169         return False
170
171
172 def was_file_written_today(filename: str) -> bool:
173     """Convenience wrapper around :meth:`was_file_written_within_n_seconds`.
174
175     Args:
176         filename: filename to check
177
178     Returns:
179         True if filename was written today.
180
181     >>> import os
182     >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
183     >>> os.system(f'touch {filename}')
184     0
185     >>> was_file_written_today(filename)
186     True
187     >>> os.system(f'touch -d 1974-04-15T01:02:03.99 {filename}')
188     0
189     >>> was_file_written_today(filename)
190     False
191     >>> os.system(f'/bin/rm -f {filename}')
192     0
193     >>> was_file_written_today(filename)
194     False
195     """
196
197     if not file_utils.does_file_exist(filename):
198         return False
199
200     mtime = file_utils.get_file_mtime_as_datetime(filename)
201     assert mtime is not None
202     now = datetime.datetime.now()
203     return mtime.month == now.month and mtime.day == now.day and mtime.year == now.year
204
205
206 def was_file_written_within_n_seconds(
207     filename: str,
208     limit_seconds: int,
209 ) -> bool:
210     """Helper for determining persisted state staleness.
211
212     Args:
213         filename: the filename to check
214         limit_seconds: how fresh, in seconds, it must be
215
216     Returns:
217         True if filename was written within the past limit_seconds
218         or False otherwise (or on error).
219
220     >>> import os
221     >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
222     >>> os.system(f'touch {filename}')
223     0
224     >>> was_file_written_within_n_seconds(filename, 60)
225     True
226     >>> import time
227     >>> time.sleep(2.0)
228     >>> was_file_written_within_n_seconds(filename, 2)
229     False
230     >>> os.system(f'/bin/rm -f {filename}')
231     0
232     >>> was_file_written_within_n_seconds(filename, 60)
233     False
234     """
235
236     if not file_utils.does_file_exist(filename):
237         return False
238
239     mtime = file_utils.get_file_mtime_as_datetime(filename)
240     assert mtime is not None
241     now = datetime.datetime.now()
242     return (now - mtime).total_seconds() <= limit_seconds
243
244
245 class PersistAtShutdown(enum.Enum):
246     """
247     An enum to describe the conditions under which state is persisted
248     to disk.  This is passed as an argument to the decorator below and
249     is used to indicate when to call :meth:`save` on a :class:`Persistent`
250     subclass.
251
252     * NEVER: never call :meth:`save`
253     * IF_NOT_LOADED: call :meth:`save` as long as we did not successfully
254       :meth:`load` its state.
255     * ALWAYS: always call :meth:`save`
256     """
257
258     NEVER = (0,)
259     IF_NOT_LOADED = (1,)
260     ALWAYS = (2,)
261
262
263 class persistent_autoloaded_singleton(object):
264     """A decorator that can be applied to a :class:`Persistent` subclass
265     (i.e.  a class with :meth:`save` and :meth:`load` methods.  The
266     decorator will intercept attempts to instantiate the class via
267     it's c'tor and, instead, invoke the class' :meth:`load` to give it a
268     chance to read state from somewhere persistent (disk, db,
269     whatever).  Subsequent calls to construt instances of the wrapped
270     class will return a single, global instance (i.e. the wrapped
271     class is a singleton).
272
273     If :meth:`load` fails (returns None), the c'tor is invoked with the
274     original args as a fallback.
275
276     Based upon the value of the optional argument
277     :code:`persist_at_shutdown` argument, (NEVER, IF_NOT_LOADED,
278     ALWAYS), the :meth:`save` method of the class will be invoked just
279     before program shutdown to give the class a chance to save its
280     state somewhere.
281
282     .. note::
283         The implementations of :meth:`save` and :meth:`load` and where the
284         class persists its state are details left to the :class:`Persistent`
285         implementation.  Essentially this decorator just handles the
286         plumbing of calling your save/load and appropriate times and
287         creates a transparent global singleton whose state can be
288         persisted between runs.
289
290     """
291
292     def __init__(
293         self,
294         *,
295         persist_at_shutdown: PersistAtShutdown = PersistAtShutdown.IF_NOT_LOADED,
296     ):
297         self.persist_at_shutdown = persist_at_shutdown
298         self.instance = None
299
300     def __call__(self, cls: Persistent):
301         @functools.wraps(cls)  # type: ignore
302         def _load(*args, **kwargs):
303
304             # If class has already been loaded, act like a singleton
305             # and return a reference to the one and only instance in
306             # memory.
307             if self.instance is not None:
308                 logger.debug(
309                     'Returning already instantiated singleton instance of %s.', cls.__name__
310                 )
311                 return self.instance
312
313             # Otherwise, try to load it from persisted state.
314             was_loaded = False
315             logger.debug('Attempting to load %s from persisted state.', cls.__name__)
316             self.instance = cls.load()
317             if not self.instance:
318                 msg = 'Loading from cache failed.'
319                 logger.warning(msg)
320                 logger.debug('Attempting to instantiate %s directly.', cls.__name__)
321                 self.instance = cls(*args, **kwargs)
322             else:
323                 logger.debug('Class %s was loaded from persisted state successfully.', cls.__name__)
324                 was_loaded = True
325
326             assert self.instance is not None
327
328             if self.persist_at_shutdown is PersistAtShutdown.ALWAYS or (
329                 not was_loaded and self.persist_at_shutdown is PersistAtShutdown.IF_NOT_LOADED
330             ):
331                 logger.debug('Scheduling a deferred called to save at process shutdown time.')
332                 atexit.register(self.instance.save)
333             return self.instance
334
335         return _load
336
337
338 if __name__ == '__main__':
339     import doctest
340
341     doctest.testmod()