Adds a __repr__ to graph.
[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         from pyutils.typez 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         """
188         Raises:
189             Exception: failure to load from file.
190         """
191         filename = cls.get_filename()
192         if cls.should_we_load_data(filename):
193             logger.debug("Attempting to load state from %s", filename)
194             assert file_utils.is_readable(filename)
195
196             import pickle
197
198             try:
199                 with open(filename, mode="rb") as rf:
200                     data = pickle.load(rf)
201                     return cls(data)
202
203             except Exception as e:
204                 raise Exception(f"Failed to load {filename}.") from e
205         return None
206
207     @overrides
208     def save(self) -> bool:
209         """
210         Raises:
211             Exception: failure to save to file.
212         """
213         filename = self.get_filename()
214         if self.should_we_save_data(filename):
215             logger.debug("Trying to save state in %s", filename)
216             try:
217                 import pickle
218
219                 with file_utils.CreateFileWithMode(filename, 0o600, "wb") as wf:
220                     pickle.dump(self.get_persistent_data(), wf, pickle.HIGHEST_PROTOCOL)
221                 return True
222             except Exception as e:
223                 raise Exception(f"Failed to save to {filename}.") from e
224         return False
225
226
227 class JsonFileBasedPersistent(FileBasedPersistent):
228     """A class that stores its state in a JSON format file.
229
230     Example usage::
231
232         from pyutils.typez import persistent
233
234         @persistent.persistent_autoloaded_singleton()
235         class MyClass(persistent.JsonFileBasedPersistent):
236             def __init__(self, data: Optional[dict[str, Any]]):
237                 # load already deserialized the JSON data for you; it's
238                 # a "cooked" JSON dict of string -> values, lists, dicts,
239                 # etc...
240                 if data:
241                     #initialize youself from data...
242                 else:
243                     # if desired, initialize an empty state object
244                     # when json_data isn't provided.
245
246             @staticmethod
247             @overrides
248             def get_filename() -> str:
249                 return "/path/to/where/you/want/to/save/data.json"
250
251             @staticmethod
252             @overrides
253             def should_we_save_data(filename: str) -> bool:
254                 return true_if_we_should_save_the_data_this_time()
255
256             @staticmethod
257             @overrides
258             def should_we_load_data(filename: str) -> bool:
259                 return persistent.was_file_written_within_n_seconds(whatever)
260
261         # Persistent will handle the plumbing to instantiate your
262         # class from its persisted state iff the
263         # :meth:`should_we_load_data` says it's ok to.  It will also
264         # persist the current in memory state to disk at program exit
265         # iff the :meth:`should_we_save_data methods` says to.
266         c = MyClass()
267     """
268
269     @abstractmethod
270     def __init__(self, data: Optional[Any]):
271         """You should override this."""
272         pass
273
274     @classmethod
275     @overrides
276     def load(cls: type[JsonFileBasedPersistent]) -> Optional[JsonFileBasedPersistent]:
277         """
278         Raises:
279             Exception: failure to load from file.
280         """
281         filename = cls.get_filename()
282         if cls.should_we_load_data(filename):
283             logger.debug("Trying to load state from %s", filename)
284             import json
285
286             try:
287                 with open(filename, mode="r", encoding="utf-8") as rf:
288                     lines = rf.readlines()
289
290                 # This is probably bad... but I like comments
291                 # in config files and JSON doesn't support them.  So
292                 # pre-process the buffer to remove comments thus
293                 # allowing people to add them.
294                 buf = ""
295                 for line in lines:
296                     line = re.sub(r"#.*$", "", line)
297                     buf += line
298                 json_dict = json.loads(buf)
299                 return cls(json_dict)
300
301             except Exception as e:
302                 logger.exception(
303                     "Failed to load path %s; raising an exception", filename
304                 )
305                 raise Exception(f"Failed to load {filename}.") from e
306         return None
307
308     @overrides
309     def save(self) -> bool:
310         """
311         Raises:
312             Exception: failure to save to file.
313         """
314         filename = self.get_filename()
315         if self.should_we_save_data(filename):
316             logger.debug("Trying to save state in %s", filename)
317             try:
318                 import json
319
320                 json_blob = json.dumps(self.get_persistent_data())
321                 with open(filename, mode="w", encoding="utf-8") as wf:
322                     wf.writelines(json_blob)
323                 return True
324             except Exception as e:
325                 raise Exception(f"Failed to save to {filename}.") from e
326         return False
327
328
329 def was_file_written_today(filename: str) -> bool:
330     """Convenience wrapper around :meth:`was_file_written_within_n_seconds`.
331
332     Args:
333         filename: path / filename to check
334
335     Returns:
336         True if filename was written today.
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_today(filename)
343     True
344     >>> os.system(f'touch -d 1974-04-15T01:02:03.99 {filename}')
345     0
346     >>> was_file_written_today(filename)
347     False
348     >>> os.system(f'/bin/rm -f {filename}')
349     0
350     >>> was_file_written_today(filename)
351     False
352     """
353     if not file_utils.does_file_exist(filename):
354         return False
355
356     mtime = file_utils.get_file_mtime_as_datetime(filename)
357     assert mtime is not None
358     now = datetime.datetime.now()
359     return mtime.month == now.month and mtime.day == now.day and mtime.year == now.year
360
361
362 def was_file_written_within_n_seconds(
363     filename: str,
364     limit_seconds: int,
365 ) -> bool:
366     """Helper for determining persisted state staleness.
367
368     Args:
369         filename: the filename to check
370         limit_seconds: how fresh, in seconds, it must be
371
372     Returns:
373         True if filename was written within the past limit_seconds
374         or False otherwise (or on error).
375
376     >>> import os
377     >>> filename = f'/tmp/testing_persistent_py_{os.getpid()}'
378     >>> os.system(f'touch {filename}')
379     0
380     >>> was_file_written_within_n_seconds(filename, 60)
381     True
382     >>> import time
383     >>> time.sleep(2.0)
384     >>> was_file_written_within_n_seconds(filename, 2)
385     False
386     >>> os.system(f'/bin/rm -f {filename}')
387     0
388     >>> was_file_written_within_n_seconds(filename, 60)
389     False
390     """
391
392     if not file_utils.does_file_exist(filename):
393         return False
394
395     mtime = file_utils.get_file_mtime_as_datetime(filename)
396     assert mtime is not None
397     now = datetime.datetime.now()
398     return (now - mtime).total_seconds() <= limit_seconds
399
400
401 class PersistAtShutdown(enum.Enum):
402     """
403     An enum to describe the conditions under which state is persisted
404     to disk.  This is passed as an argument to the decorator below and
405     is used to indicate when to call :meth:`save` on a :class:`Persistent`
406     subclass.
407
408     * NEVER: never call :meth:`save`
409     * IF_NOT_LOADED: call :meth:`save` as long as we did not successfully
410       :meth:`load` its state.
411     * ALWAYS: always call :meth:`save`
412     """
413
414     NEVER = (0,)
415     IF_NOT_LOADED = (1,)
416     ALWAYS = (2,)
417
418
419 class persistent_autoloaded_singleton(object):
420     """A decorator that can be applied to a :class:`Persistent` subclass
421     (i.e.  a class with :meth:`save` and :meth:`load` methods.  The
422     decorator will intercept attempts to instantiate the class via
423     it's c'tor and, instead, invoke the class' :meth:`load` to give it a
424     chance to read state from somewhere persistent (disk, db,
425     whatever).  Subsequent calls to construct instances of the wrapped
426     class will return a single, global instance (i.e. the wrapped
427     class is must be a singleton).
428
429     If :meth:`load` fails (returns None), the class' c'tor is invoked
430     with the original args as a fallback.
431
432     Based upon the value of the optional argument
433     :code:`persist_at_shutdown` argument, (NEVER, IF_NOT_LOADED,
434     ALWAYS), the :meth:`save` method of the class will be invoked just
435     before program shutdown to give the class a chance to save its
436     state somewhere.
437
438     .. note::
439         The implementations of :meth:`save` and :meth:`load` and where the
440         class persists its state are details left to the :class:`Persistent`
441         implementation.  Essentially this decorator just handles the
442         plumbing of calling your save/load and appropriate times and
443         creates a transparent global singleton whose state can be
444         persisted between runs.  See example implementations such as
445         :class:`JsonFileBasedPersistent` and
446         :class:`PicklingFileBasedPersistent`.
447     """
448
449     def __init__(
450         self,
451         *,
452         persist_at_shutdown: PersistAtShutdown = PersistAtShutdown.IF_NOT_LOADED,
453     ):
454         self.persist_at_shutdown = persist_at_shutdown
455         self.instance = None
456
457     def __call__(self, cls: Persistent):
458         @functools.wraps(cls)  # type: ignore
459         def _load(*args, **kwargs):
460
461             # If class has already been loaded, act like a singleton
462             # and return a reference to the one and only instance in
463             # memory.
464             if self.instance is not None:
465                 logger.debug(
466                     "Returning already instantiated singleton instance of %s.",
467                     cls.__name__,
468                 )
469                 return self.instance
470
471             # Otherwise, try to load it from persisted state.
472             was_loaded = False
473             logger.debug("Attempting to load %s from persisted state.", cls.__name__)
474             self.instance = cls.load()
475             if not self.instance:
476                 msg = "Loading from cache failed."
477                 logger.warning(msg)
478                 logger.debug("Attempting to instantiate %s directly.", cls.__name__)
479                 self.instance = cls(*args, **kwargs)
480             else:
481                 logger.debug(
482                     "Class %s was loaded from persisted state successfully.",
483                     cls.__name__,
484                 )
485                 was_loaded = True
486
487             assert self.instance is not None
488
489             if self.persist_at_shutdown is PersistAtShutdown.ALWAYS or (
490                 not was_loaded
491                 and self.persist_at_shutdown is PersistAtShutdown.IF_NOT_LOADED
492             ):
493                 logger.debug(
494                     "Scheduling a deferred called to save at process shutdown time."
495                 )
496                 atexit.register(self.instance.save)
497             return self.instance
498
499         return _load
500
501
502 if __name__ == "__main__":
503     import doctest
504
505     doctest.testmod()