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