Towards a more type-clean mypy check.
[pyutils.git] / src / pyutils / 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 from pyutils.files 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     """
92     Example usage:
93
94     import persistent
95
96     @persistent.persistent_autoloaded_singleton()
97     class MyClass(persistent.PicklingFileBasedPersistent):
98         def __init__(self, data: Whatever):
99             #initialize youself from data
100
101         @staticmethod
102         @overrides
103         def get_filename() -> str:
104             return "/path/to/where/you/want/to/save/data.bin"
105
106         @staticmethod
107         @overrides
108         def should_we_save_data(filename: str) -> bool:
109             return true_if_we_should_save_the_data_this_time()
110
111         @staticmethod
112         @overrides
113         def should_we_load_data(filename: str) -> bool:
114             return persistent.was_file_written_within_n_seconds(whatever)
115
116     # Persistent will handle the plumbing to instantiate your class from its
117     # persisted state iff the should_we_load_data says it's ok to.  It will
118     # also persist the current in memory state to disk at program exit iff
119     # the should_we_save_data methods says to.
120     c = MyClass()
121
122     """
123
124     @classmethod
125     @overrides
126     def load(cls) -> Optional[Any]:
127         filename = cls.get_filename()
128         if cls.should_we_load_data(filename):
129             logger.debug('Attempting to load state from %s', filename)
130             assert file_utils.file_is_readable(filename)
131
132             import pickle
133
134             try:
135                 with open(filename, 'rb') as rf:
136                     data = pickle.load(rf)
137                     return cls(data)
138
139             except Exception as e:
140                 raise Exception(f'Failed to load {filename}.') from e
141         return None
142
143     @overrides
144     def save(self) -> bool:
145         filename = self.get_filename()
146         if self.should_we_save_data(filename):
147             logger.debug('Trying to save state in %s', filename)
148             try:
149                 import pickle
150
151                 with open(filename, 'wb') as wf:
152                     pickle.dump(self.get_persistent_data(), wf, pickle.HIGHEST_PROTOCOL)
153                 return True
154             except Exception as e:
155                 raise Exception(f'Failed to save to {filename}.') from e
156         return False
157
158
159 class JsonFileBasedPersistent(FileBasedPersistent):
160     """
161     Example usage:
162
163     import persistent
164
165     @persistent.persistent_autoloaded_singleton()
166     class MyClass(persistent.JsonFileBasedPersistent):
167         def __init__(self, data: Whatever):
168             #initialize youself from data
169
170         @staticmethod
171         @overrides
172         def get_filename() -> str:
173             return "/path/to/where/you/want/to/save/data.json"
174
175         @staticmethod
176         @overrides
177         def should_we_save_data(filename: str) -> bool:
178             return true_if_we_should_save_the_data_this_time()
179
180         @staticmethod
181         @overrides
182         def should_we_load_data(filename: str) -> bool:
183             return persistent.was_file_written_within_n_seconds(whatever)
184
185     # Persistent will handle the plumbing to instantiate your class from its
186     # persisted state iff the should_we_load_data says it's ok to.  It will
187     # also persist the current in memory state to disk at program exit iff
188     # the should_we_save_data methods says to.
189     c = MyClass()
190
191     """
192
193     @classmethod
194     @overrides
195     def load(cls) -> Any:
196         filename = cls.get_filename()
197         if cls.should_we_load_data(filename):
198             logger.debug('Trying to load state from %s', filename)
199             import json
200
201             try:
202                 with open(filename, 'r') as rf:
203                     lines = rf.readlines()
204
205                 # This is probably bad... but I like comments
206                 # in config files and JSON doesn't support them.  So
207                 # pre-process the buffer to remove comments thus
208                 # allowing people to add them.
209                 buf = ''
210                 for line in lines:
211                     line = re.sub(r'#.*$', '', line)
212                     buf += line
213
214                 json_dict = json.loads(buf)
215                 return cls(json_dict)
216
217             except Exception as e:
218                 logger.exception(e)
219                 raise Exception(f'Failed to load {filename}.') from e
220         return None
221
222     @overrides
223     def save(self) -> bool:
224         filename = self.get_filename()
225         if self.should_we_save_data(filename):
226             logger.debug('Trying to save state in %s', filename)
227             try:
228                 import json
229
230                 json_blob = json.dumps(self.get_persistent_data())
231                 with open(filename, 'w') as wf:
232                     wf.writelines(json_blob)
233                 return True
234             except Exception as e:
235                 raise Exception(f'Failed to save to {filename}.') from e
236         return False
237
238
239 def was_file_written_today(filename: str) -> bool:
240     """Convenience wrapper around :meth:`was_file_written_within_n_seconds`.
241
242     Args:
243         filename: filename to check
244
245     Returns:
246         True if filename was written today.
247
248     >>> import os
249     >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
250     >>> os.system(f'touch {filename}')
251     0
252     >>> was_file_written_today(filename)
253     True
254     >>> os.system(f'touch -d 1974-04-15T01:02:03.99 {filename}')
255     0
256     >>> was_file_written_today(filename)
257     False
258     >>> os.system(f'/bin/rm -f {filename}')
259     0
260     >>> was_file_written_today(filename)
261     False
262     """
263
264     if not file_utils.does_file_exist(filename):
265         return False
266
267     mtime = file_utils.get_file_mtime_as_datetime(filename)
268     assert mtime is not None
269     now = datetime.datetime.now()
270     return mtime.month == now.month and mtime.day == now.day and mtime.year == now.year
271
272
273 def was_file_written_within_n_seconds(
274     filename: str,
275     limit_seconds: int,
276 ) -> bool:
277     """Helper for determining persisted state staleness.
278
279     Args:
280         filename: the filename to check
281         limit_seconds: how fresh, in seconds, it must be
282
283     Returns:
284         True if filename was written within the past limit_seconds
285         or False otherwise (or on error).
286
287     >>> import os
288     >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
289     >>> os.system(f'touch {filename}')
290     0
291     >>> was_file_written_within_n_seconds(filename, 60)
292     True
293     >>> import time
294     >>> time.sleep(2.0)
295     >>> was_file_written_within_n_seconds(filename, 2)
296     False
297     >>> os.system(f'/bin/rm -f {filename}')
298     0
299     >>> was_file_written_within_n_seconds(filename, 60)
300     False
301     """
302
303     if not file_utils.does_file_exist(filename):
304         return False
305
306     mtime = file_utils.get_file_mtime_as_datetime(filename)
307     assert mtime is not None
308     now = datetime.datetime.now()
309     return (now - mtime).total_seconds() <= limit_seconds
310
311
312 class PersistAtShutdown(enum.Enum):
313     """
314     An enum to describe the conditions under which state is persisted
315     to disk.  This is passed as an argument to the decorator below and
316     is used to indicate when to call :meth:`save` on a :class:`Persistent`
317     subclass.
318
319     * NEVER: never call :meth:`save`
320     * IF_NOT_LOADED: call :meth:`save` as long as we did not successfully
321       :meth:`load` its state.
322     * ALWAYS: always call :meth:`save`
323     """
324
325     NEVER = (0,)
326     IF_NOT_LOADED = (1,)
327     ALWAYS = (2,)
328
329
330 class persistent_autoloaded_singleton(object):
331     """A decorator that can be applied to a :class:`Persistent` subclass
332     (i.e.  a class with :meth:`save` and :meth:`load` methods.  The
333     decorator will intercept attempts to instantiate the class via
334     it's c'tor and, instead, invoke the class' :meth:`load` to give it a
335     chance to read state from somewhere persistent (disk, db,
336     whatever).  Subsequent calls to construt instances of the wrapped
337     class will return a single, global instance (i.e. the wrapped
338     class is a singleton).
339
340     If :meth:`load` fails (returns None), the c'tor is invoked with the
341     original args as a fallback.
342
343     Based upon the value of the optional argument
344     :code:`persist_at_shutdown` argument, (NEVER, IF_NOT_LOADED,
345     ALWAYS), the :meth:`save` method of the class will be invoked just
346     before program shutdown to give the class a chance to save its
347     state somewhere.
348
349     .. note::
350         The implementations of :meth:`save` and :meth:`load` and where the
351         class persists its state are details left to the :class:`Persistent`
352         implementation.  Essentially this decorator just handles the
353         plumbing of calling your save/load and appropriate times and
354         creates a transparent global singleton whose state can be
355         persisted between runs.
356
357     """
358
359     def __init__(
360         self,
361         *,
362         persist_at_shutdown: PersistAtShutdown = PersistAtShutdown.IF_NOT_LOADED,
363     ):
364         self.persist_at_shutdown = persist_at_shutdown
365         self.instance = None
366
367     def __call__(self, cls: Persistent):
368         @functools.wraps(cls)  # type: ignore
369         def _load(*args, **kwargs):
370
371             # If class has already been loaded, act like a singleton
372             # and return a reference to the one and only instance in
373             # memory.
374             if self.instance is not None:
375                 logger.debug(
376                     'Returning already instantiated singleton instance of %s.',
377                     cls.__name__,
378                 )
379                 return self.instance
380
381             # Otherwise, try to load it from persisted state.
382             was_loaded = False
383             logger.debug('Attempting to load %s from persisted state.', cls.__name__)
384             self.instance = cls.load()
385             if not self.instance:
386                 msg = 'Loading from cache failed.'
387                 logger.warning(msg)
388                 logger.debug('Attempting to instantiate %s directly.', cls.__name__)
389                 self.instance = cls(*args, **kwargs)
390             else:
391                 logger.debug(
392                     'Class %s was loaded from persisted state successfully.',
393                     cls.__name__,
394                 )
395                 was_loaded = True
396
397             assert self.instance is not None
398
399             if self.persist_at_shutdown is PersistAtShutdown.ALWAYS or (
400                 not was_loaded
401                 and self.persist_at_shutdown is PersistAtShutdown.IF_NOT_LOADED
402             ):
403                 logger.debug(
404                     'Scheduling a deferred called to save at process shutdown time.'
405                 )
406                 atexit.register(self.instance.save)
407             return self.instance
408
409         return _load
410
411
412 if __name__ == '__main__':
413     import doctest
414
415     doctest.testmod()