|
| 1 | +""" |
| 2 | +Save/restore for the `RE.md` dictionary |
| 3 | +======================================= |
| 4 | +
|
| 5 | +To save and restore metadata between Python sessions, we provide |
| 6 | +:class:`~apstools.utils.stored_dict.StoredDict`. This support is used to save |
| 7 | +the contents of ``RE.md`` in a YAML file when this dictionary is changed. |
| 8 | +
|
| 9 | +.. autosummary:: |
| 10 | + :nosignatures: |
| 11 | +
|
| 12 | + ~StoredDict |
| 13 | +
|
| 14 | +.. rubric:: Example |
| 15 | +
|
| 16 | +Setup :class:`~apstools.utils.stored_dict.StoredDict()` to write the contents |
| 17 | +of ``RE.md`` to file ``".re.md_dict.yml"`` (in the present working directory): |
| 18 | +
|
| 19 | +.. code-block: python |
| 20 | + :linenos: |
| 21 | +
|
| 22 | + RE = bluesky.RunEngine() |
| 23 | + RE.md = StoredDict(".re.md_dict.yml") |
| 24 | +
|
| 25 | +Within seconds of changing any key in the ``RE.md`` dictionary, the entire |
| 26 | +dictionary contents will be written to the file. |
| 27 | +
|
| 28 | +.. note:: Only changes to the top level of `RE.md` will trigger the file |
| 29 | + to be updated. Changes to lower-level structure will not trigger updates. |
| 30 | +
|
| 31 | +.. note:: The YAML file is only loaded on initial use. |
| 32 | + It is not intended for :class:`~apstools.utils.stored_dict.StoredDict()` to be |
| 33 | + used to share information between multiple instances (such as two simulataneous |
| 34 | + Bluesky sessions, each writing to the same file). |
| 35 | +
|
| 36 | + To share the ``scan_id`` (so that it increases in a monotonic sequence) |
| 37 | + between multiple sessions, consider using an EPICS PV |
| 38 | + :class:`~apstools.devices.epics_scan_id_signal.EpicsScanIdSignal()`. |
| 39 | +
|
| 40 | +.. tip:: The file could be stored in the present working directory |
| 41 | + (of the bluesky session) or could be in any directory (absolute or |
| 42 | + relative) to which the session has write access. |
| 43 | +
|
| 44 | +.. tip:: The storage model (YAML) could be changed to something else |
| 45 | + (such as EPICS PV) by changing these two static methods: |
| 46 | + :meth:`~apstools.utils.stored_dict.StoredDict.dump()` and |
| 47 | + :meth:`~apstools.utils.stored_dict.StoredDict.load()`. |
| 48 | +""" |
| 49 | + |
| 50 | +import atexit |
| 51 | +import collections.abc |
| 52 | +import datetime |
| 53 | +import json |
| 54 | +import pathlib |
| 55 | +import threading |
| 56 | +import time |
| 57 | + |
| 58 | +import yaml |
| 59 | + |
| 60 | + |
| 61 | +class StoredDict(collections.abc.MutableMapping): |
| 62 | + """ |
| 63 | + A MutableMapping which syncs it contents to storage. |
| 64 | +
|
| 65 | + The contents are stored as a single YAML file. |
| 66 | +
|
| 67 | + When an item is *mutated* it is not written to storage immediately. The |
| 68 | + mapping is written to storage afer a 'delay' period. The 'delay' is |
| 69 | + chosen long enough to allow multiple updates to the mapping before a single |
| 70 | + write but short enough to ensure prompt backup of the mapping. |
| 71 | +
|
| 72 | + Example:: |
| 73 | +
|
| 74 | + >>> import bluesky |
| 75 | + >>> RE = bluesky.RunEngine() |
| 76 | + >>> RE.md = StoredDict(".re_md_dict") # save file in pwd |
| 77 | +
|
| 78 | + .. rubric:: Static methods |
| 79 | +
|
| 80 | + All support for the YAML format is implemented in the static methods. |
| 81 | +
|
| 82 | + .. autosummary:: |
| 83 | +
|
| 84 | + ~dump |
| 85 | + ~load |
| 86 | +
|
| 87 | + .. rubric:: Other public methods |
| 88 | +
|
| 89 | + .. autosummary:: |
| 90 | +
|
| 91 | + ~flush |
| 92 | + ~popitem |
| 93 | + ~reload |
| 94 | + """ |
| 95 | + |
| 96 | + def __init__(self, file, delay=5, title=None, serializable=True): |
| 97 | + """ |
| 98 | + StoredDict : Dictionary that syncs to storage |
| 99 | +
|
| 100 | + PARAMETERS |
| 101 | +
|
| 102 | + file : str or pathlib.Path |
| 103 | + Name of file to store dictionary contents. |
| 104 | + delay : number |
| 105 | + Time delay (s) since last dictionary update to write to storage. |
| 106 | + Default: 5 seconds. |
| 107 | + title : str or None |
| 108 | + Comment to write at top of file. |
| 109 | + Default: "Written by StoredDict." |
| 110 | + serializable : bool |
| 111 | + If True, validate new dictionary entries are JSON serializable. |
| 112 | + """ |
| 113 | + self._file = pathlib.Path(file) |
| 114 | + self._delay = max(0, delay) |
| 115 | + self._title = title or f"Written by {self.__class__.__name__}." |
| 116 | + self.test_serializable = serializable |
| 117 | + |
| 118 | + self.sync_in_progress = False |
| 119 | + self._sync_deadline = time.time() |
| 120 | + self._sync_key = f"sync_agent_{id(self):x}" |
| 121 | + self._sync_loop_period = 0.005 |
| 122 | + |
| 123 | + self._cache = {} |
| 124 | + self.reload() |
| 125 | + |
| 126 | + # Write to storage (as needed) when process exits. |
| 127 | + atexit.register(self.flush) |
| 128 | + |
| 129 | + def __delitem__(self, key): |
| 130 | + """Delete dictionary value by key.""" |
| 131 | + del self._cache[key] |
| 132 | + self._queue_storage() |
| 133 | + |
| 134 | + def __getitem__(self, key): |
| 135 | + """Get dictionary value by key.""" |
| 136 | + return self._cache[key] |
| 137 | + |
| 138 | + def __iter__(self): |
| 139 | + """Iterate over the dictionary keys.""" |
| 140 | + yield from self._cache |
| 141 | + |
| 142 | + def __len__(self): |
| 143 | + """Number of keys in the dictionary.""" |
| 144 | + return len(self._cache) |
| 145 | + |
| 146 | + def __repr__(self): |
| 147 | + """representation of this object.""" |
| 148 | + return f"<{self.__class__.__name__} {dict(self)!r}>" |
| 149 | + |
| 150 | + def __setitem__(self, key, value): |
| 151 | + """Write to the dictionary.""" |
| 152 | + if self.test_serializable: |
| 153 | + json.dumps({key: value}) |
| 154 | + |
| 155 | + self._cache[key] = value # Store the new (or revised) content. |
| 156 | + self._queue_storage() |
| 157 | + |
| 158 | + def _delayed_sync_to_storage(self): |
| 159 | + """ |
| 160 | + Sync the metadata to storage. |
| 161 | +
|
| 162 | + Start a time-delay thread. New writes to the metadata dictionary will |
| 163 | + extend the deadline. Sync once the deadline is reached. |
| 164 | + """ |
| 165 | + |
| 166 | + def sync_agent(): |
| 167 | + """Threaded task.""" |
| 168 | + self.sync_in_progress = True |
| 169 | + while time.time() < self._sync_deadline: |
| 170 | + time.sleep(self._sync_loop_period) |
| 171 | + self.sync_in_progress = False |
| 172 | + |
| 173 | + StoredDict.dump(self._file, self._cache, title=self._title) |
| 174 | + |
| 175 | + thred = threading.Thread(target=sync_agent) |
| 176 | + thred.start() |
| 177 | + |
| 178 | + def _queue_storage(self): |
| 179 | + """Set timer to store the revised dict.""" |
| 180 | + # Reset the deadline. |
| 181 | + self._sync_deadline = time.time() + self._delay |
| 182 | + |
| 183 | + if not self.sync_in_progress: |
| 184 | + # Start the sync_agent (thread). |
| 185 | + self._delayed_sync_to_storage() |
| 186 | + |
| 187 | + def flush(self): |
| 188 | + """Force a write of the dictionary to disk""" |
| 189 | + if not self.sync_in_progress: |
| 190 | + StoredDict.dump(self._file, self._cache, title=self._title) |
| 191 | + self._sync_deadline = time.time() |
| 192 | + self.sync_in_progress = False |
| 193 | + |
| 194 | + def popitem(self): |
| 195 | + """ |
| 196 | + Remove and return a (key, value) pair as a 2-tuple. |
| 197 | +
|
| 198 | + Pairs are returned in LIFO (last-in, first-out) order. |
| 199 | + Raises KeyError if the dict is empty. |
| 200 | + """ |
| 201 | + # self._queue_storage() will be called by self.__delitem__() |
| 202 | + return self._cache.popitem() |
| 203 | + |
| 204 | + def reload(self): |
| 205 | + """Read dictionary from storage.""" |
| 206 | + self._cache = StoredDict.load(self._file) |
| 207 | + |
| 208 | + @staticmethod |
| 209 | + def dump(file, contents, title=None): |
| 210 | + """Write dictionary to YAML file.""" |
| 211 | + with open(file, "w") as f: |
| 212 | + if isinstance(title, str) and len(title) > 0: |
| 213 | + f.write(f"# {title}\n") |
| 214 | + f.write(f"# Dictionary contents written: {datetime.datetime.now()}\n\n") |
| 215 | + f.write(yaml.dump(contents.copy(), indent=2)) |
| 216 | + |
| 217 | + @staticmethod |
| 218 | + def load(file): |
| 219 | + """Read dictionary from YAML file.""" |
| 220 | + file = pathlib.Path(file) |
| 221 | + md = None |
| 222 | + if file.exists(): |
| 223 | + md = yaml.load(open(file).read(), yaml.Loader) |
| 224 | + return md or {} # In case file is empty. |
0 commit comments