From e7f5aa5c060c49bb842948ead489214244d1d808 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 1 Jul 2024 16:18:00 +0100 Subject: [PATCH 01/53] Add initial outline for Autosave class --- softioc/autosave.py | 107 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 softioc/autosave.py diff --git a/softioc/autosave.py b/softioc/autosave.py new file mode 100644 index 00000000..3f134465 --- /dev/null +++ b/softioc/autosave.py @@ -0,0 +1,107 @@ +import json +from pathlib import Path +from typing import Dict, List, Optional +from softioc.pythonSoftIoc import RecordWrapper +from datetime import datetime +import shutil + +SAV_SUFFIX = "softsav" +SAVB_SUFFIX = "softsavB" + + +class Autosave: + def __init__( + self, + device_name: str, + directory: str, + pvs: List[RecordWrapper], + save_period: float = 30.0, + enabled: bool = True, + backup_on_restart: bool = True + ): + self._device: str = device_name + self._directory: Path = Path(directory) # cast string to Path + self._last_saved_time = datetime.now() + if not self._directory.is_dir(): + raise RuntimeError(f"{directory} is not a valid autosave directory") + if backup_on_restart: + self.backup_sav_file() + self._enabled = enabled + self._save_period = save_period + self._pvs = {pv.name: pv for pv in pvs} + self._state: Dict[str, RecordWrapper] = {} + self._last_saved_state = {} + + def _change_directory(self, directory: str): + dir_path = Path(directory) + if dir_path.is_dir(): + self._directory = dir_path + else: + raise ValueError(f"{directory} is not a valid autosave directory") + + def backup_sav_file(self): + sav_path = self._get_current_sav_path() + if sav_path.is_file(): + shutil.copy2(sav_path, self._get_timestamped_backup_sav_path()) + + def add_pv(self, pv: RecordWrapper): + self._pvs[pv.name] = pv + + def remove_pv(self, pv: RecordWrapper): + self._pvs.pop(pv.name, None) + + def _get_state_from_device(self): + for name, pv in self._pvs.items(): + self._state[name] = pv.get() + + def _get_timestamped_backup_sav_path(self) -> Path: + sav_path = self._get_current_sav_path() + return sav_path.parent / ( + sav_path.name + self._last_saved_time.strftime("_%y%m%d-%H%M%S") + ) + + def _get_backup_save_path(self) -> Path: + return self._directory / f"{self._device}.{SAVB_SUFFIX}" + + def _get_current_sav_path(self) -> Path: + return self._directory / f"{self._device}.{SAV_SUFFIX}" + + def _update_last_saved(self): + self._last_saved_state = self._state.copy() + self._last_saved_time = datetime.now() + + def _save(self): + try: + for path in [self._get_current_sav_path(), self._get_backup_save_path()]: + with open(path, "w") as f: + json.dump(self._state, f, indent=4) + self._update_last_saved() + except Exception as e: + print(f"Could not save state to file: {e}") + + def save(self): + if not self._enabled: + print("Not saving to file as autosave adapter disabled") + return + timenow = datetime.now() + self._get_state_from_device() + if ( + (timenow - self._last_saved_time).total_seconds() > self._save_period + and self._state != self._last_saved_state # only save if changed + ): + self._save() + + def load(self, path: Optional[str] = None): + if not self._enabled: + print("Not loading from file as autosave adapter disabled") + return + sav_path = path or self._get_current_sav_path() + with open(sav_path, "r") as f: + state = json.load(f) + for name, value in state.items(): + pv = self._pvs.get(name, None) + if not pv: + print(f"{name} is not a valid autosaved PV") + continue + pv.set(value) + self._get_state_from_device() From 41ca3cd47731fb95be15d2308d71982628415714 Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 2 Jul 2024 10:34:03 +0100 Subject: [PATCH 02/53] get autosave device name from builder --- softioc/autosave.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 3f134465..cd375b0e 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -4,6 +4,7 @@ from softioc.pythonSoftIoc import RecordWrapper from datetime import datetime import shutil +from softioc import builder SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" @@ -12,14 +13,13 @@ class Autosave: def __init__( self, - device_name: str, directory: str, pvs: List[RecordWrapper], save_period: float = 30.0, enabled: bool = True, backup_on_restart: bool = True ): - self._device: str = device_name + self._device_name: str = builder.GetRecordNames().prefix[0] self._directory: Path = Path(directory) # cast string to Path self._last_saved_time = datetime.now() if not self._directory.is_dir(): @@ -61,10 +61,10 @@ def _get_timestamped_backup_sav_path(self) -> Path: ) def _get_backup_save_path(self) -> Path: - return self._directory / f"{self._device}.{SAVB_SUFFIX}" + return self._directory / f"{self._device_name}.{SAVB_SUFFIX}" def _get_current_sav_path(self) -> Path: - return self._directory / f"{self._device}.{SAV_SUFFIX}" + return self._directory / f"{self._device_name}.{SAV_SUFFIX}" def _update_last_saved(self): self._last_saved_state = self._state.copy() @@ -96,6 +96,9 @@ def load(self, path: Optional[str] = None): print("Not loading from file as autosave adapter disabled") return sav_path = path or self._get_current_sav_path() + if not sav_path or not sav_path.is_file(): + print(f"Could not load autosave values from file {sav_path}") + return with open(sav_path, "r") as f: state = json.load(f) for name, value in state.items(): From c88dc29f10366bc7dbdb1094ad85bb4ec08f3313 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 3 Jul 2024 09:59:56 +0100 Subject: [PATCH 03/53] Start autosave thread when in dispatcher init --- softioc/asyncio_dispatcher.py | 8 ++++ softioc/autosave.py | 88 +++++++++++++++++++++++----------- softioc/cothread_dispatcher.py | 10 +++- softioc/device.py | 3 ++ softioc/pythonSoftIoc.py | 2 +- 5 files changed, 82 insertions(+), 29 deletions(-) diff --git a/softioc/asyncio_dispatcher.py b/softioc/asyncio_dispatcher.py index d4886b5f..5129cb61 100644 --- a/softioc/asyncio_dispatcher.py +++ b/softioc/asyncio_dispatcher.py @@ -4,6 +4,7 @@ import threading import atexit import signal +from . import autosave class AsyncioDispatcher: def __init__(self, loop=None, debug=False): @@ -41,6 +42,13 @@ def __init__(self, loop=None, debug=False): raise ValueError("Provided asyncio event loop is not running") else: self.loop = loop + # set up autosave thread + autosaver = autosave.Autosave() + self.__autosave_worker = threading.Thread( + target=autosaver.loop, + ) + self.__autosave_worker.daemon = True + self.__autosave_worker.start() def close(self): if self.__atexit is not None: diff --git a/softioc/autosave.py b/softioc/autosave.py index cd375b0e..4e495ea1 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -1,41 +1,66 @@ import json from pathlib import Path from typing import Dict, List, Optional -from softioc.pythonSoftIoc import RecordWrapper from datetime import datetime import shutil -from softioc import builder +from softioc.device_core import LookupRecordList +import time +import threading SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" +def configure(device=None, directory=None, save_period=None, poll_period=None): + Autosave.poll_period = poll_period or Autosave.poll_period + Autosave.save_period = save_period or Autosave.save_period + if device is None and Autosave.device_name is None: + from .builder import GetRecordNames + Autosave.device_name = GetRecordNames().prefix[0] + else: + Autosave.device_name = device + if directory is None and Autosave.directory is None: + raise RuntimeError("Autosave directory is not known, " + "call autosave.configure() with directory keyword argument") + else: + Autosave.directory = Path(directory) class Autosave: + _instance = None + poll_period = 1.0 + save_period = 30.0 + device_name = None + directory = None + enabled = True + backup_on_restart = True + def __init__( self, - directory: str, - pvs: List[RecordWrapper], - save_period: float = 30.0, - enabled: bool = True, - backup_on_restart: bool = True ): - self._device_name: str = builder.GetRecordNames().prefix[0] - self._directory: Path = Path(directory) # cast string to Path + if not self.directory: + raise RuntimeError("Autosave directory is not known, " + "call autosave.configure() with directory keyword argument") + if not self.device_name: + raise RuntimeError("Device name is not known to autosave thread, " + "call autosave.configure() with device keyword argument") self._last_saved_time = datetime.now() - if not self._directory.is_dir(): + if not self.directory.is_dir(): raise RuntimeError(f"{directory} is not a valid autosave directory") - if backup_on_restart: + if self.backup_on_restart: self.backup_sav_file() - self._enabled = enabled - self._save_period = save_period - self._pvs = {pv.name: pv for pv in pvs} - self._state: Dict[str, RecordWrapper] = {} + self.get_autosave_pvs() + self._state = {} self._last_saved_state = {} + self._started = False + if self.enabled: + self.load() # load at startup if enabled + + def get_autosave_pvs(self): + self._pvs = {name: pv for name, pv in LookupRecordList() if pv.autosave} def _change_directory(self, directory: str): dir_path = Path(directory) if dir_path.is_dir(): - self._directory = dir_path + self.directory = dir_path else: raise ValueError(f"{directory} is not a valid autosave directory") @@ -44,27 +69,29 @@ def backup_sav_file(self): if sav_path.is_file(): shutil.copy2(sav_path, self._get_timestamped_backup_sav_path()) - def add_pv(self, pv: RecordWrapper): + def add_pv(self, pv): + pv.autosave = True self._pvs[pv.name] = pv - def remove_pv(self, pv: RecordWrapper): + def remove_pv(self, pv): + pv.autosave = False self._pvs.pop(pv.name, None) def _get_state_from_device(self): for name, pv in self._pvs.items(): self._state[name] = pv.get() - def _get_timestamped_backup_sav_path(self) -> Path: + def _get_timestamped_backup_sav_path(self): sav_path = self._get_current_sav_path() return sav_path.parent / ( sav_path.name + self._last_saved_time.strftime("_%y%m%d-%H%M%S") ) - def _get_backup_save_path(self) -> Path: - return self._directory / f"{self._device_name}.{SAVB_SUFFIX}" + def _get_backup_save_path(self): + return self.directory / f"{self.device_name}.{SAVB_SUFFIX}" - def _get_current_sav_path(self) -> Path: - return self._directory / f"{self._device_name}.{SAV_SUFFIX}" + def _get_current_sav_path(self): + return self.directory / f"{self.device_name}.{SAV_SUFFIX}" def _update_last_saved(self): self._last_saved_state = self._state.copy() @@ -80,19 +107,19 @@ def _save(self): print(f"Could not save state to file: {e}") def save(self): - if not self._enabled: + if not self.enabled: print("Not saving to file as autosave adapter disabled") return timenow = datetime.now() self._get_state_from_device() if ( - (timenow - self._last_saved_time).total_seconds() > self._save_period + (timenow - self._last_saved_time).total_seconds() > self.save_period and self._state != self._last_saved_state # only save if changed ): self._save() - def load(self, path: Optional[str] = None): - if not self._enabled: + def load(self, path = None): + if not self.enabled: print("Not loading from file as autosave adapter disabled") return sav_path = path or self._get_current_sav_path() @@ -108,3 +135,10 @@ def load(self, path: Optional[str] = None): continue pv.set(value) self._get_state_from_device() + + def loop(self): + if not self._pvs: + return # end thread if no PVs to save + while True: + time.sleep(self.poll_period) + self.save() diff --git a/softioc/cothread_dispatcher.py b/softioc/cothread_dispatcher.py index 3f9d23b8..4a7ae6a6 100644 --- a/softioc/cothread_dispatcher.py +++ b/softioc/cothread_dispatcher.py @@ -1,4 +1,5 @@ - +import threading +from . import autosave class CothreadDispatcher: def __init__(self, dispatcher = None): """A dispatcher for `cothread` based IOCs, suitable to be passed to @@ -20,6 +21,13 @@ def __init__(self, dispatcher = None): self.__dispatcher = dispatcher self.wait_for_quit = cothread.WaitForQuit + # set up autosave thread + autosaver = autosave.Autosave() + self.__autosave_worker = threading.Thread( + target=autosaver.loop, + ) + self.__autosave_worker.daemon = True + self.__autosave_worker.start() def __call__( self, diff --git a/softioc/device.py b/softioc/device.py index 8c133fad..4efb8572 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -109,6 +109,7 @@ class ProcessDeviceSupportIn(ProcessDeviceSupportCore): _link_ = 'INP' def __init__(self, name, **kargs): + self.autosave = kargs.pop("autosave", False) if 'initial_value' in kargs: value = self._value_to_epics(kargs.pop('initial_value')) else: @@ -159,6 +160,7 @@ class ProcessDeviceSupportOut(ProcessDeviceSupportCore): _link_ = 'OUT' def __init__(self, name, **kargs): + self.autosave = kargs.pop('autosave', False) on_update = kargs.pop('on_update', None) on_update_name = kargs.pop('on_update_name', None) # At most one of on_update and on_update_name can be specified @@ -432,6 +434,7 @@ class WaveformBase(ProcessDeviceSupportCore): def __init__(self, name, _wf_nelm, _wf_dtype, **kargs): + self.autosave = kargs.pop("autosave", False) self._dtype = _wf_dtype self._nelm = _wf_nelm self.__super.__init__(name, **kargs) diff --git a/softioc/pythonSoftIoc.py b/softioc/pythonSoftIoc.py index 906ffa86..46191700 100644 --- a/softioc/pythonSoftIoc.py +++ b/softioc/pythonSoftIoc.py @@ -24,7 +24,7 @@ def __init__(self, builder, device, name, **fields): # have to maintain this separately from the corresponding device list. DeviceKeywords = [ 'on_update', 'on_update_name', 'validate', 'always_update', - 'initial_value', '_wf_nelm', '_wf_dtype', 'blocking'] + 'initial_value', '_wf_nelm', '_wf_dtype', 'blocking', 'autosave'] device_kargs = {} for keyword in DeviceKeywords: if keyword in fields: From a7a1084c57aad9ced4c0176bea3fec5a05e41d05 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 3 Jul 2024 13:23:52 +0100 Subject: [PATCH 04/53] remove autosave._state member --- softioc/autosave.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 4e495ea1..6e1a4cad 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -44,7 +44,7 @@ def __init__( "call autosave.configure() with device keyword argument") self._last_saved_time = datetime.now() if not self.directory.is_dir(): - raise RuntimeError(f"{directory} is not a valid autosave directory") + raise RuntimeError(f"{self.directory} is not a valid autosave directory") if self.backup_on_restart: self.backup_sav_file() self.get_autosave_pvs() @@ -77,10 +77,6 @@ def remove_pv(self, pv): pv.autosave = False self._pvs.pop(pv.name, None) - def _get_state_from_device(self): - for name, pv in self._pvs.items(): - self._state[name] = pv.get() - def _get_timestamped_backup_sav_path(self): sav_path = self._get_current_sav_path() return sav_path.parent / ( @@ -93,16 +89,13 @@ def _get_backup_save_path(self): def _get_current_sav_path(self): return self.directory / f"{self.device_name}.{SAV_SUFFIX}" - def _update_last_saved(self): - self._last_saved_state = self._state.copy() - self._last_saved_time = datetime.now() - - def _save(self): + def _save(self, state): try: for path in [self._get_current_sav_path(), self._get_backup_save_path()]: with open(path, "w") as f: - json.dump(self._state, f, indent=4) - self._update_last_saved() + json.dump(state, f, indent=4) + self._last_saved_state = state.copy() # do we need to copy? + self._last_saved_time = datetime.now() except Exception as e: print(f"Could not save state to file: {e}") @@ -111,12 +104,13 @@ def save(self): print("Not saving to file as autosave adapter disabled") return timenow = datetime.now() - self._get_state_from_device() + state = {name: pv.get() for name, pv in self._pvs.items()} + if ( (timenow - self._last_saved_time).total_seconds() > self.save_period - and self._state != self._last_saved_state # only save if changed + and state != self._last_saved_state # only save if changed ): - self._save() + self._save(state) def load(self, path = None): if not self.enabled: @@ -134,7 +128,6 @@ def load(self, path = None): print(f"{name} is not a valid autosaved PV") continue pv.set(value) - self._get_state_from_device() def loop(self): if not self._pvs: From db187c4fe8682a3ac7e480d28640ee7ff60dcd68 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 3 Jul 2024 15:53:07 +0100 Subject: [PATCH 05/53] Remove poll_period from autosave, alter autosave.configure logic --- softioc/autosave.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 6e1a4cad..c6af13b5 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -10,12 +10,12 @@ SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" -def configure(device=None, directory=None, save_period=None, poll_period=None): - Autosave.poll_period = poll_period or Autosave.poll_period +def configure(directory=None, save_period=None, device=None): Autosave.save_period = save_period or Autosave.save_period - if device is None and Autosave.device_name is None: - from .builder import GetRecordNames - Autosave.device_name = GetRecordNames().prefix[0] + if device is None: + if Autosave.device_name is None: + from .builder import GetRecordNames + Autosave.device_name = GetRecordNames().prefix[0] else: Autosave.device_name = device if directory is None and Autosave.directory is None: @@ -26,7 +26,6 @@ def configure(device=None, directory=None, save_period=None, poll_period=None): class Autosave: _instance = None - poll_period = 1.0 save_period = 30.0 device_name = None directory = None @@ -103,13 +102,8 @@ def save(self): if not self.enabled: print("Not saving to file as autosave adapter disabled") return - timenow = datetime.now() state = {name: pv.get() for name, pv in self._pvs.items()} - - if ( - (timenow - self._last_saved_time).total_seconds() > self.save_period - and state != self._last_saved_state # only save if changed - ): + if state != self._last_saved_state: self._save(state) def load(self, path = None): @@ -133,5 +127,5 @@ def loop(self): if not self._pvs: return # end thread if no PVs to save while True: - time.sleep(self.poll_period) + time.sleep(self.save_period) self.save() From 6180e55126caa2b7ceacda6eab77bcf0a3162345 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 3 Jul 2024 16:07:56 +0100 Subject: [PATCH 06/53] call stop on autosave thread at dispatcher exit lint and isort --- softioc/asyncio_dispatcher.py | 12 ++++--- softioc/autosave.py | 58 ++++++++++++++++++++++++---------- softioc/cothread_dispatcher.py | 15 +++++++-- 3 files changed, 62 insertions(+), 23 deletions(-) diff --git a/softioc/asyncio_dispatcher.py b/softioc/asyncio_dispatcher.py index 5129cb61..3563426a 100644 --- a/softioc/asyncio_dispatcher.py +++ b/softioc/asyncio_dispatcher.py @@ -1,11 +1,13 @@ import asyncio +import atexit import inspect import logging -import threading -import atexit import signal +import threading + from . import autosave + class AsyncioDispatcher: def __init__(self, loop=None, debug=False): """A dispatcher for `asyncio` based IOCs, suitable to be passed to @@ -43,9 +45,9 @@ def __init__(self, loop=None, debug=False): else: self.loop = loop # set up autosave thread - autosaver = autosave.Autosave() + self.__autosave = autosave.Autosave() self.__autosave_worker = threading.Thread( - target=autosaver.loop, + target=self.__autosave.loop, ) self.__autosave_worker.daemon = True self.__autosave_worker.start() @@ -80,6 +82,8 @@ def __shutdown(self): self.loop.call_soon_threadsafe(self.__interrupt.set) self.__worker.join() self.__worker = None + self.__autosave.stop() + self.__autosave_worker.join() def __call__( self, diff --git a/softioc/autosave.py b/softioc/autosave.py index c6af13b5..8d3d2636 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -1,29 +1,34 @@ import json -from pathlib import Path -from typing import Dict, List, Optional -from datetime import datetime import shutil -from softioc.device_core import LookupRecordList -import time import threading +from datetime import datetime +from pathlib import Path + +from softioc.device_core import LookupRecordList SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" + def configure(directory=None, save_period=None, device=None): Autosave.save_period = save_period or Autosave.save_period if device is None: if Autosave.device_name is None: from .builder import GetRecordNames + Autosave.device_name = GetRecordNames().prefix[0] else: Autosave.device_name = device if directory is None and Autosave.directory is None: - raise RuntimeError("Autosave directory is not known, " - "call autosave.configure() with directory keyword argument") + raise RuntimeError( + "Autosave directory is not known, call " + "autosave.configure() with keyword argument " + "directory." + ) else: Autosave.directory = Path(directory) + class Autosave: _instance = None save_period = 30.0 @@ -36,20 +41,27 @@ def __init__( self, ): if not self.directory: - raise RuntimeError("Autosave directory is not known, " - "call autosave.configure() with directory keyword argument") + raise RuntimeError( + "Autosave directory is not known, call " + "autosave.configure() with keyword argument " + "directory." + ) if not self.device_name: - raise RuntimeError("Device name is not known to autosave thread, " - "call autosave.configure() with device keyword argument") + raise RuntimeError( + "Device name is not known to autosave thread, " + "call autosave.configure() with device keyword argument" + ) self._last_saved_time = datetime.now() if not self.directory.is_dir(): - raise RuntimeError(f"{self.directory} is not a valid autosave directory") + raise RuntimeError( + f"{self.directory} is not a valid autosave directory" + ) if self.backup_on_restart: self.backup_sav_file() self.get_autosave_pvs() self._state = {} self._last_saved_state = {} - self._started = False + self._stop_event = threading.Event() if self.enabled: self.load() # load at startup if enabled @@ -90,7 +102,10 @@ def _get_current_sav_path(self): def _save(self, state): try: - for path in [self._get_current_sav_path(), self._get_backup_save_path()]: + for path in [ + self._get_current_sav_path(), + self._get_backup_save_path() + ]: with open(path, "w") as f: json.dump(state, f, indent=4) self._last_saved_state = state.copy() # do we need to copy? @@ -106,7 +121,7 @@ def save(self): if state != self._last_saved_state: self._save(state) - def load(self, path = None): + def load(self, path=None): if not self.enabled: print("Not loading from file as autosave adapter disabled") return @@ -123,9 +138,18 @@ def load(self, path = None): continue pv.set(value) + def stop(self): + self._stop_event.set() + def loop(self): if not self._pvs: return # end thread if no PVs to save while True: - time.sleep(self.save_period) - self.save() + try: + self._stop_event.wait(timeout=self.save_period) + except TimeoutError: + # No stop requested, we should save and continue + self.save() + else: + # Stop requested + return diff --git a/softioc/cothread_dispatcher.py b/softioc/cothread_dispatcher.py index 4a7ae6a6..fdc8a55c 100644 --- a/softioc/cothread_dispatcher.py +++ b/softioc/cothread_dispatcher.py @@ -1,5 +1,9 @@ +import atexit import threading + from . import autosave + + class CothreadDispatcher: def __init__(self, dispatcher = None): """A dispatcher for `cothread` based IOCs, suitable to be passed to @@ -14,6 +18,7 @@ def __init__(self, dispatcher = None): # Import here to ensure we don't instantiate any of cothread's # global state unless we have to import cothread + # Create our own cothread callback queue so that our callbacks # processing doesn't interfere with other callback processing. self.__dispatcher = cothread.cothread._Callback() @@ -21,10 +26,12 @@ def __init__(self, dispatcher = None): self.__dispatcher = dispatcher self.wait_for_quit = cothread.WaitForQuit + self.__atexit = atexit.register(self.__shutdown) + # set up autosave thread - autosaver = autosave.Autosave() + self.__autosave = autosave.Autosave() self.__autosave_worker = threading.Thread( - target=autosaver.loop, + target=self.__autosave.loop, ) self.__autosave_worker.daemon = True self.__autosave_worker.start() @@ -40,3 +47,7 @@ def wrapper(): if completion: completion(*completion_args) self.__dispatcher(wrapper) + + def __shutdown(self): + self.__autosave.stop() + self.__autosave_worker.join() \ No newline at end of file From 5e060297f6704c3c9efa9b9776925bbef916bac5 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 4 Jul 2024 09:32:15 +0100 Subject: [PATCH 07/53] Fix autosave loop logic, allow autosave to be set after pv init but before LoadDatabase set autosave attribute in ProcessDeviceSupportCore init --- softioc/autosave.py | 36 ++++++++++++++++++++---------------- softioc/device.py | 10 +++++++--- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 8d3d2636..b15398e0 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -28,18 +28,23 @@ def configure(directory=None, save_period=None, device=None): else: Autosave.directory = Path(directory) +def set_autosave(pv, value=True): + if value: + Autosave.add_pv(pv) + else: + Autosave.remove_pv(pv) + class Autosave: _instance = None + _pvs = {} save_period = 30.0 device_name = None directory = None enabled = True backup_on_restart = True - def __init__( - self, - ): + def __init__(self): if not self.directory: raise RuntimeError( "Autosave directory is not known, call " @@ -80,13 +85,15 @@ def backup_sav_file(self): if sav_path.is_file(): shutil.copy2(sav_path, self._get_timestamped_backup_sav_path()) - def add_pv(self, pv): - pv.autosave = True - self._pvs[pv.name] = pv + @classmethod + def add_pv(cls, pv): + pv.set_autosave(True) + cls._pvs[pv.name] = pv - def remove_pv(self, pv): - pv.autosave = False - self._pvs.pop(pv.name, None) + @classmethod + def remove_pv(cls, pv): + pv.set_autosave(False) + cls._pvs.pop(pv.name, None) def _get_timestamped_backup_sav_path(self): sav_path = self._get_current_sav_path() @@ -145,11 +152,8 @@ def loop(self): if not self._pvs: return # end thread if no PVs to save while True: - try: - self._stop_event.wait(timeout=self.save_period) - except TimeoutError: - # No stop requested, we should save and continue - self.save() - else: - # Stop requested + self._stop_event.wait(timeout=self.save_period) + if self._stop_event.is_set(): # Stop requested return + else: # No stop requested, we should save and continue + self.save() diff --git a/softioc/device.py b/softioc/device.py index 4efb8572..75169d05 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -53,6 +53,13 @@ class ProcessDeviceSupportCore(DeviceSupportCore, RecordLookup): # from record init or processing _epics_rc_ = EPICS_OK + # all record types can support autosave + def __init__(self, name, **kargs): + self.autosave = kargs.pop("autosave", False) + self.__super.__init__(name, **kargs) + + def set_autosave(self, value): + self.autosave = value # Most subclasses (all except waveforms) define a ctypes constructor for the # underlying EPICS compatible value. @@ -109,7 +116,6 @@ class ProcessDeviceSupportIn(ProcessDeviceSupportCore): _link_ = 'INP' def __init__(self, name, **kargs): - self.autosave = kargs.pop("autosave", False) if 'initial_value' in kargs: value = self._value_to_epics(kargs.pop('initial_value')) else: @@ -160,7 +166,6 @@ class ProcessDeviceSupportOut(ProcessDeviceSupportCore): _link_ = 'OUT' def __init__(self, name, **kargs): - self.autosave = kargs.pop('autosave', False) on_update = kargs.pop('on_update', None) on_update_name = kargs.pop('on_update_name', None) # At most one of on_update and on_update_name can be specified @@ -434,7 +439,6 @@ class WaveformBase(ProcessDeviceSupportCore): def __init__(self, name, _wf_nelm, _wf_dtype, **kargs): - self.autosave = kargs.pop("autosave", False) self._dtype = _wf_dtype self._nelm = _wf_nelm self.__super.__init__(name, **kargs) From 4359357d81a94e95f865626c564d427b0f386fdb Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 4 Jul 2024 09:59:29 +0100 Subject: [PATCH 08/53] Add load_req_file util function --- softioc/autosave.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index b15398e0..32f9335b 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -4,7 +4,7 @@ from datetime import datetime from pathlib import Path -from softioc.device_core import LookupRecordList +from softioc.device_core import LookupRecord, LookupRecordList SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" @@ -34,6 +34,17 @@ def set_autosave(pv, value=True): else: Autosave.remove_pv(pv) +def load_req_file(file, override=False): + with open(file, "r") as f: + pv_names = [name.strip() for name in f.readlines()] + if not override: + for pv_name in pv_names: + pv = LookupRecord(pv_name) + set_autosave(pv, True) + else: # explicitly set autosave for False if pv not in file + for pv_name, pv in LookupRecordList(): + set_autosave(pv, pv_name in pv_names) + class Autosave: _instance = None @@ -88,12 +99,12 @@ def backup_sav_file(self): @classmethod def add_pv(cls, pv): pv.set_autosave(True) - cls._pvs[pv.name] = pv + cls._pvs[pv._name] = pv @classmethod def remove_pv(cls, pv): pv.set_autosave(False) - cls._pvs.pop(pv.name, None) + cls._pvs.pop(pv._name, None) def _get_timestamped_backup_sav_path(self): sav_path = self._get_current_sav_path() From 4760487be95ebbc35dca596d5be5ab2e74a5b0c7 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 4 Jul 2024 13:28:33 +0100 Subject: [PATCH 09/53] represent ndarrays as list in yaml to support waveform autosave --- softioc/autosave.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 32f9335b..b3913020 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -1,14 +1,19 @@ -import json +import yaml import shutil import threading from datetime import datetime from pathlib import Path +from numpy import ndarray from softioc.device_core import LookupRecord, LookupRecordList SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" +def _ndarray_representer(dumper, array): + return dumper.represent_sequence( + "tag:yaml.org,2002:seq", array.tolist(), flow_style=True + ) def configure(directory=None, save_period=None, device=None): Autosave.save_period = save_period or Autosave.save_period @@ -56,6 +61,9 @@ class Autosave: backup_on_restart = True def __init__(self): + yaml.add_representer( + ndarray, _ndarray_representer, Dumper=yaml.Dumper + ) if not self.directory: raise RuntimeError( "Autosave directory is not known, call " @@ -125,7 +133,7 @@ def _save(self, state): self._get_backup_save_path() ]: with open(path, "w") as f: - json.dump(state, f, indent=4) + yaml.dump(state, f, indent=4) self._last_saved_state = state.copy() # do we need to copy? self._last_saved_time = datetime.now() except Exception as e: @@ -148,7 +156,7 @@ def load(self, path=None): print(f"Could not load autosave values from file {sav_path}") return with open(sav_path, "r") as f: - state = json.load(f) + state = yaml.full_load(f) for name, value in state.items(): pv = self._pvs.get(name, None) if not pv: From 760bb1a46d3ac3d1f411674d21eb51c298adb2d9 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 4 Jul 2024 13:33:05 +0100 Subject: [PATCH 10/53] linting --- softioc/autosave.py | 2 +- softioc/cothread_dispatcher.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index b3913020..3bde3001 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -172,7 +172,7 @@ def loop(self): return # end thread if no PVs to save while True: self._stop_event.wait(timeout=self.save_period) - if self._stop_event.is_set(): # Stop requested + if self._stop_event.is_set(): # Stop requested return else: # No stop requested, we should save and continue self.save() diff --git a/softioc/cothread_dispatcher.py b/softioc/cothread_dispatcher.py index fdc8a55c..9d8277d8 100644 --- a/softioc/cothread_dispatcher.py +++ b/softioc/cothread_dispatcher.py @@ -50,4 +50,4 @@ def wrapper(): def __shutdown(self): self.__autosave.stop() - self.__autosave_worker.join() \ No newline at end of file + self.__autosave_worker.join() From 0a7e2d363d2f08e9d9cfc60bba46a67243665a54 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 5 Jul 2024 15:11:10 +0100 Subject: [PATCH 11/53] move autosave thread start to iocInit. add enable arg to autosave.configure --- softioc/asyncio_dispatcher.py | 12 ------------ softioc/autosave.py | 25 +++++++++++++++---------- softioc/cothread_dispatcher.py | 15 --------------- softioc/softioc.py | 21 ++++++++++++++++++++- 4 files changed, 35 insertions(+), 38 deletions(-) diff --git a/softioc/asyncio_dispatcher.py b/softioc/asyncio_dispatcher.py index 3563426a..4a38fd39 100644 --- a/softioc/asyncio_dispatcher.py +++ b/softioc/asyncio_dispatcher.py @@ -5,9 +5,6 @@ import signal import threading -from . import autosave - - class AsyncioDispatcher: def __init__(self, loop=None, debug=False): """A dispatcher for `asyncio` based IOCs, suitable to be passed to @@ -44,13 +41,6 @@ def __init__(self, loop=None, debug=False): raise ValueError("Provided asyncio event loop is not running") else: self.loop = loop - # set up autosave thread - self.__autosave = autosave.Autosave() - self.__autosave_worker = threading.Thread( - target=self.__autosave.loop, - ) - self.__autosave_worker.daemon = True - self.__autosave_worker.start() def close(self): if self.__atexit is not None: @@ -82,8 +72,6 @@ def __shutdown(self): self.loop.call_soon_threadsafe(self.__interrupt.set) self.__worker.join() self.__worker = None - self.__autosave.stop() - self.__autosave_worker.join() def __call__( self, diff --git a/softioc/autosave.py b/softioc/autosave.py index 3bde3001..b8e8b091 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -15,8 +15,10 @@ def _ndarray_representer(dumper, array): "tag:yaml.org,2002:seq", array.tolist(), flow_style=True ) -def configure(directory=None, save_period=None, device=None): +def configure(directory=None, save_period=None, device=None, enable=None): Autosave.save_period = save_period or Autosave.save_period + if enable is not None: + Autosave.enabled = enable if device is None: if Autosave.device_name is None: from .builder import GetRecordNames @@ -33,8 +35,8 @@ def configure(directory=None, save_period=None, device=None): else: Autosave.directory = Path(directory) -def set_autosave(pv, value=True): - if value: +def set_autosave(pv, enable=True): + if enable: Autosave.add_pv(pv) else: Autosave.remove_pv(pv) @@ -46,7 +48,7 @@ def load_req_file(file, override=False): for pv_name in pv_names: pv = LookupRecord(pv_name) set_autosave(pv, True) - else: # explicitly set autosave for False if pv not in file + else: # explicitly set autosave to False if pv not in file for pv_name, pv in LookupRecordList(): set_autosave(pv, pv_name in pv_names) @@ -170,9 +172,12 @@ def stop(self): def loop(self): if not self._pvs: return # end thread if no PVs to save - while True: - self._stop_event.wait(timeout=self.save_period) - if self._stop_event.is_set(): # Stop requested - return - else: # No stop requested, we should save and continue - self.save() + try: + while True: + self._stop_event.wait(timeout=self.save_period) + if self._stop_event.is_set(): # Stop requested + return + else: # No stop requested, we should save and continue + self.save() + except Exception as e: + print(f"Exception in autosave loop: {e}") diff --git a/softioc/cothread_dispatcher.py b/softioc/cothread_dispatcher.py index 9d8277d8..c9d14ca6 100644 --- a/softioc/cothread_dispatcher.py +++ b/softioc/cothread_dispatcher.py @@ -1,8 +1,6 @@ import atexit import threading -from . import autosave - class CothreadDispatcher: def __init__(self, dispatcher = None): @@ -26,15 +24,6 @@ def __init__(self, dispatcher = None): self.__dispatcher = dispatcher self.wait_for_quit = cothread.WaitForQuit - self.__atexit = atexit.register(self.__shutdown) - - # set up autosave thread - self.__autosave = autosave.Autosave() - self.__autosave_worker = threading.Thread( - target=self.__autosave.loop, - ) - self.__autosave_worker.daemon = True - self.__autosave_worker.start() def __call__( self, @@ -47,7 +36,3 @@ def wrapper(): if completion: completion(*completion_args) self.__dispatcher(wrapper) - - def __shutdown(self): - self.__autosave.stop() - self.__autosave_worker.join() diff --git a/softioc/softioc.py b/softioc/softioc.py index aa0a3968..e6a54be1 100644 --- a/softioc/softioc.py +++ b/softioc/softioc.py @@ -1,10 +1,11 @@ import os import sys +import threading import atexit from ctypes import * from tempfile import NamedTemporaryFile -from . import imports, device +from . import autosave, imports, device from . import cothread_dispatcher __all__ = ['dbLoadDatabase', 'iocInit', 'interactive_ioc'] @@ -16,6 +17,21 @@ def epicsAtPyExit(): imports.epicsExitCallAtExits() +def _start_autosave_thread(): + autosaver = autosave.Autosave() + worker = threading.Thread( + target=autosaver.loop, + ) + worker.daemon = True + worker.start() + return autosaver, worker + + +def _shutdown_autosave_thread(autosaver, worker): + autosaver.stop() + worker.join() + + def iocInit(dispatcher=None): '''This must be called exactly once after loading all EPICS database files. After this point the EPICS IOC is running and serving PVs. @@ -33,6 +49,9 @@ def iocInit(dispatcher=None): dispatcher = cothread_dispatcher.CothreadDispatcher() # Set the dispatcher for record processing callbacks device.dispatcher = dispatcher + + autosaver, worker = _start_autosave_thread() + atexit.register(_shutdown_autosave_thread, autosaver, worker) imports.iocInit() From dcfb4d73f4b15e590196e1bd907c65ce590df6df Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 8 Jul 2024 09:04:02 +0100 Subject: [PATCH 12/53] remove public methods for adding PVs to Autosave after Autosave init called --- softioc/asyncio_dispatcher.py | 4 +-- softioc/autosave.py | 46 +++++----------------------------- softioc/cothread_dispatcher.py | 4 --- softioc/device.py | 3 --- softioc/softioc.py | 6 ++--- 5 files changed, 10 insertions(+), 53 deletions(-) diff --git a/softioc/asyncio_dispatcher.py b/softioc/asyncio_dispatcher.py index 4a38fd39..d4886b5f 100644 --- a/softioc/asyncio_dispatcher.py +++ b/softioc/asyncio_dispatcher.py @@ -1,9 +1,9 @@ import asyncio -import atexit import inspect import logging -import signal import threading +import atexit +import signal class AsyncioDispatcher: def __init__(self, loop=None, debug=False): diff --git a/softioc/autosave.py b/softioc/autosave.py index b8e8b091..bf104e35 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -5,16 +5,18 @@ from pathlib import Path from numpy import ndarray -from softioc.device_core import LookupRecord, LookupRecordList +from softioc.device_core import LookupRecordList SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" + def _ndarray_representer(dumper, array): return dumper.represent_sequence( "tag:yaml.org,2002:seq", array.tolist(), flow_style=True ) + def configure(directory=None, save_period=None, device=None, enable=None): Autosave.save_period = save_period or Autosave.save_period if enable is not None: @@ -22,7 +24,6 @@ def configure(directory=None, save_period=None, device=None, enable=None): if device is None: if Autosave.device_name is None: from .builder import GetRecordNames - Autosave.device_name = GetRecordNames().prefix[0] else: Autosave.device_name = device @@ -35,26 +36,8 @@ def configure(directory=None, save_period=None, device=None, enable=None): else: Autosave.directory = Path(directory) -def set_autosave(pv, enable=True): - if enable: - Autosave.add_pv(pv) - else: - Autosave.remove_pv(pv) - -def load_req_file(file, override=False): - with open(file, "r") as f: - pv_names = [name.strip() for name in f.readlines()] - if not override: - for pv_name in pv_names: - pv = LookupRecord(pv_name) - set_autosave(pv, True) - else: # explicitly set autosave to False if pv not in file - for pv_name, pv in LookupRecordList(): - set_autosave(pv, pv_name in pv_names) - class Autosave: - _instance = None _pvs = {} save_period = 30.0 device_name = None @@ -84,16 +67,12 @@ def __init__(self): ) if self.backup_on_restart: self.backup_sav_file() - self.get_autosave_pvs() - self._state = {} + self._pvs = {name: pv for name, pv in LookupRecordList() if pv.autosave} self._last_saved_state = {} self._stop_event = threading.Event() if self.enabled: self.load() # load at startup if enabled - def get_autosave_pvs(self): - self._pvs = {name: pv for name, pv in LookupRecordList() if pv.autosave} - def _change_directory(self, directory: str): dir_path = Path(directory) if dir_path.is_dir(): @@ -106,16 +85,6 @@ def backup_sav_file(self): if sav_path.is_file(): shutil.copy2(sav_path, self._get_timestamped_backup_sav_path()) - @classmethod - def add_pv(cls, pv): - pv.set_autosave(True) - cls._pvs[pv._name] = pv - - @classmethod - def remove_pv(cls, pv): - pv.set_autosave(False) - cls._pvs.pop(pv._name, None) - def _get_timestamped_backup_sav_path(self): sav_path = self._get_current_sav_path() return sav_path.parent / ( @@ -136,14 +105,13 @@ def _save(self, state): ]: with open(path, "w") as f: yaml.dump(state, f, indent=4) - self._last_saved_state = state.copy() # do we need to copy? + self._last_saved_state = state.copy() self._last_saved_time = datetime.now() except Exception as e: print(f"Could not save state to file: {e}") def save(self): - if not self.enabled: - print("Not saving to file as autosave adapter disabled") + if not self.enabled or not self._pvs: return state = {name: pv.get() for name, pv in self._pvs.items()} if state != self._last_saved_state: @@ -170,8 +138,6 @@ def stop(self): self._stop_event.set() def loop(self): - if not self._pvs: - return # end thread if no PVs to save try: while True: self._stop_event.wait(timeout=self.save_period) diff --git a/softioc/cothread_dispatcher.py b/softioc/cothread_dispatcher.py index c9d14ca6..3f9d23b8 100644 --- a/softioc/cothread_dispatcher.py +++ b/softioc/cothread_dispatcher.py @@ -1,6 +1,3 @@ -import atexit -import threading - class CothreadDispatcher: def __init__(self, dispatcher = None): @@ -16,7 +13,6 @@ def __init__(self, dispatcher = None): # Import here to ensure we don't instantiate any of cothread's # global state unless we have to import cothread - # Create our own cothread callback queue so that our callbacks # processing doesn't interfere with other callback processing. self.__dispatcher = cothread.cothread._Callback() diff --git a/softioc/device.py b/softioc/device.py index 75169d05..f5bb8ef4 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -58,9 +58,6 @@ def __init__(self, name, **kargs): self.autosave = kargs.pop("autosave", False) self.__super.__init__(name, **kargs) - def set_autosave(self, value): - self.autosave = value - # Most subclasses (all except waveforms) define a ctypes constructor for the # underlying EPICS compatible value. def _value_to_epics(self, value): diff --git a/softioc/softioc.py b/softioc/softioc.py index e6a54be1..a8505f41 100644 --- a/softioc/softioc.py +++ b/softioc/softioc.py @@ -24,7 +24,7 @@ def _start_autosave_thread(): ) worker.daemon = True worker.start() - return autosaver, worker + atexit.register(_shutdown_autosave_thread, autosaver, worker) def _shutdown_autosave_thread(autosaver, worker): @@ -49,9 +49,7 @@ def iocInit(dispatcher=None): dispatcher = cothread_dispatcher.CothreadDispatcher() # Set the dispatcher for record processing callbacks device.dispatcher = dispatcher - - autosaver, worker = _start_autosave_thread() - atexit.register(_shutdown_autosave_thread, autosaver, worker) + _start_autosave_thread() imports.iocInit() From 9651a573e3c076580be62be635d61fde86d3e473 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 8 Jul 2024 10:33:19 +0100 Subject: [PATCH 13/53] Make autosave methods private, set to disabled by default if configured not called --- softioc/autosave.py | 59 ++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index bf104e35..7f7ddde5 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -17,10 +17,9 @@ def _ndarray_representer(dumper, array): ) -def configure(directory=None, save_period=None, device=None, enable=None): +def configure(directory=None, save_period=None, device=None, enabled=True): Autosave.save_period = save_period or Autosave.save_period - if enable is not None: - Autosave.enabled = enable + Autosave.enabled = enabled if device is None: if Autosave.device_name is None: from .builder import GetRecordNames @@ -39,13 +38,17 @@ def configure(directory=None, save_period=None, device=None, enable=None): class Autosave: _pvs = {} + _last_saved_state = {} + _stop_event = threading.Event() save_period = 30.0 device_name = None directory = None - enabled = True + enabled = False backup_on_restart = True def __init__(self): + if not self.enabled: + return yaml.add_representer( ndarray, _ndarray_representer, Dumper=yaml.Dumper ) @@ -66,12 +69,9 @@ def __init__(self): f"{self.directory} is not a valid autosave directory" ) if self.backup_on_restart: - self.backup_sav_file() + self._backup_sav_file() self._pvs = {name: pv for name, pv in LookupRecordList() if pv.autosave} - self._last_saved_state = {} - self._stop_event = threading.Event() - if self.enabled: - self.load() # load at startup if enabled + self._load() # load at startup if enabled def _change_directory(self, directory: str): dir_path = Path(directory) @@ -80,10 +80,12 @@ def _change_directory(self, directory: str): else: raise ValueError(f"{directory} is not a valid autosave directory") - def backup_sav_file(self): + def _backup_sav_file(self): sav_path = self._get_current_sav_path() if sav_path.is_file(): shutil.copy2(sav_path, self._get_timestamped_backup_sav_path()) + else: + print(f"Could not back up autosave {sav_path} is not a file") def _get_timestamped_backup_sav_path(self): sav_path = self._get_current_sav_path() @@ -97,27 +99,22 @@ def _get_backup_save_path(self): def _get_current_sav_path(self): return self.directory / f"{self.device_name}.{SAV_SUFFIX}" - def _save(self, state): + def _save(self): try: - for path in [ - self._get_current_sav_path(), - self._get_backup_save_path() - ]: - with open(path, "w") as f: - yaml.dump(state, f, indent=4) - self._last_saved_state = state.copy() - self._last_saved_time = datetime.now() + state = {name: pv.get() for name, pv in self._pvs.items()} + if state != self._last_saved_state: + for path in [ + self._get_current_sav_path(), + self._get_backup_save_path() + ]: + with open(path, "w") as f: + yaml.dump(state, f, indent=4) + self._last_saved_state = state.copy() + self._last_saved_time = datetime.now() except Exception as e: print(f"Could not save state to file: {e}") - def save(self): - if not self.enabled or not self._pvs: - return - state = {name: pv.get() for name, pv in self._pvs.items()} - if state != self._last_saved_state: - self._save(state) - - def load(self, path=None): + def _load(self, path=None): if not self.enabled: print("Not loading from file as autosave adapter disabled") return @@ -126,8 +123,8 @@ def load(self, path=None): print(f"Could not load autosave values from file {sav_path}") return with open(sav_path, "r") as f: - state = yaml.full_load(f) - for name, value in state.items(): + self._last_saved_state = yaml.full_load(f) + for name, value in self._last_saved_state.items(): pv = self._pvs.get(name, None) if not pv: print(f"{name} is not a valid autosaved PV") @@ -138,12 +135,14 @@ def stop(self): self._stop_event.set() def loop(self): + if not self.enabled: + return try: while True: self._stop_event.wait(timeout=self.save_period) if self._stop_event.is_set(): # Stop requested return else: # No stop requested, we should save and continue - self.save() + self._save() except Exception as e: print(f"Exception in autosave loop: {e}") From fd586bde65ef24aed5772f5b4f073cbea1226f13 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 8 Jul 2024 10:42:44 +0100 Subject: [PATCH 14/53] add docstring to autosave.configure --- softioc/autosave.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 7f7ddde5..0eaa4d8f 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -17,7 +17,21 @@ def _ndarray_representer(dumper, array): ) -def configure(directory=None, save_period=None, device=None, enabled=True): +def configure(directory=None, save_period=None, enabled=True, device=None): + '''This should be called before initialising the IOC. Configures the + autosave thread for periodic backing up of PV values. + + Args: + directory: string or Path giving directory path where autosave files + should be saved and loaded, must be supplied before iocInit if + autosave is required. + save_period: time in seconds between backups. Backups are only performed + if PV values have changed. + enabled: boolean which enables or disables autosave, set to True by + default, or False if configure not called. + device: string name of the device prefix used for naming autosave files, + automatically supplied by builder if not explicitly provided. + ''' Autosave.save_period = save_period or Autosave.save_period Autosave.enabled = enabled if device is None: @@ -85,7 +99,7 @@ def _backup_sav_file(self): if sav_path.is_file(): shutil.copy2(sav_path, self._get_timestamped_backup_sav_path()) else: - print(f"Could not back up autosave {sav_path} is not a file") + print(f"Could not back up autosave, {sav_path} is not a file") def _get_timestamped_backup_sav_path(self): sav_path = self._get_current_sav_path() From b84eee4623fbf5c26d886ded4460105c21e53a38 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 8 Jul 2024 10:47:53 +0100 Subject: [PATCH 15/53] remove unused _change_directory method --- softioc/autosave.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 0eaa4d8f..9f178c44 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -87,13 +87,6 @@ def __init__(self): self._pvs = {name: pv for name, pv in LookupRecordList() if pv.autosave} self._load() # load at startup if enabled - def _change_directory(self, directory: str): - dir_path = Path(directory) - if dir_path.is_dir(): - self.directory = dir_path - else: - raise ValueError(f"{directory} is not a valid autosave directory") - def _backup_sav_file(self): sav_path = self._get_current_sav_path() if sav_path.is_file(): From c7ad3f612be490ee52c7ee65b66c6d6a3a61e848 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 8 Jul 2024 15:38:10 +0100 Subject: [PATCH 16/53] Move autosave thread functions to autosave.py, fix try block logic --- softioc/autosave.py | 79 +++++++++++++++++++++++++++------------------ softioc/softioc.py | 18 +---------- 2 files changed, 49 insertions(+), 48 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 9f178c44..b2483bb8 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -4,8 +4,9 @@ from datetime import datetime from pathlib import Path from numpy import ndarray - from softioc.device_core import LookupRecordList +import sys +import atexit SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" @@ -32,22 +33,32 @@ def configure(directory=None, save_period=None, enabled=True, device=None): device: string name of the device prefix used for naming autosave files, automatically supplied by builder if not explicitly provided. ''' + # if already set, do not overwrite save_period or directory Autosave.save_period = save_period or Autosave.save_period Autosave.enabled = enabled + if directory is not None: + Autosave.directory = Path(directory) if device is None: if Autosave.device_name is None: from .builder import GetRecordNames Autosave.device_name = GetRecordNames().prefix[0] else: Autosave.device_name = device - if directory is None and Autosave.directory is None: - raise RuntimeError( - "Autosave directory is not known, call " - "autosave.configure() with keyword argument " - "directory." - ) - else: - Autosave.directory = Path(directory) + + +def start_autosave_thread(): + autosaver = Autosave() + worker = threading.Thread( + target=autosaver.loop, + ) + worker.daemon = True + worker.start() + atexit.register(_shutdown_autosave_thread, autosaver, worker) + + +def _shutdown_autosave_thread(autosaver, worker): + autosaver.stop() + worker.join() class Autosave: @@ -66,22 +77,22 @@ def __init__(self): yaml.add_representer( ndarray, _ndarray_representer, Dumper=yaml.Dumper ) + if not self.device_name: + raise RuntimeError( + "Device name is not known to autosave thread, " + "call autosave.configure() with keyword argument device" + ) if not self.directory: raise RuntimeError( "Autosave directory is not known, call " "autosave.configure() with keyword argument " - "directory." + "directory" ) - if not self.device_name: - raise RuntimeError( - "Device name is not known to autosave thread, " - "call autosave.configure() with device keyword argument" - ) - self._last_saved_time = datetime.now() if not self.directory.is_dir(): - raise RuntimeError( + raise FileNotFoundError( f"{self.directory} is not a valid autosave directory" ) + self._last_saved_time = datetime.now() if self.backup_on_restart: self._backup_sav_file() self._pvs = {name: pv for name, pv in LookupRecordList() if pv.autosave} @@ -92,7 +103,9 @@ def _backup_sav_file(self): if sav_path.is_file(): shutil.copy2(sav_path, self._get_timestamped_backup_sav_path()) else: - print(f"Could not back up autosave, {sav_path} is not a file") + sys.stderr.write( + f"Could not back up autosave, {sav_path} is not a file" + ) def _get_timestamped_backup_sav_path(self): sav_path = self._get_current_sav_path() @@ -116,27 +129,31 @@ def _save(self): ]: with open(path, "w") as f: yaml.dump(state, f, indent=4) - self._last_saved_state = state.copy() + self._last_saved_state = state self._last_saved_time = datetime.now() except Exception as e: - print(f"Could not save state to file: {e}") + sys.stderr.write(f"Could not save state to file: {e}") def _load(self, path=None): if not self.enabled: - print("Not loading from file as autosave adapter disabled") + sys.stdout.write( + "Not loading from file as autosave adapter disabled" + ) return sav_path = path or self._get_current_sav_path() if not sav_path or not sav_path.is_file(): - print(f"Could not load autosave values from file {sav_path}") + sys.stderr.write( + f"Could not load autosave values from file {sav_path}" + ) return with open(sav_path, "r") as f: self._last_saved_state = yaml.full_load(f) for name, value in self._last_saved_state.items(): - pv = self._pvs.get(name, None) - if not pv: - print(f"{name} is not a valid autosaved PV") - continue - pv.set(value) + try: + pv = self._pvs.get(name) + pv.set(value) + except Exception as e: + sys.stderr.write(f"Exception setting {name} to {value}: {e}") def stop(self): self._stop_event.set() @@ -144,12 +161,12 @@ def stop(self): def loop(self): if not self.enabled: return - try: - while True: + while True: + try: self._stop_event.wait(timeout=self.save_period) if self._stop_event.is_set(): # Stop requested return else: # No stop requested, we should save and continue self._save() - except Exception as e: - print(f"Exception in autosave loop: {e}") + except Exception as e: + sys.stderr.write(f"Exception in autosave loop: {e}") diff --git a/softioc/softioc.py b/softioc/softioc.py index a8505f41..8e6519c9 100644 --- a/softioc/softioc.py +++ b/softioc/softioc.py @@ -1,6 +1,5 @@ import os import sys -import threading import atexit from ctypes import * from tempfile import NamedTemporaryFile @@ -17,21 +16,6 @@ def epicsAtPyExit(): imports.epicsExitCallAtExits() -def _start_autosave_thread(): - autosaver = autosave.Autosave() - worker = threading.Thread( - target=autosaver.loop, - ) - worker.daemon = True - worker.start() - atexit.register(_shutdown_autosave_thread, autosaver, worker) - - -def _shutdown_autosave_thread(autosaver, worker): - autosaver.stop() - worker.join() - - def iocInit(dispatcher=None): '''This must be called exactly once after loading all EPICS database files. After this point the EPICS IOC is running and serving PVs. @@ -49,7 +33,7 @@ def iocInit(dispatcher=None): dispatcher = cothread_dispatcher.CothreadDispatcher() # Set the dispatcher for record processing callbacks device.dispatcher = dispatcher - _start_autosave_thread() + autosave.start_autosave_thread() imports.iocInit() From 57cfb304ea831539cf149ccfc17f59de2a44c72e Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 8 Jul 2024 16:11:59 +0100 Subject: [PATCH 17/53] block autosave thread until iocInit called --- softioc/autosave.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index b2483bb8..ac397d47 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -96,7 +96,6 @@ def __init__(self): if self.backup_on_restart: self._backup_sav_file() self._pvs = {name: pv for name, pv in LookupRecordList() if pv.autosave} - self._load() # load at startup if enabled def _backup_sav_file(self): sav_path = self._get_current_sav_path() @@ -159,8 +158,14 @@ def stop(self): self._stop_event.set() def loop(self): - if not self.enabled: + if not self.enabled or not self._pvs: return + # wait until iocInit has been called + # TODO: put in a timeout here otherwise this may get silently stuck... + while True: + if all(hasattr(pv, "_record") for pv in self._pvs.values()): + break + self._load() # load at startup if enabled while True: try: self._stop_event.wait(timeout=self.save_period) From 793616a0d67b19d1f8dddcf3b7cf5e661d6a4f44 Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 9 Jul 2024 11:58:15 +0100 Subject: [PATCH 18/53] Add support for autosaving non-VAL pv fields --- softioc/autosave.py | 78 ++++++++++++++++++++++++++-------------- softioc/device.py | 9 +++-- softioc/pythonSoftIoc.py | 4 ++- softioc/softioc.py | 2 +- 4 files changed, 62 insertions(+), 31 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index ac397d47..be5f9869 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -1,12 +1,13 @@ -import yaml +import atexit import shutil +import sys import threading +import traceback from datetime import datetime from pathlib import Path + +import yaml from numpy import ndarray -from softioc.device_core import LookupRecordList -import sys -import atexit SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" @@ -61,6 +62,20 @@ def _shutdown_autosave_thread(autosaver, worker): worker.join() +def add_pv_to_autosave(pv, name, field=None): + Autosave._pvs[name] = AutosavePV(pv, field) + + +class AutosavePV: + def __init__(self, pv, field = None): + if not field or field == "VAL": + self.get = pv.get + self.set = lambda val: pv.set(val) + else: + self.get = lambda: pv.get_field(field) + self.set = lambda val: pv.set_field(field, val) + + class Autosave: _pvs = {} _last_saved_state = {} @@ -95,7 +110,6 @@ def __init__(self): self._last_saved_time = datetime.now() if self.backup_on_restart: self._backup_sav_file() - self._pvs = {name: pv for name, pv in LookupRecordList() if pv.autosave} def _backup_sav_file(self): sav_path = self._get_current_sav_path() @@ -103,8 +117,9 @@ def _backup_sav_file(self): shutil.copy2(sav_path, self._get_timestamped_backup_sav_path()) else: sys.stderr.write( - f"Could not back up autosave, {sav_path} is not a file" + f"Could not back up autosave, {sav_path} is not a file\n" ) + sys.stderr.flush() def _get_timestamped_backup_sav_path(self): sav_path = self._get_current_sav_path() @@ -118,9 +133,31 @@ def _get_backup_save_path(self): def _get_current_sav_path(self): return self.directory / f"{self.device_name}.{SAV_SUFFIX}" + def _get_state(self): + # state = {pv_field: pv.get() for pv_field, pv in self._pvs.items()} + state = {} + for pv_field, pv in self._pvs.items(): + try: + state[pv_field] = pv.get() + except Exception as e: + sys.stderr.write("Exception getting {pv_field}: {e}\n") + sys.stderr.flush() + return state + + def _set_pvs_from_saved_state(self): + for pv_field, value in self._last_saved_state.items(): + try: + pv = self._pvs[pv_field] + pv.set(value) + except Exception as e: + sys.stderr.write( + f"Exception setting {pv_field} to {value}: {e}\n" + ) + sys.stderr.flush() + def _save(self): try: - state = {name: pv.get() for name, pv in self._pvs.items()} + state = self._get_state() if state != self._last_saved_state: for path in [ self._get_current_sav_path(), @@ -131,28 +168,20 @@ def _save(self): self._last_saved_state = state self._last_saved_time = datetime.now() except Exception as e: - sys.stderr.write(f"Could not save state to file: {e}") + sys.stderr.write(f"Could not save state to file: {e}\n") + sys.stderr.flush() def _load(self, path=None): - if not self.enabled: - sys.stdout.write( - "Not loading from file as autosave adapter disabled" - ) - return sav_path = path or self._get_current_sav_path() if not sav_path or not sav_path.is_file(): sys.stderr.write( - f"Could not load autosave values from file {sav_path}" + f"Could not load autosave values from file {sav_path}\n" ) + sys.stderr.flush() return with open(sav_path, "r") as f: self._last_saved_state = yaml.full_load(f) - for name, value in self._last_saved_state.items(): - try: - pv = self._pvs.get(name) - pv.set(value) - except Exception as e: - sys.stderr.write(f"Exception setting {name} to {value}: {e}") + self._set_pvs_from_saved_state() def stop(self): self._stop_event.set() @@ -160,11 +189,6 @@ def stop(self): def loop(self): if not self.enabled or not self._pvs: return - # wait until iocInit has been called - # TODO: put in a timeout here otherwise this may get silently stuck... - while True: - if all(hasattr(pv, "_record") for pv in self._pvs.values()): - break self._load() # load at startup if enabled while True: try: @@ -173,5 +197,5 @@ def loop(self): return else: # No stop requested, we should save and continue self._save() - except Exception as e: - sys.stderr.write(f"Exception in autosave loop: {e}") + except Exception: + traceback.print_exc() diff --git a/softioc/device.py b/softioc/device.py index f5bb8ef4..5cbadabd 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -3,7 +3,7 @@ import ctypes from ctypes import * import numpy - +from .autosave import add_pv_to_autosave from . import alarm from . import fields from .imports import ( @@ -55,7 +55,12 @@ class ProcessDeviceSupportCore(DeviceSupportCore, RecordLookup): # all record types can support autosave def __init__(self, name, **kargs): - self.autosave = kargs.pop("autosave", False) + autosave = kargs.pop("autosave", False) + if autosave: + add_pv_to_autosave(self, name) + autosave_fields = kargs.pop("autosave_fields", []) + for field in autosave_fields: + add_pv_to_autosave(self, f"{name}.{field}", field) self.__super.__init__(name, **kargs) # Most subclasses (all except waveforms) define a ctypes constructor for the diff --git a/softioc/pythonSoftIoc.py b/softioc/pythonSoftIoc.py index 46191700..c755e3f4 100644 --- a/softioc/pythonSoftIoc.py +++ b/softioc/pythonSoftIoc.py @@ -24,7 +24,9 @@ def __init__(self, builder, device, name, **fields): # have to maintain this separately from the corresponding device list. DeviceKeywords = [ 'on_update', 'on_update_name', 'validate', 'always_update', - 'initial_value', '_wf_nelm', '_wf_dtype', 'blocking', 'autosave'] + 'initial_value', '_wf_nelm', '_wf_dtype', 'blocking', + 'autosave', 'autosave_fields' + ] device_kargs = {} for keyword in DeviceKeywords: if keyword in fields: diff --git a/softioc/softioc.py b/softioc/softioc.py index 8e6519c9..7867d50f 100644 --- a/softioc/softioc.py +++ b/softioc/softioc.py @@ -33,8 +33,8 @@ def iocInit(dispatcher=None): dispatcher = cothread_dispatcher.CothreadDispatcher() # Set the dispatcher for record processing callbacks device.dispatcher = dispatcher - autosave.start_autosave_thread() imports.iocInit() + autosave.start_autosave_thread() def safeEpicsExit(code=0): From 6bea3fd97b200cc2f540de58d1689b98d95e885b Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 10 Jul 2024 13:20:52 +0100 Subject: [PATCH 19/53] Add autosave units tests rename AutosavePV to _AutosavePV --- softioc/autosave.py | 10 +-- tests/test_autosave.py | 167 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 tests/test_autosave.py diff --git a/softioc/autosave.py b/softioc/autosave.py index be5f9869..d5301205 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -63,10 +63,10 @@ def _shutdown_autosave_thread(autosaver, worker): def add_pv_to_autosave(pv, name, field=None): - Autosave._pvs[name] = AutosavePV(pv, field) + Autosave._pvs[name] = _AutosavePV(pv, field) -class AutosavePV: +class _AutosavePV: def __init__(self, pv, field = None): if not field or field == "VAL": self.get = pv.get @@ -127,20 +127,20 @@ def _get_timestamped_backup_sav_path(self): sav_path.name + self._last_saved_time.strftime("_%y%m%d-%H%M%S") ) - def _get_backup_save_path(self): + def _get_backup_sav_path(self): return self.directory / f"{self.device_name}.{SAVB_SUFFIX}" def _get_current_sav_path(self): return self.directory / f"{self.device_name}.{SAV_SUFFIX}" def _get_state(self): - # state = {pv_field: pv.get() for pv_field, pv in self._pvs.items()} state = {} for pv_field, pv in self._pvs.items(): try: state[pv_field] = pv.get() except Exception as e: sys.stderr.write("Exception getting {pv_field}: {e}\n") + sys.stderr.write(f"Exception getting {pv_field}: {e}\n") sys.stderr.flush() return state @@ -161,7 +161,7 @@ def _save(self): if state != self._last_saved_state: for path in [ self._get_current_sav_path(), - self._get_backup_save_path() + self._get_backup_sav_path() ]: with open(path, "w") as f: yaml.dump(state, f, indent=4) diff --git a/tests/test_autosave.py b/tests/test_autosave.py new file mode 100644 index 00000000..a9f699cc --- /dev/null +++ b/tests/test_autosave.py @@ -0,0 +1,167 @@ +from softioc import autosave, builder +from pathlib import Path +import pytest +import threading +import shutil +import numpy +import yaml + +DEVICE_NAME = "MY-DEVICE" + + +@pytest.fixture(autouse=True) +def reset_autosave_setup_teardown(): + default_pvs = autosave.Autosave._pvs.copy() + default_state = autosave.Autosave._last_saved_state.copy() + default_save_period = autosave.Autosave.save_period + default_device_name = autosave.Autosave.device_name + default_directory = autosave.Autosave.directory + default_enabled = autosave.Autosave.enabled + default_bor = autosave.Autosave.backup_on_restart + yield + autosave.Autosave._pvs = default_pvs + autosave.Autosave._last_saved_state = default_state + autosave.Autosave._stop_event = threading.Event() + autosave.Autosave.save_period = default_save_period + autosave.Autosave.device_name = default_device_name + autosave.Autosave.directory = default_directory + autosave.Autosave.enabled = default_enabled + autosave.Autosave.backup_on_restart = default_bor + if builder.GetRecordNames().prefix: # reset device name to empty if set + builder.SetDeviceName("") + + +@pytest.fixture +def autosave_dir(): + autosave_dir = Path("/tmp/autosave") + autosave_dir.mkdir(parents=True, exist_ok=True) + yield autosave_dir + shutil.rmtree(autosave_dir, ignore_errors=True) + + +@pytest.fixture +def existing_autosave_dir(): + dir = Path("/tmp/dummy-autosave") + dir.mkdir(parents=True, exist_ok=True) + state = {f"{DEVICE_NAME}:ALREADYSAVED": 20.0} + with open(dir / f"{DEVICE_NAME}.softsav", "w") as f: + yaml.dump(state, f, indent=4) + yield dir + shutil.rmtree(dir, ignore_errors=True) + + +def test_configure(autosave_dir): + assert autosave.Autosave.enabled is False # this is problematic, gets reset + autosave.configure(autosave_dir, device=DEVICE_NAME) + assert autosave.Autosave.device_name == DEVICE_NAME + assert autosave.Autosave.directory == autosave_dir + assert autosave.Autosave.enabled is True + + +def test_autosave_defaults(): + assert autosave.Autosave._pvs == {} + assert autosave.Autosave._last_saved_state == {} + assert isinstance(autosave.Autosave._stop_event, threading.Event) + assert not autosave.Autosave._stop_event.is_set() + assert autosave.Autosave.save_period == 30.0 + assert autosave.Autosave.device_name is None + assert autosave.Autosave.directory is None + assert autosave.Autosave.enabled is False + assert autosave.Autosave.backup_on_restart is True + + +def test_configure_dir_doesnt_exist(): + autosave_dir = Path("/tmp/autosave-doesnt-exist") + shutil.rmtree(autosave_dir, ignore_errors=True) + DEVICE_NAME = "MY_DEVICE" + autosave.configure(autosave_dir, device=DEVICE_NAME) + with pytest.raises(FileNotFoundError): + autosaver = autosave.Autosave() + + +def test_returns_if_init_called_before_configure(): + autosave.Autosave() + assert autosave.Autosave.enabled is False + + +def test_runtime_error_if_dir_not_configured(): + autosave.configure(device="MY-DEVICE") + with pytest.raises(RuntimeError): + autosave.Autosave() + + +def test_device_name_from_builder(autosave_dir): + builder.SetDeviceName(DEVICE_NAME) + autosave.configure(autosave_dir) + autosaver = autosave.Autosave() + assert autosaver.device_name == DEVICE_NAME + +def test_all_record_types_saveable(autosave_dir): + builder.SetDeviceName(DEVICE_NAME) + autosave.configure(autosave_dir) + + number_types = ["aIn", "aOut", "boolIn", "boolOut", "longIn", "longOut", + "int64In", "int64Out", "mbbIn", "mbbOut", "Action"] + string_types = ["stringIn", "stringOut", "longStringIn", "longStringOut"] + waveform_types = ["WaveformIn", "WaveformOut"] + for pv_type in number_types: + pv = getattr(builder, pv_type)(pv_type, autosave=True) + for pv_type in string_types: + pv = getattr(builder, pv_type)(pv_type, autosave=True) + pv.set("test string") + for pv_type in waveform_types: + getattr(builder, pv_type)(pv_type, numpy.zeros((100)), autosave=True) + autosaver = autosave.Autosave() + autosaver._save() + + with open(autosave_dir / f"{DEVICE_NAME}.softsav", "r") as f: + saved = yaml.full_load(f) + for pv_type in number_types + string_types + waveform_types: + assert f"{DEVICE_NAME}:{pv_type}" in saved + +def test_can_save_fields(mocker, autosave_dir): + builder.SetDeviceName(DEVICE_NAME) + builder.aOut("SAVEVAL", autosave=True, autosave_fields=["DISA"]) + builder.aOut("DONTSAVEVAL", autosave_fields=["SCAN"]) + mocker.patch( + 'softioc.device.ProcessDeviceSupportCore.get_field', return_value="0" + ) + # we need to patch get_field as we can't call builder.LoadDatabase() + # and softioc.iocInit() in unit tests + autosave.configure(autosave_dir) + autosaver = autosave.Autosave() + assert f"{DEVICE_NAME}:SAVEVAL" in autosaver._pvs + assert f"{DEVICE_NAME}:SAVEVAL.DISA" in autosaver._pvs + assert f"{DEVICE_NAME}:DONTSAVEVAL" not in autosaver._pvs + assert f"{DEVICE_NAME}:DONTSAVEVAL.SCAN" in autosaver._pvs + autosaver._save() + with open(autosave_dir / f"{DEVICE_NAME}.softsav", "r") as f: + saved = yaml.full_load(f) + assert f"{DEVICE_NAME}:SAVEVAL" in saved + assert f"{DEVICE_NAME}:SAVEVAL.DISA" in saved + assert f"{DEVICE_NAME}:DONTSAVEVAL" not in saved + assert f"{DEVICE_NAME}:DONTSAVEVAL.SCAN" in saved + +def test_stop_event(autosave_dir): + autosave.configure(autosave_dir, device=DEVICE_NAME) + builder.aOut("DUMMYRECORD", autosave=True) + autosaver = autosave.Autosave() + worker = threading.Thread( + target=autosaver.loop, + ) + worker.daemon = True + worker.start() + assert not autosaver._stop_event.is_set() + assert worker.is_alive() + autosaver.stop() + assert autosaver._stop_event.is_set() + worker.join(timeout=1) + +def test_load_autosave(existing_autosave_dir): + builder.SetDeviceName(DEVICE_NAME) + autosave.configure(existing_autosave_dir) + pv = builder.aOut("ALREADYSAVED", autosave=True) + assert pv.get() == 0.0 + autosaver = autosave.Autosave() + autosaver._load() + assert pv.get() == 20.0 From 816c158fdd720586b03dd4edb0c319c83edb1fee Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 11 Jul 2024 09:46:05 +0100 Subject: [PATCH 20/53] Support loading of fields for In records by requiring explicit autosave.load() --- softioc/autosave.py | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index d5301205..596d6abe 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -65,6 +65,8 @@ def _shutdown_autosave_thread(autosaver, worker): def add_pv_to_autosave(pv, name, field=None): Autosave._pvs[name] = _AutosavePV(pv, field) +def load(): + Autosave._load() class _AutosavePV: def __init__(self, pv, field = None): @@ -73,7 +75,7 @@ def __init__(self, pv, field = None): self.set = lambda val: pv.set(val) else: self.get = lambda: pv.get_field(field) - self.set = lambda val: pv.set_field(field, val) + self.set = lambda val: setattr(pv, field, val) class Autosave: @@ -127,11 +129,13 @@ def _get_timestamped_backup_sav_path(self): sav_path.name + self._last_saved_time.strftime("_%y%m%d-%H%M%S") ) - def _get_backup_sav_path(self): - return self.directory / f"{self.device_name}.{SAVB_SUFFIX}" + @classmethod + def _get_backup_sav_path(cls): + return cls.directory / f"{cls.device_name}.{SAVB_SUFFIX}" - def _get_current_sav_path(self): - return self.directory / f"{self.device_name}.{SAV_SUFFIX}" + @classmethod + def _get_current_sav_path(cls): + return cls.directory / f"{cls.device_name}.{SAV_SUFFIX}" def _get_state(self): state = {} @@ -139,15 +143,15 @@ def _get_state(self): try: state[pv_field] = pv.get() except Exception as e: - sys.stderr.write("Exception getting {pv_field}: {e}\n") sys.stderr.write(f"Exception getting {pv_field}: {e}\n") sys.stderr.flush() return state - def _set_pvs_from_saved_state(self): - for pv_field, value in self._last_saved_state.items(): + @classmethod + def _set_pvs_from_saved_state(cls): + for pv_field, value in cls._last_saved_state.items(): try: - pv = self._pvs[pv_field] + pv = cls._pvs[pv_field] pv.set(value) except Exception as e: sys.stderr.write( @@ -171,8 +175,16 @@ def _save(self): sys.stderr.write(f"Could not save state to file: {e}\n") sys.stderr.flush() - def _load(self, path=None): - sav_path = path or self._get_current_sav_path() + @classmethod + def _load(cls, path=None): + if not cls.enabled or not cls._pvs: + return + from .pythonSoftIoc import RecordWrapper + if RecordWrapper.is_builder_reset(): + sys.stderr.write("Could not load from autosave file as builder has been written\n") + sys.stderr.flush() + return + sav_path = path or cls._get_current_sav_path() if not sav_path or not sav_path.is_file(): sys.stderr.write( f"Could not load autosave values from file {sav_path}\n" @@ -180,8 +192,8 @@ def _load(self, path=None): sys.stderr.flush() return with open(sav_path, "r") as f: - self._last_saved_state = yaml.full_load(f) - self._set_pvs_from_saved_state() + cls._last_saved_state = yaml.full_load(f) + cls._set_pvs_from_saved_state() def stop(self): self._stop_event.set() @@ -189,7 +201,6 @@ def stop(self): def loop(self): if not self.enabled or not self._pvs: return - self._load() # load at startup if enabled while True: try: self._stop_event.wait(timeout=self.save_period) From 74d6cc988c3044dc24e1781a39fd37de19b0e456 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 12 Jul 2024 09:54:57 +0100 Subject: [PATCH 21/53] require autosave file name to be passed explicitly perform final backup on exit call autosave.load() in LoadDatabase() --- softioc/autosave.py | 65 ++++++++++++++++++--------------------------- softioc/builder.py | 2 ++ softioc/device.py | 9 +++---- 3 files changed, 32 insertions(+), 44 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 596d6abe..a99750a4 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -19,32 +19,24 @@ def _ndarray_representer(dumper, array): ) -def configure(directory=None, save_period=None, enabled=True, device=None): +def configure(directory, name, save_period=None, enabled=True): '''This should be called before initialising the IOC. Configures the autosave thread for periodic backing up of PV values. Args: - directory: string or Path giving directory path where autosave files - should be saved and loaded, must be supplied before iocInit if - autosave is required. + directory: string or Path giving directory path where autosave backup + files are saved and loaded. + nane: string name of the root used for naming backup files, this + could be the same as the device prefix. save_period: time in seconds between backups. Backups are only performed if PV values have changed. enabled: boolean which enables or disables autosave, set to True by default, or False if configure not called. - device: string name of the device prefix used for naming autosave files, - automatically supplied by builder if not explicitly provided. ''' - # if already set, do not overwrite save_period or directory + Autosave.directory = Path(directory) Autosave.save_period = save_period or Autosave.save_period Autosave.enabled = enabled - if directory is not None: - Autosave.directory = Path(directory) - if device is None: - if Autosave.device_name is None: - from .builder import GetRecordNames - Autosave.device_name = GetRecordNames().prefix[0] - else: - Autosave.device_name = device + Autosave.device_name = name def start_autosave_thread(): @@ -62,17 +54,22 @@ def _shutdown_autosave_thread(autosaver, worker): worker.join() -def add_pv_to_autosave(pv, name, field=None): - Autosave._pvs[name] = _AutosavePV(pv, field) +def add_pv_to_autosave(pv, name, val, fields): + if val: + Autosave._pvs[name] = _AutosavePV(pv) + for field in fields: + Autosave._pvs[f"{name}.{field}"] = _AutosavePV(pv, field) + def load(): Autosave._load() + class _AutosavePV: def __init__(self, pv, field = None): if not field or field == "VAL": self.get = pv.get - self.set = lambda val: pv.set(val) + self.set = pv.set else: self.get = lambda: pv.get_field(field) self.set = lambda val: setattr(pv, field, val) @@ -160,30 +157,21 @@ def _set_pvs_from_saved_state(cls): sys.stderr.flush() def _save(self): - try: - state = self._get_state() - if state != self._last_saved_state: - for path in [ - self._get_current_sav_path(), - self._get_backup_sav_path() - ]: - with open(path, "w") as f: - yaml.dump(state, f, indent=4) - self._last_saved_state = state - self._last_saved_time = datetime.now() - except Exception as e: - sys.stderr.write(f"Could not save state to file: {e}\n") - sys.stderr.flush() + state = self._get_state() + if state != self._last_saved_state: + for path in [ + self._get_current_sav_path(), + self._get_backup_sav_path() + ]: + with open(path, "w") as f: + yaml.dump(state, f, indent=4) + self._last_saved_state = state + self._last_saved_time = datetime.now() @classmethod def _load(cls, path=None): if not cls.enabled or not cls._pvs: return - from .pythonSoftIoc import RecordWrapper - if RecordWrapper.is_builder_reset(): - sys.stderr.write("Could not load from autosave file as builder has been written\n") - sys.stderr.flush() - return sav_path = path or cls._get_current_sav_path() if not sav_path or not sav_path.is_file(): sys.stderr.write( @@ -204,9 +192,8 @@ def loop(self): while True: try: self._stop_event.wait(timeout=self.save_period) + self._save() if self._stop_event.is_set(): # Stop requested return - else: # No stop requested, we should save and continue - self._save() except Exception: traceback.print_exc() diff --git a/softioc/builder.py b/softioc/builder.py index e84e53a6..3bad5802 100644 --- a/softioc/builder.py +++ b/softioc/builder.py @@ -5,6 +5,7 @@ from .softioc import dbLoadDatabase from epicsdbbuilder import * +from . import autosave InitialiseDbd() LoadDbdFile(os.path.join(os.path.dirname(__file__), 'device.dbd')) @@ -299,6 +300,7 @@ def LoadDatabase(): '''This should be called after all the builder records have been created, but before calling iocInit(). The database is loaded into EPICS memory, ready for operation.''' + autosave.load() from tempfile import mkstemp fd, database = mkstemp('.db') os.close(fd) diff --git a/softioc/device.py b/softioc/device.py index 5cbadabd..a5f01fd8 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -55,12 +55,11 @@ class ProcessDeviceSupportCore(DeviceSupportCore, RecordLookup): # all record types can support autosave def __init__(self, name, **kargs): - autosave = kargs.pop("autosave", False) - if autosave: - add_pv_to_autosave(self, name) + autosave_enabled = kargs.pop("autosave", False) autosave_fields = kargs.pop("autosave_fields", []) - for field in autosave_fields: - add_pv_to_autosave(self, f"{name}.{field}", field) + if autosave_enabled or autosave_fields: + add_pv_to_autosave( + self, name, autosave_enabled, autosave_fields) self.__super.__init__(name, **kargs) # Most subclasses (all except waveforms) define a ctypes constructor for the From 997266aec03b603c428b6c5c074dbb86830b2d5a Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 12 Jul 2024 11:47:48 +0100 Subject: [PATCH 22/53] check for equality between arrays when loading from autosave --- softioc/autosave.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index a99750a4..87ae252b 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -6,6 +6,7 @@ from datetime import datetime from pathlib import Path +import numpy import yaml from numpy import ndarray @@ -20,7 +21,7 @@ def _ndarray_representer(dumper, array): def configure(directory, name, save_period=None, enabled=True): - '''This should be called before initialising the IOC. Configures the + """This should be called before initialising the IOC. Configures the autosave thread for periodic backing up of PV values. Args: @@ -32,7 +33,7 @@ def configure(directory, name, save_period=None, enabled=True): if PV values have changed. enabled: boolean which enables or disables autosave, set to True by default, or False if configure not called. - ''' + """ Autosave.directory = Path(directory) Autosave.save_period = save_period or Autosave.save_period Autosave.enabled = enabled @@ -66,7 +67,7 @@ def load(): class _AutosavePV: - def __init__(self, pv, field = None): + def __init__(self, pv, field=None): if not field or field == "VAL": self.get = pv.get self.set = pv.set @@ -88,9 +89,7 @@ class Autosave: def __init__(self): if not self.enabled: return - yaml.add_representer( - ndarray, _ndarray_representer, Dumper=yaml.Dumper - ) + yaml.add_representer(ndarray, _ndarray_representer, Dumper=yaml.Dumper) if not self.device_name: raise RuntimeError( "Device name is not known to autosave thread, " @@ -156,12 +155,19 @@ def _set_pvs_from_saved_state(cls): ) sys.stderr.flush() + def _state_changed(self, state): + return self._last_saved_state.keys() != state.keys() or any( + # checks equality for builtins and numpy arrays + not numpy.array_equal(state[key], self._last_saved_state[key]) + for key in state + ) + def _save(self): state = self._get_state() - if state != self._last_saved_state: + if self._state_changed(state): for path in [ self._get_current_sav_path(), - self._get_backup_sav_path() + self._get_backup_sav_path(), ]: with open(path, "w") as f: yaml.dump(state, f, indent=4) From a8e9a0408492274adb2b9fac2f5e3f34b9d05b6b Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 12 Jul 2024 10:03:39 +0100 Subject: [PATCH 23/53] Remove device prefix from autosave signal name by default --- softioc/device.py | 3 ++- softioc/pythonSoftIoc.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/softioc/device.py b/softioc/device.py index a5f01fd8..02f02a68 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -57,9 +57,10 @@ class ProcessDeviceSupportCore(DeviceSupportCore, RecordLookup): def __init__(self, name, **kargs): autosave_enabled = kargs.pop("autosave", False) autosave_fields = kargs.pop("autosave_fields", []) + autosave_name = kargs.pop("autosave_name", name) if autosave_enabled or autosave_fields: add_pv_to_autosave( - self, name, autosave_enabled, autosave_fields) + self, autosave_name, autosave_enabled, autosave_fields) self.__super.__init__(name, **kargs) # Most subclasses (all except waveforms) define a ctypes constructor for the diff --git a/softioc/pythonSoftIoc.py b/softioc/pythonSoftIoc.py index c755e3f4..acf8c510 100644 --- a/softioc/pythonSoftIoc.py +++ b/softioc/pythonSoftIoc.py @@ -25,12 +25,13 @@ def __init__(self, builder, device, name, **fields): DeviceKeywords = [ 'on_update', 'on_update_name', 'validate', 'always_update', 'initial_value', '_wf_nelm', '_wf_dtype', 'blocking', - 'autosave', 'autosave_fields' + 'autosave', 'autosave_fields', 'autosave_name' ] device_kargs = {} for keyword in DeviceKeywords: if keyword in fields: device_kargs[keyword] = fields.pop(keyword) + device_kargs.setdefault('autosave_name', name) record = builder(name, **fields) record.address = '@' + record.name From f3b181e071f61396c8252dce012b209eb03c9730 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 12 Jul 2024 15:41:16 +0100 Subject: [PATCH 24/53] Fix tests for autosave api changes --- tests/test_autosave.py | 50 +++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/tests/test_autosave.py b/tests/test_autosave.py index a9f699cc..332c7cf5 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -43,7 +43,7 @@ def autosave_dir(): def existing_autosave_dir(): dir = Path("/tmp/dummy-autosave") dir.mkdir(parents=True, exist_ok=True) - state = {f"{DEVICE_NAME}:ALREADYSAVED": 20.0} + state = {"ALREADYSAVED": 20.0} with open(dir / f"{DEVICE_NAME}.softsav", "w") as f: yaml.dump(state, f, indent=4) yield dir @@ -52,7 +52,7 @@ def existing_autosave_dir(): def test_configure(autosave_dir): assert autosave.Autosave.enabled is False # this is problematic, gets reset - autosave.configure(autosave_dir, device=DEVICE_NAME) + autosave.configure(autosave_dir, DEVICE_NAME) assert autosave.Autosave.device_name == DEVICE_NAME assert autosave.Autosave.directory == autosave_dir assert autosave.Autosave.enabled is True @@ -74,7 +74,7 @@ def test_configure_dir_doesnt_exist(): autosave_dir = Path("/tmp/autosave-doesnt-exist") shutil.rmtree(autosave_dir, ignore_errors=True) DEVICE_NAME = "MY_DEVICE" - autosave.configure(autosave_dir, device=DEVICE_NAME) + autosave.configure(autosave_dir, DEVICE_NAME) with pytest.raises(FileNotFoundError): autosaver = autosave.Autosave() @@ -84,21 +84,9 @@ def test_returns_if_init_called_before_configure(): assert autosave.Autosave.enabled is False -def test_runtime_error_if_dir_not_configured(): - autosave.configure(device="MY-DEVICE") - with pytest.raises(RuntimeError): - autosave.Autosave() - - -def test_device_name_from_builder(autosave_dir): - builder.SetDeviceName(DEVICE_NAME) - autosave.configure(autosave_dir) - autosaver = autosave.Autosave() - assert autosaver.device_name == DEVICE_NAME - def test_all_record_types_saveable(autosave_dir): builder.SetDeviceName(DEVICE_NAME) - autosave.configure(autosave_dir) + autosave.configure(autosave_dir, DEVICE_NAME) number_types = ["aIn", "aOut", "boolIn", "boolOut", "longIn", "longOut", "int64In", "int64Out", "mbbIn", "mbbOut", "Action"] @@ -117,7 +105,8 @@ def test_all_record_types_saveable(autosave_dir): with open(autosave_dir / f"{DEVICE_NAME}.softsav", "r") as f: saved = yaml.full_load(f) for pv_type in number_types + string_types + waveform_types: - assert f"{DEVICE_NAME}:{pv_type}" in saved + assert pv_type in saved + def test_can_save_fields(mocker, autosave_dir): builder.SetDeviceName(DEVICE_NAME) @@ -128,22 +117,23 @@ def test_can_save_fields(mocker, autosave_dir): ) # we need to patch get_field as we can't call builder.LoadDatabase() # and softioc.iocInit() in unit tests - autosave.configure(autosave_dir) + autosave.configure(autosave_dir, DEVICE_NAME) autosaver = autosave.Autosave() - assert f"{DEVICE_NAME}:SAVEVAL" in autosaver._pvs - assert f"{DEVICE_NAME}:SAVEVAL.DISA" in autosaver._pvs - assert f"{DEVICE_NAME}:DONTSAVEVAL" not in autosaver._pvs - assert f"{DEVICE_NAME}:DONTSAVEVAL.SCAN" in autosaver._pvs + assert "SAVEVAL" in autosaver._pvs + assert "SAVEVAL.DISA" in autosaver._pvs + assert "DONTSAVEVAL" not in autosaver._pvs + assert "DONTSAVEVAL.SCAN" in autosaver._pvs autosaver._save() with open(autosave_dir / f"{DEVICE_NAME}.softsav", "r") as f: saved = yaml.full_load(f) - assert f"{DEVICE_NAME}:SAVEVAL" in saved - assert f"{DEVICE_NAME}:SAVEVAL.DISA" in saved - assert f"{DEVICE_NAME}:DONTSAVEVAL" not in saved - assert f"{DEVICE_NAME}:DONTSAVEVAL.SCAN" in saved + assert "SAVEVAL" in saved + assert "SAVEVAL.DISA" in saved + assert "DONTSAVEVAL" not in saved + assert "DONTSAVEVAL.SCAN" in saved + def test_stop_event(autosave_dir): - autosave.configure(autosave_dir, device=DEVICE_NAME) + autosave.configure(autosave_dir, DEVICE_NAME) builder.aOut("DUMMYRECORD", autosave=True) autosaver = autosave.Autosave() worker = threading.Thread( @@ -157,11 +147,11 @@ def test_stop_event(autosave_dir): assert autosaver._stop_event.is_set() worker.join(timeout=1) + def test_load_autosave(existing_autosave_dir): builder.SetDeviceName(DEVICE_NAME) - autosave.configure(existing_autosave_dir) + autosave.configure(existing_autosave_dir, DEVICE_NAME) pv = builder.aOut("ALREADYSAVED", autosave=True) assert pv.get() == 0.0 - autosaver = autosave.Autosave() - autosaver._load() + autosave.load() assert pv.get() == 20.0 From 377602a90545be87f1eb33ab3dbb7063d1aed081 Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 16 Jul 2024 09:38:27 +0100 Subject: [PATCH 25/53] write autosave state to backup file then rename, add configuration option for backup_on_load add additional autosave tests --- softioc/autosave.py | 42 +++++++++++++++++++++++++----------------- tests/test_autosave.py | 37 +++++++++++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 87ae252b..dbcfee8c 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -1,5 +1,4 @@ import atexit -import shutil import sys import threading import traceback @@ -9,6 +8,8 @@ import numpy import yaml from numpy import ndarray +from os import rename +from shutil import copy2 SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" @@ -20,7 +21,7 @@ def _ndarray_representer(dumper, array): ) -def configure(directory, name, save_period=None, enabled=True): +def configure(directory, name, save_period=None, backup=True, enabled=True): """This should be called before initialising the IOC. Configures the autosave thread for periodic backing up of PV values. @@ -31,10 +32,13 @@ def configure(directory, name, save_period=None, enabled=True): could be the same as the device prefix. save_period: time in seconds between backups. Backups are only performed if PV values have changed. + backup: creates a backup of the loaded autosave file on load, + timestamped with the time of backup. enabled: boolean which enables or disables autosave, set to True by default, or False if configure not called. """ Autosave.directory = Path(directory) + Autosave.backup_on_load = backup Autosave.save_period = save_period or Autosave.save_period Autosave.enabled = enabled Autosave.device_name = name @@ -79,12 +83,13 @@ def __init__(self, pv, field=None): class Autosave: _pvs = {} _last_saved_state = {} + _last_saved_time = datetime.now() _stop_event = threading.Event() save_period = 30.0 device_name = None directory = None enabled = False - backup_on_restart = True + backup_on_load = True def __init__(self): if not self.enabled: @@ -106,23 +111,23 @@ def __init__(self): f"{self.directory} is not a valid autosave directory" ) self._last_saved_time = datetime.now() - if self.backup_on_restart: - self._backup_sav_file() - def _backup_sav_file(self): - sav_path = self._get_current_sav_path() + @classmethod + def _backup_sav_file(cls): + sav_path = cls._get_current_sav_path() if sav_path.is_file(): - shutil.copy2(sav_path, self._get_timestamped_backup_sav_path()) + copy2(sav_path, cls._get_timestamped_backup_sav_path()) else: sys.stderr.write( f"Could not back up autosave, {sav_path} is not a file\n" ) sys.stderr.flush() - def _get_timestamped_backup_sav_path(self): - sav_path = self._get_current_sav_path() + @classmethod + def _get_timestamped_backup_sav_path(cls): + sav_path = cls._get_current_sav_path() return sav_path.parent / ( - sav_path.name + self._last_saved_time.strftime("_%y%m%d-%H%M%S") + sav_path.name + cls._last_saved_time.strftime("_%y%m%d-%H%M%S") ) @classmethod @@ -165,17 +170,20 @@ def _state_changed(self, state): def _save(self): state = self._get_state() if self._state_changed(state): - for path in [ - self._get_current_sav_path(), - self._get_backup_sav_path(), - ]: - with open(path, "w") as f: - yaml.dump(state, f, indent=4) + sav_path = self._get_current_sav_path() + backup_path = self._get_backup_sav_path() + # write to backup file first then use atomic os.rename + # to safely update stored state + with open(backup_path, "w") as backup: + yaml.dump(state, backup, indent=4) + rename(backup_path, sav_path) self._last_saved_state = state self._last_saved_time = datetime.now() @classmethod def _load(cls, path=None): + if cls.backup_on_load: + cls._backup_sav_file() if not cls.enabled or not cls._pvs: return sav_path = path or cls._get_current_sav_path() diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 332c7cf5..02e02b21 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -4,6 +4,7 @@ import threading import shutil import numpy +import re import yaml DEVICE_NAME = "MY-DEVICE" @@ -17,7 +18,7 @@ def reset_autosave_setup_teardown(): default_device_name = autosave.Autosave.device_name default_directory = autosave.Autosave.directory default_enabled = autosave.Autosave.enabled - default_bor = autosave.Autosave.backup_on_restart + default_bol = autosave.Autosave.backup_on_load yield autosave.Autosave._pvs = default_pvs autosave.Autosave._last_saved_state = default_state @@ -26,7 +27,7 @@ def reset_autosave_setup_teardown(): autosave.Autosave.device_name = default_device_name autosave.Autosave.directory = default_directory autosave.Autosave.enabled = default_enabled - autosave.Autosave.backup_on_restart = default_bor + autosave.Autosave.backup_on_load = default_bol if builder.GetRecordNames().prefix: # reset device name to empty if set builder.SetDeviceName("") @@ -67,7 +68,7 @@ def test_autosave_defaults(): assert autosave.Autosave.device_name is None assert autosave.Autosave.directory is None assert autosave.Autosave.enabled is False - assert autosave.Autosave.backup_on_restart is True + assert autosave.Autosave.backup_on_load is True def test_configure_dir_doesnt_exist(): @@ -76,7 +77,7 @@ def test_configure_dir_doesnt_exist(): DEVICE_NAME = "MY_DEVICE" autosave.configure(autosave_dir, DEVICE_NAME) with pytest.raises(FileNotFoundError): - autosaver = autosave.Autosave() + autosave.Autosave() def test_returns_if_init_called_before_configure(): @@ -89,7 +90,7 @@ def test_all_record_types_saveable(autosave_dir): autosave.configure(autosave_dir, DEVICE_NAME) number_types = ["aIn", "aOut", "boolIn", "boolOut", "longIn", "longOut", - "int64In", "int64Out", "mbbIn", "mbbOut", "Action"] + "int64In", "int64Out", "mbbIn", "mbbOut", "Action"] string_types = ["stringIn", "stringOut", "longStringIn", "longStringOut"] waveform_types = ["WaveformIn", "WaveformOut"] for pv_type in number_types: @@ -150,8 +151,32 @@ def test_stop_event(autosave_dir): def test_load_autosave(existing_autosave_dir): builder.SetDeviceName(DEVICE_NAME) - autosave.configure(existing_autosave_dir, DEVICE_NAME) + autosave.configure(existing_autosave_dir, DEVICE_NAME, backup=False) pv = builder.aOut("ALREADYSAVED", autosave=True) assert pv.get() == 0.0 autosave.load() assert pv.get() == 20.0 + +def test_backup_on_load(existing_autosave_dir): + autosave.configure(existing_autosave_dir, DEVICE_NAME, backup=True) + autosave.load() + backup_files = list(existing_autosave_dir.glob("*.softsav_*")) + assert len(backup_files) == 1 + # assert backup file is named .softsave_yymmdd-HHMMSS + for file in backup_files: + assert re.match(r"^" + DEVICE_NAME + r"\.softsav_[0-9]{6}-[0-9]{6}$", + file.name) + +def test_autosave_key_names(autosave_dir): + builder.aOut("DEFAULTNAME", autosave=True) + builder.SetDeviceName(DEVICE_NAME) + builder.aOut("DEFAULTNAMEAFTERPREFIXSET", autosave=True) + builder.aOut("RENAMEME", autosave=True, autosave_name="CUSTOMNAME") + autosave.configure(autosave_dir, DEVICE_NAME) + autosaver = autosave.Autosave() + autosaver._save() + with open(autosave_dir / f"{DEVICE_NAME}.softsav", "r") as f: + saved = yaml.full_load(f) + assert "DEFAULTNAME" in saved + assert "DEFAULTNAMEAFTERPREFIXSET" in saved + assert "CUSTOMNAME" in saved From f7d0980d0f8c54bb7c9086aadeb49fedb9bc508e Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 16 Jul 2024 15:38:56 +0100 Subject: [PATCH 26/53] Add docstring and rename variables for autosave Use built in pytest/unittest fixtures in autosave tests --- softioc/autosave.py | 76 ++++++++++++++++---------- softioc/builder.py | 4 +- tests/test_autosave.py | 117 ++++++++++++++++++++--------------------- 3 files changed, 107 insertions(+), 90 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index dbcfee8c..cb32fb66 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -3,13 +3,12 @@ import threading import traceback from datetime import datetime +from os import rename from pathlib import Path +from shutil import copy2 import numpy import yaml -from numpy import ndarray -from os import rename -from shutil import copy2 SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" @@ -28,8 +27,8 @@ def configure(directory, name, save_period=None, backup=True, enabled=True): Args: directory: string or Path giving directory path where autosave backup files are saved and loaded. - nane: string name of the root used for naming backup files, this - could be the same as the device prefix. + name: string name of the root used for naming backup files, this + is usually the same as the device prefix. save_period: time in seconds between backups. Backups are only performed if PV values have changed. backup: creates a backup of the loaded autosave file on load, @@ -59,14 +58,26 @@ def _shutdown_autosave_thread(autosaver, worker): worker.join() -def add_pv_to_autosave(pv, name, val, fields): - if val: +def add_pv_to_autosave(pv, name, save_val, save_fields): + """Configures a PV for autosave + + Args: + pv: a PV object inheriting ProcessDeviceSupportCore + name: the name of the PV which is used to generate the key + by which the PV value is saved to and loaded from a backup, + this is typically the signal name without the device prefix + save_val: a boolean that tracks whether to save the VAL field + in an autosave backup + save_fields: a list of string names of fields associated with the pv + to be saved to and loaded from a backup + """ + if save_val: Autosave._pvs[name] = _AutosavePV(pv) - for field in fields: + for field in save_fields: Autosave._pvs[f"{name}.{field}"] = _AutosavePV(pv, field) -def load(): +def load_autosave(): Autosave._load() @@ -89,16 +100,18 @@ class Autosave: device_name = None directory = None enabled = False - backup_on_load = True + backup_on_load = False def __init__(self): if not self.enabled: return - yaml.add_representer(ndarray, _ndarray_representer, Dumper=yaml.Dumper) + yaml.add_representer( + numpy.ndarray, _ndarray_representer, Dumper=yaml.Dumper + ) if not self.device_name: raise RuntimeError( "Device name is not known to autosave thread, " - "call autosave.configure() with keyword argument device" + "call autosave.configure() with keyword argument name" ) if not self.directory: raise RuntimeError( @@ -114,14 +127,21 @@ def __init__(self): @classmethod def _backup_sav_file(cls): + if not cls.directory and cls.directory.is_dir(): + print( + f"Could not back up autosave as {cls.directory} is" + " not a valid directory", + file=sys.stderr, + ) + return sav_path = cls._get_current_sav_path() if sav_path.is_file(): copy2(sav_path, cls._get_timestamped_backup_sav_path()) else: - sys.stderr.write( - f"Could not back up autosave, {sav_path} is not a file\n" + print( + f"Could not back up autosave, {sav_path} is not a file", + file=sys.stderr, ) - sys.stderr.flush() @classmethod def _get_timestamped_backup_sav_path(cls): @@ -143,9 +163,9 @@ def _get_state(self): for pv_field, pv in self._pvs.items(): try: state[pv_field] = pv.get() - except Exception as e: - sys.stderr.write(f"Exception getting {pv_field}: {e}\n") - sys.stderr.flush() + except Exception: + print(f"Exception getting {pv_field}", file=sys.stderr) + traceback.print_exc() return state @classmethod @@ -154,11 +174,13 @@ def _set_pvs_from_saved_state(cls): try: pv = cls._pvs[pv_field] pv.set(value) - except Exception as e: - sys.stderr.write( - f"Exception setting {pv_field} to {value}: {e}\n" + except Exception: + print( + f"Exception setting {pv_field} to {value}", + file=sys.stderr, ) - sys.stderr.flush() + traceback.print_exc() + def _state_changed(self, state): return self._last_saved_state.keys() != state.keys() or any( @@ -182,16 +204,16 @@ def _save(self): @classmethod def _load(cls, path=None): - if cls.backup_on_load: - cls._backup_sav_file() if not cls.enabled or not cls._pvs: return + if cls.backup_on_load: + cls._backup_sav_file() sav_path = path or cls._get_current_sav_path() if not sav_path or not sav_path.is_file(): - sys.stderr.write( - f"Could not load autosave values from file {sav_path}\n" + print( + f"Could not load autosave values from file {sav_path}", + file=sys.stderr, ) - sys.stderr.flush() return with open(sav_path, "r") as f: cls._last_saved_state = yaml.full_load(f) diff --git a/softioc/builder.py b/softioc/builder.py index 3bad5802..bcbd21d3 100644 --- a/softioc/builder.py +++ b/softioc/builder.py @@ -3,9 +3,9 @@ from .device_core import RecordLookup from .softioc import dbLoadDatabase +from .autosave import load_autosave from epicsdbbuilder import * -from . import autosave InitialiseDbd() LoadDbdFile(os.path.join(os.path.dirname(__file__), 'device.dbd')) @@ -300,7 +300,7 @@ def LoadDatabase(): '''This should be called after all the builder records have been created, but before calling iocInit(). The database is loaded into EPICS memory, ready for operation.''' - autosave.load() + load_autosave() from tempfile import mkstemp fd, database = mkstemp('.db') os.close(fd) diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 02e02b21..c44db1d5 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -1,5 +1,6 @@ from softioc import autosave, builder from pathlib import Path +from unittest.mock import patch import pytest import threading import shutil @@ -33,29 +34,18 @@ def reset_autosave_setup_teardown(): @pytest.fixture -def autosave_dir(): - autosave_dir = Path("/tmp/autosave") - autosave_dir.mkdir(parents=True, exist_ok=True) - yield autosave_dir - shutil.rmtree(autosave_dir, ignore_errors=True) - - -@pytest.fixture -def existing_autosave_dir(): - dir = Path("/tmp/dummy-autosave") - dir.mkdir(parents=True, exist_ok=True) +def existing_autosave_dir(tmp_path): state = {"ALREADYSAVED": 20.0} - with open(dir / f"{DEVICE_NAME}.softsav", "w") as f: + with open(tmp_path / f"{DEVICE_NAME}.softsav", "w") as f: yaml.dump(state, f, indent=4) - yield dir - shutil.rmtree(dir, ignore_errors=True) + return tmp_path -def test_configure(autosave_dir): - assert autosave.Autosave.enabled is False # this is problematic, gets reset - autosave.configure(autosave_dir, DEVICE_NAME) +def test_configure(tmp_path): + assert autosave.Autosave.enabled is False + autosave.configure(tmp_path, DEVICE_NAME) assert autosave.Autosave.device_name == DEVICE_NAME - assert autosave.Autosave.directory == autosave_dir + assert autosave.Autosave.directory == tmp_path assert autosave.Autosave.enabled is True @@ -68,14 +58,12 @@ def test_autosave_defaults(): assert autosave.Autosave.device_name is None assert autosave.Autosave.directory is None assert autosave.Autosave.enabled is False - assert autosave.Autosave.backup_on_load is True + assert autosave.Autosave.backup_on_load is False -def test_configure_dir_doesnt_exist(): - autosave_dir = Path("/tmp/autosave-doesnt-exist") - shutil.rmtree(autosave_dir, ignore_errors=True) +def test_configure_dir_doesnt_exist(tmp_path): DEVICE_NAME = "MY_DEVICE" - autosave.configure(autosave_dir, DEVICE_NAME) + autosave.configure(tmp_path / "subdir-doesnt-exist", DEVICE_NAME) with pytest.raises(FileNotFoundError): autosave.Autosave() @@ -85,9 +73,9 @@ def test_returns_if_init_called_before_configure(): assert autosave.Autosave.enabled is False -def test_all_record_types_saveable(autosave_dir): +def test_all_record_types_saveable(tmp_path): builder.SetDeviceName(DEVICE_NAME) - autosave.configure(autosave_dir, DEVICE_NAME) + autosave.configure(tmp_path, DEVICE_NAME) number_types = ["aIn", "aOut", "boolIn", "boolOut", "longIn", "longOut", "int64In", "int64Out", "mbbIn", "mbbOut", "Action"] @@ -103,50 +91,52 @@ def test_all_record_types_saveable(autosave_dir): autosaver = autosave.Autosave() autosaver._save() - with open(autosave_dir / f"{DEVICE_NAME}.softsav", "r") as f: + with open(tmp_path / f"{DEVICE_NAME}.softsav", "r") as f: saved = yaml.full_load(f) for pv_type in number_types + string_types + waveform_types: assert pv_type in saved -def test_can_save_fields(mocker, autosave_dir): +def test_can_save_fields(tmp_path): builder.SetDeviceName(DEVICE_NAME) builder.aOut("SAVEVAL", autosave=True, autosave_fields=["DISA"]) builder.aOut("DONTSAVEVAL", autosave_fields=["SCAN"]) - mocker.patch( - 'softioc.device.ProcessDeviceSupportCore.get_field', return_value="0" - ) # we need to patch get_field as we can't call builder.LoadDatabase() # and softioc.iocInit() in unit tests - autosave.configure(autosave_dir, DEVICE_NAME) - autosaver = autosave.Autosave() - assert "SAVEVAL" in autosaver._pvs - assert "SAVEVAL.DISA" in autosaver._pvs - assert "DONTSAVEVAL" not in autosaver._pvs - assert "DONTSAVEVAL.SCAN" in autosaver._pvs - autosaver._save() - with open(autosave_dir / f"{DEVICE_NAME}.softsav", "r") as f: - saved = yaml.full_load(f) - assert "SAVEVAL" in saved - assert "SAVEVAL.DISA" in saved - assert "DONTSAVEVAL" not in saved - assert "DONTSAVEVAL.SCAN" in saved - - -def test_stop_event(autosave_dir): - autosave.configure(autosave_dir, DEVICE_NAME) + with patch( + "softioc.device.ProcessDeviceSupportCore.get_field", return_value="0" + ): + autosave.configure(tmp_path, DEVICE_NAME) + autosaver = autosave.Autosave() + assert "SAVEVAL" in autosaver._pvs + assert "SAVEVAL.DISA" in autosaver._pvs + assert "DONTSAVEVAL" not in autosaver._pvs + assert "DONTSAVEVAL.SCAN" in autosaver._pvs + autosaver._save() + with open(tmp_path / f"{DEVICE_NAME}.softsav", "r") as f: + saved = yaml.full_load(f) + assert "SAVEVAL" in saved + assert "SAVEVAL.DISA" in saved + assert "DONTSAVEVAL" not in saved + assert "DONTSAVEVAL.SCAN" in saved + + +def test_stop_event(tmp_path): + autosave.configure(tmp_path, DEVICE_NAME) builder.aOut("DUMMYRECORD", autosave=True) autosaver = autosave.Autosave() worker = threading.Thread( target=autosaver.loop, ) - worker.daemon = True - worker.start() - assert not autosaver._stop_event.is_set() - assert worker.is_alive() - autosaver.stop() - assert autosaver._stop_event.is_set() - worker.join(timeout=1) + try: + worker.daemon = True + worker.start() + assert not autosaver._stop_event.is_set() + assert worker.is_alive() + autosaver.stop() + assert autosaver._stop_event.is_set() + finally: + worker.join(timeout=1) def test_load_autosave(existing_autosave_dir): @@ -154,28 +144,33 @@ def test_load_autosave(existing_autosave_dir): autosave.configure(existing_autosave_dir, DEVICE_NAME, backup=False) pv = builder.aOut("ALREADYSAVED", autosave=True) assert pv.get() == 0.0 - autosave.load() + autosave.load_autosave() assert pv.get() == 20.0 + def test_backup_on_load(existing_autosave_dir): autosave.configure(existing_autosave_dir, DEVICE_NAME, backup=True) - autosave.load() + # backup only performed if there are any pvs to save + builder.aOut("ALREADYSAVED", autosave=True) + autosave.load_autosave() backup_files = list(existing_autosave_dir.glob("*.softsav_*")) assert len(backup_files) == 1 # assert backup file is named .softsave_yymmdd-HHMMSS for file in backup_files: - assert re.match(r"^" + DEVICE_NAME + r"\.softsav_[0-9]{6}-[0-9]{6}$", - file.name) + assert re.match( + r"^" + DEVICE_NAME + r"\.softsav_[0-9]{6}-[0-9]{6}$", file.name + ) + -def test_autosave_key_names(autosave_dir): +def test_autosave_key_names(tmp_path): builder.aOut("DEFAULTNAME", autosave=True) builder.SetDeviceName(DEVICE_NAME) builder.aOut("DEFAULTNAMEAFTERPREFIXSET", autosave=True) builder.aOut("RENAMEME", autosave=True, autosave_name="CUSTOMNAME") - autosave.configure(autosave_dir, DEVICE_NAME) + autosave.configure(tmp_path, DEVICE_NAME) autosaver = autosave.Autosave() autosaver._save() - with open(autosave_dir / f"{DEVICE_NAME}.softsav", "r") as f: + with open(tmp_path / f"{DEVICE_NAME}.softsav", "r") as f: saved = yaml.full_load(f) assert "DEFAULTNAME" in saved assert "DEFAULTNAMEAFTERPREFIXSET" in saved From 3de2b86ac826e99d272ba97b2e5ce8c82c166181 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 17 Jul 2024 08:34:47 +0100 Subject: [PATCH 27/53] test load works for all autosave types --- tests/test_autosave.py | 99 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 9 deletions(-) diff --git a/tests/test_autosave.py b/tests/test_autosave.py index c44db1d5..19423adf 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -1,9 +1,7 @@ from softioc import autosave, builder -from pathlib import Path from unittest.mock import patch import pytest import threading -import shutil import numpy import re import yaml @@ -35,7 +33,25 @@ def reset_autosave_setup_teardown(): @pytest.fixture def existing_autosave_dir(tmp_path): - state = {"ALREADYSAVED": 20.0} + state = { + "SAVED-AO": 20.0, + "SAVED-AI": 20.0, + "SAVED-BO": 1, + "SAVED-BI": 1, + "SAVED-LONGIN": 20, + "SAVED-LONGOUT": 20, + "SAVED-INT64IN": 100, + "SAVED-INT64OUT": 100, + "SAVED-MBBI": 15, + "SAVED-MBBO": 15, + "SAVED-STRINGIN": "test string in", + "SAVED-STRINGOUT": "test string out", + "SAVED-LONGSTRINGIN": "test long string in", + "SAVED-LONGSTRINGOUT": "test long string out", + "SAVED-ACTION": 1, + "SAVED-WAVEFORMIN": [1, 2, 3, 4], + "SAVED-WAVEFORMOUT": [1, 2, 3, 4], + } with open(tmp_path / f"{DEVICE_NAME}.softsav", "w") as f: yaml.dump(state, f, indent=4) return tmp_path @@ -77,8 +93,19 @@ def test_all_record_types_saveable(tmp_path): builder.SetDeviceName(DEVICE_NAME) autosave.configure(tmp_path, DEVICE_NAME) - number_types = ["aIn", "aOut", "boolIn", "boolOut", "longIn", "longOut", - "int64In", "int64Out", "mbbIn", "mbbOut", "Action"] + number_types = [ + "aIn", + "aOut", + "boolIn", + "boolOut", + "longIn", + "longOut", + "int64In", + "int64Out", + "mbbIn", + "mbbOut", + "Action", + ] string_types = ["stringIn", "stringOut", "longStringIn", "longStringOut"] waveform_types = ["WaveformIn", "WaveformOut"] for pv_type in number_types: @@ -142,16 +169,70 @@ def test_stop_event(tmp_path): def test_load_autosave(existing_autosave_dir): builder.SetDeviceName(DEVICE_NAME) autosave.configure(existing_autosave_dir, DEVICE_NAME, backup=False) - pv = builder.aOut("ALREADYSAVED", autosave=True) - assert pv.get() == 0.0 + pv_aOut = builder.aOut("SAVED-AO", autosave=True) + pv_aIn = builder.aIn("SAVED-AI", autosave=True) + pv_boolOut = builder.boolOut("SAVED-BO", autosave=True) + pv_boolIn = builder.boolIn("SAVED-BI", autosave=True) + pv_longIn = builder.longIn("SAVED-LONGIN", autosave=True) + pv_longOut = builder.longOut("SAVED-LONGOUT", autosave=True) + pv_int64In = builder.int64In("SAVED-INT64IN", autosave=True) + pv_int64Out = builder.int64Out("SAVED-INT64OUT", autosave=True) + pv_mbbIn = builder.mbbIn("SAVED-MBBI", autosave=True) + pv_mbbOut = builder.mbbOut("SAVED-MBBO", autosave=True) + pv_stringIn = builder.stringIn("SAVED-STRINGIN", autosave=True) + pv_stringOut = builder.stringOut("SAVED-STRINGOUT", autosave=True) + pv_longStringIn = builder.longStringIn("SAVED-LONGSTRINGIN", autosave=True) + pv_longStringOut = builder.longStringOut( + "SAVED-LONGSTRINGOUT", autosave=True + ) + pv_Action = builder.Action("SAVED-ACTION", autosave=True) + pv_WaveformIn = builder.WaveformIn( + "SAVED-WAVEFORMIN", numpy.zeros((4)), autosave=True + ) + pv_WaveformOut = builder.WaveformOut( + "SAVED-WAVEFORMOUT", numpy.zeros((4)), autosave=True + ) + assert pv_aOut.get() == 0.0 + assert pv_aIn.get() == 0.0 + assert pv_boolOut.get() == 0 + assert pv_boolIn.get() == 0 + assert pv_longIn.get() == 0 + assert pv_longOut.get() == 0 + assert pv_int64In.get() == 0 + assert pv_int64Out.get() == 0 + assert pv_mbbIn.get() == 0 + assert pv_mbbOut.get() == 0 + assert pv_stringIn.get() == "" + assert pv_stringOut.get() == "" + assert pv_longStringIn.get() == "" + assert pv_longStringOut.get() == "" + assert pv_Action.get() == 0 + assert (pv_WaveformIn.get() == numpy.array([0, 0, 0, 0])).all() + assert (pv_WaveformOut.get() == numpy.array([0, 0, 0, 0])).all() autosave.load_autosave() - assert pv.get() == 20.0 + assert pv_aOut.get() == 20.0 + assert pv_aIn.get() == 20.0 + assert pv_boolOut.get() == 1 + assert pv_boolIn.get() == 1 + assert pv_longIn.get() == 20 + assert pv_longOut.get() == 20 + assert pv_int64In.get() == 100 + assert pv_int64Out.get() == 100 + assert pv_mbbIn.get() == 15 + assert pv_mbbOut.get() == 15 + assert pv_stringIn.get() == "test string in" + assert pv_stringOut.get() == "test string out" + assert pv_longStringIn.get() == "test long string in" + assert pv_longStringOut.get() == "test long string out" + assert pv_Action.get() == 1 + assert (pv_WaveformIn.get() == numpy.array([1, 2, 3, 4])).all() + assert (pv_WaveformOut.get() == numpy.array([1, 2, 3, 4])).all() def test_backup_on_load(existing_autosave_dir): autosave.configure(existing_autosave_dir, DEVICE_NAME, backup=True) # backup only performed if there are any pvs to save - builder.aOut("ALREADYSAVED", autosave=True) + builder.aOut("SAVED-AO", autosave=True) autosave.load_autosave() backup_files = list(existing_autosave_dir.glob("*.softsav_*")) assert len(backup_files) == 1 From 33a03bd559cf6811320b30b2d71171ea1ca9e98b Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 18 Jul 2024 10:45:57 +0100 Subject: [PATCH 28/53] Add full-system IOC save and load tests and autosave how-to doc --- docs/examples/example_autosave_ioc.py | 33 +++++ docs/how-to/use-autosave-in-an-ioc.rst | 65 ++++++++++ tests/test_autosave.py | 159 ++++++++++++++++++++----- 3 files changed, 229 insertions(+), 28 deletions(-) create mode 100644 docs/examples/example_autosave_ioc.py create mode 100644 docs/how-to/use-autosave-in-an-ioc.rst diff --git a/docs/examples/example_autosave_ioc.py b/docs/examples/example_autosave_ioc.py new file mode 100644 index 00000000..9ef79321 --- /dev/null +++ b/docs/examples/example_autosave_ioc.py @@ -0,0 +1,33 @@ +from softioc import autosave, builder, softioc +import cothread + +# Set the record prefix +builder.SetDeviceName("MY-DEVICE-PREFIX") + +# Create records, set some of them to autosave, also save some of their fields + +builder.aOut("AO", autosave=True) +builder.aOut("AO2", autosave=True, autosave_name="EXTRA-AOUT") +builder.aIn("AI", autosave_fields=["PREC", "SCAN"]) +builder.boolIn("BO") +builder.WaveformIn("WAVEFORMOUT", [0, 0, 0, 0], autosave=True) +minutes = builder.longOut("MINUTESRUN", autosave=True) + +autosave.configure( + directory="/tmp/autosave-data/MY-DEVICE-PREFIX", + name="MY-DEVICE-PREFIX", + save_period=20.0 +) + +builder.LoadDatabase() +softioc.iocInit() + +# Start processes required to be run after iocInit +def update(): + while True: + cothread.Sleep(60) + minutes.set(minutes.get() + 1) + +cothread.Spawn(update) + +softioc.interactive_ioc(globals()) diff --git a/docs/how-to/use-autosave-in-an-ioc.rst b/docs/how-to/use-autosave-in-an-ioc.rst new file mode 100644 index 00000000..0b1648c1 --- /dev/null +++ b/docs/how-to/use-autosave-in-an-ioc.rst @@ -0,0 +1,65 @@ +Use `softioc.autosave` in an IOC +======================= + +`../tutorials/creating-an-ioc` shows how to create a pythonSoftIOC. + + +Example IOC +----------- + +.. literalinclude:: ../examples/example_autosave_ioc.py + +Records are instantiated as normal and configured for automatic loading and +periodic saving to a backup file with the keyword arguments ``autosave``, ``autosave_fields`` +and ``autosave_name``. Records with ``autosave=True`` (``False`` by default) have their +VAL fields backed up. Additional record fields in a list passed to ``autosave_fields`` will be backed +up, note that this applies even when ``autosave`` is ``False``. + +The field values get written into a yaml-formatted file containing key-value pairs, +by default the keys are the same as the record name passed as the first argument to the +`builder.()` call, excluding the device name specified in the `builder.SetDeviceName()` +call, but this can be changed to a string name specified in the ``autosave_name`` keyword +argument. + +Autosave is disabled by default until `autosave.configure()` is called. The first two arguments, +``directory`` and ``name`` are required. Backup files are periodically written into +``directory`` with the name ``.softsav`` every ``save_period`` seconds, +set to 30.0 by default. The directory must exist, and should be configured with the appropriate +read/write permissions for the user running the IOC. + +IOC developers should only need to interface with autosave via the `autosave.configre()` +method and the ``autosave``, ``autosave_fields`` and ``autosave_name`` keyword arguments, +all other module members are intended for internal use only. + +In normal operation, loading from a backup is performed once during the +`builder.LoadDatabase()` call, periodic saving to the backup file begins when +`softioc.iocInit()` is called, provided that any PVs are configured to be saved. +Saving only occurs when any of the saved field values have changed since the last save. +Users are discouraged from manually editing the backup files while the +IOC is running so that the internal state of the autosave thread is consistent with +the backup file. + +If autosave is enabled and active, a timestamped backup file of the latest existing backup file is created +when the IOC is restarted, e.g. ``.softsave-240717-095004`` (timestamps are in the format yymmdd-HHMMSS). +This can be disabled by passing ``backup_on_load=False`` to `autosave.configure()`. +To disable any autosaving, comment out the `autosave.configre()` call or pass it the keyword argument +``enabled=False``. + +The resulting backup file after running the IOC for a minute is the following: + +.. code-block:: + + AI.PREC: '0' + AI.SCAN: I/O Intr + AO: 0.0 + EXTRA-AOUT: 0.0 + MINUTESRUN: 1 + WAVEFORMOUT: [0, 0, 0, 0] + +If the IOC is stopped and restarted, the MINUTESRUN record will load its saved +value of 1 from the backup. +All non-VAL fields are stored as strings. Waveform type records holding arrays +are cast into lists before saving. + +This example IOC uses cothread, but autosave works identically when using +an asyncio dispatcher. \ No newline at end of file diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 19423adf..0029730b 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -1,10 +1,12 @@ -from softioc import autosave, builder +from conftest import get_multiprocessing_context, select_and_recv +from softioc import autosave, builder, softioc from unittest.mock import patch import pytest import threading import numpy import re import yaml +import time DEVICE_NAME = "MY-DEVICE" @@ -51,6 +53,8 @@ def existing_autosave_dir(tmp_path): "SAVED-ACTION": 1, "SAVED-WAVEFORMIN": [1, 2, 3, 4], "SAVED-WAVEFORMOUT": [1, 2, 3, 4], + "SAVED-WAVEFORMIN-STRINGS": ["test", "waveform", "strings"], + "SAVED-WAVEFORMOUT-STRINGS": ["test", "waveform", "strings"], } with open(tmp_path / f"{DEVICE_NAME}.softsav", "w") as f: yaml.dump(state, f, indent=4) @@ -115,6 +119,13 @@ def test_all_record_types_saveable(tmp_path): pv.set("test string") for pv_type in waveform_types: getattr(builder, pv_type)(pv_type, numpy.zeros((100)), autosave=True) + getattr(builder, pv_type)( + f"{pv_type}_of_chars", "test waveform string", autosave=True + ) + getattr(builder, pv_type)( + f"{pv_type}_of_strings", ["array", "of", "strings"], autosave=True + ) + autosaver = autosave.Autosave() autosaver._save() @@ -166,9 +177,38 @@ def test_stop_event(tmp_path): worker.join(timeout=1) -def test_load_autosave(existing_autosave_dir): +def test_backup_on_load(existing_autosave_dir): + autosave.configure(existing_autosave_dir, DEVICE_NAME, backup=True) + # backup only performed if there are any pvs to save + builder.aOut("SAVED-AO", autosave=True) + autosave.load_autosave() + backup_files = list(existing_autosave_dir.glob("*.softsav_*")) + assert len(backup_files) == 1 + # assert backup file is named .softsave_yymmdd-HHMMSS + for file in backup_files: + assert re.match( + r"^" + DEVICE_NAME + r"\.softsav_[0-9]{6}-[0-9]{6}$", file.name + ) + + +def test_autosave_key_names(tmp_path): + builder.aOut("DEFAULTNAME", autosave=True) builder.SetDeviceName(DEVICE_NAME) - autosave.configure(existing_autosave_dir, DEVICE_NAME, backup=False) + builder.aOut("DEFAULTNAMEAFTERPREFIXSET", autosave=True) + builder.aOut("RENAMEME", autosave=True, autosave_name="CUSTOMNAME") + autosave.configure(tmp_path, DEVICE_NAME) + autosaver = autosave.Autosave() + autosaver._save() + with open(tmp_path / f"{DEVICE_NAME}.softsav", "r") as f: + saved = yaml.full_load(f) + assert "DEFAULTNAME" in saved + assert "DEFAULTNAMEAFTERPREFIXSET" in saved + assert "CUSTOMNAME" in saved + + +def check_all_record_types_load_properly(device_name, autosave_dir, conn): + builder.SetDeviceName(device_name) + autosave.configure(autosave_dir, device_name, backup=False) pv_aOut = builder.aOut("SAVED-AO", autosave=True) pv_aIn = builder.aIn("SAVED-AI", autosave=True) pv_boolOut = builder.boolOut("SAVED-BO", autosave=True) @@ -192,6 +232,16 @@ def test_load_autosave(existing_autosave_dir): pv_WaveformOut = builder.WaveformOut( "SAVED-WAVEFORMOUT", numpy.zeros((4)), autosave=True ) + pv_WaveformIn_strings = builder.WaveformIn( + "SAVED-WAVEFORMIN-STRINGS", + ["initial", "waveform", "strings"], + autosave=True, + ) + pv_WaveformOut_strings = builder.WaveformOut( + "SAVED-WAVEFORMOUT-STRINGS", + ["initial", "waveform", "strings"], + autosave=True, + ) assert pv_aOut.get() == 0.0 assert pv_aIn.get() == 0.0 assert pv_boolOut.get() == 0 @@ -209,7 +259,10 @@ def test_load_autosave(existing_autosave_dir): assert pv_Action.get() == 0 assert (pv_WaveformIn.get() == numpy.array([0, 0, 0, 0])).all() assert (pv_WaveformOut.get() == numpy.array([0, 0, 0, 0])).all() - autosave.load_autosave() + assert pv_WaveformIn_strings.get() == ["initial", "waveform", "strings"] + assert pv_WaveformOut_strings.get() == ["initial", "waveform", "strings"] + # load called automatically when LoadDatabase() called + builder.LoadDatabase() assert pv_aOut.get() == 20.0 assert pv_aIn.get() == 20.0 assert pv_boolOut.get() == 1 @@ -227,32 +280,82 @@ def test_load_autosave(existing_autosave_dir): assert pv_Action.get() == 1 assert (pv_WaveformIn.get() == numpy.array([1, 2, 3, 4])).all() assert (pv_WaveformOut.get() == numpy.array([1, 2, 3, 4])).all() + assert pv_WaveformIn_strings.get() == ["test", "waveform", "strings"] + assert pv_WaveformOut_strings.get() == ["test", "waveform", "strings"] + conn.send("D") # "Done" -def test_backup_on_load(existing_autosave_dir): - autosave.configure(existing_autosave_dir, DEVICE_NAME, backup=True) - # backup only performed if there are any pvs to save - builder.aOut("SAVED-AO", autosave=True) - autosave.load_autosave() - backup_files = list(existing_autosave_dir.glob("*.softsav_*")) - assert len(backup_files) == 1 - # assert backup file is named .softsave_yymmdd-HHMMSS - for file in backup_files: - assert re.match( - r"^" + DEVICE_NAME + r"\.softsav_[0-9]{6}-[0-9]{6}$", file.name - ) +def test_actual_ioc_load(existing_autosave_dir): + ctx = get_multiprocessing_context() + parent_conn, child_conn = ctx.Pipe() + ioc_process = ctx.Process( + target=check_all_record_types_load_properly, + args=(DEVICE_NAME, existing_autosave_dir, child_conn), + ) + ioc_process.start() + # If we never receive D it probably means an assert failed + select_and_recv(parent_conn, "D") -def test_autosave_key_names(tmp_path): - builder.aOut("DEFAULTNAME", autosave=True) - builder.SetDeviceName(DEVICE_NAME) - builder.aOut("DEFAULTNAMEAFTERPREFIXSET", autosave=True) - builder.aOut("RENAMEME", autosave=True, autosave_name="CUSTOMNAME") - autosave.configure(tmp_path, DEVICE_NAME) - autosaver = autosave.Autosave() - autosaver._save() - with open(tmp_path / f"{DEVICE_NAME}.softsav", "r") as f: +def check_all_record_types_save_properly(device_name, autosave_dir, conn): + builder.SetDeviceName(device_name) + autosave.configure(autosave_dir, device_name, save_period=1) + builder.aOut("aOut", autosave=True, initial_value=20.0) + builder.aIn("aIn", autosave=True, initial_value=20.0) + builder.boolOut("boolOut", autosave=True, initial_value=1) + builder.boolIn("boolIn", autosave=True, initial_value=1) + builder.longIn("longIn", autosave=True, initial_value=20) + builder.longOut("longOut", autosave=True, initial_value=20) + builder.int64In("int64In", autosave=True, initial_value=100) + builder.int64Out("int64Out", autosave=True, initial_value=100) + builder.mbbIn("mbbIn", autosave=True, initial_value=15) + builder.mbbOut("mbbOut", autosave=True, initial_value=15) + builder.stringIn("stringIn", autosave=True, initial_value="test string in") + builder.stringOut( + "stringOut", autosave=True, initial_value="test string out" + ) + builder.longStringIn( + "longStringIn", autosave=True, initial_value="test long string in" + ) + builder.longStringOut( + "longStringOut", autosave=True, initial_value="test long string out" + ) + builder.Action("Action", autosave=True, initial_value=1) + builder.WaveformIn("WaveformIn", [1, 2, 3, 4], autosave=True) + builder.WaveformOut("WaveformOut", [1, 2, 3, 4], autosave=True) + builder.LoadDatabase() + softioc.iocInit() + # wait long enough to ensure one save has occurred + time.sleep(2) + with open(autosave_dir / f"{device_name}.softsav", "r") as f: saved = yaml.full_load(f) - assert "DEFAULTNAME" in saved - assert "DEFAULTNAMEAFTERPREFIXSET" in saved - assert "CUSTOMNAME" in saved + assert saved["aOut"] == 20.0 + assert saved["aIn"] == 20.0 + assert saved["boolOut"] == 1 + assert saved["boolIn"] == 1 + assert saved["longIn"] == 20 + assert saved["longOut"] == 20 + assert saved["int64In"] == 100 + assert saved["int64Out"] == 100 + assert saved["mbbIn"] == 15 + assert saved["mbbOut"] == 15 + assert saved["stringIn"] == "test string in" + assert saved["stringOut"] == "test string out" + assert saved["longStringIn"] == "test long string in" + assert saved["longStringOut"] == "test long string out" + assert saved["Action"] == 1 + assert (saved["WaveformIn"] == numpy.array([1, 2, 3, 4])).all() + assert (saved["WaveformOut"] == numpy.array([1, 2, 3, 4])).all() + conn.send("D") + + +def test_actual_ioc_save(tmp_path): + ctx = get_multiprocessing_context() + parent_conn, child_conn = ctx.Pipe() + ioc_process = ctx.Process( + target=check_all_record_types_save_properly, + args=(DEVICE_NAME, tmp_path, child_conn), + ) + ioc_process.start() + # If we never receive D it probably means an assert failed + select_and_recv(parent_conn, "D") From 500bed7eb18181f66a3416e83240e8098f18f323 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 18 Jul 2024 13:25:14 +0100 Subject: [PATCH 29/53] Remove ability to override autosave backup key for record --- docs/examples/example_autosave_ioc.py | 1 - docs/how-to/use-autosave-in-an-ioc.rst | 12 +++++------- softioc/pythonSoftIoc.py | 4 ++-- tests/test_autosave.py | 2 -- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/docs/examples/example_autosave_ioc.py b/docs/examples/example_autosave_ioc.py index 9ef79321..a1a49a19 100644 --- a/docs/examples/example_autosave_ioc.py +++ b/docs/examples/example_autosave_ioc.py @@ -7,7 +7,6 @@ # Create records, set some of them to autosave, also save some of their fields builder.aOut("AO", autosave=True) -builder.aOut("AO2", autosave=True, autosave_name="EXTRA-AOUT") builder.aIn("AI", autosave_fields=["PREC", "SCAN"]) builder.boolIn("BO") builder.WaveformIn("WAVEFORMOUT", [0, 0, 0, 0], autosave=True) diff --git a/docs/how-to/use-autosave-in-an-ioc.rst b/docs/how-to/use-autosave-in-an-ioc.rst index 0b1648c1..8ad1df84 100644 --- a/docs/how-to/use-autosave-in-an-ioc.rst +++ b/docs/how-to/use-autosave-in-an-ioc.rst @@ -10,25 +10,24 @@ Example IOC .. literalinclude:: ../examples/example_autosave_ioc.py Records are instantiated as normal and configured for automatic loading and -periodic saving to a backup file with the keyword arguments ``autosave``, ``autosave_fields`` -and ``autosave_name``. Records with ``autosave=True`` (``False`` by default) have their +periodic saving to a backup file with the keyword arguments ``autosave`` and ``autosave_fields``. +Records with ``autosave=True`` (``False`` by default) have their VAL fields backed up. Additional record fields in a list passed to ``autosave_fields`` will be backed up, note that this applies even when ``autosave`` is ``False``. The field values get written into a yaml-formatted file containing key-value pairs, by default the keys are the same as the record name passed as the first argument to the `builder.()` call, excluding the device name specified in the `builder.SetDeviceName()` -call, but this can be changed to a string name specified in the ``autosave_name`` keyword -argument. +call. -Autosave is disabled by default until `autosave.configure()` is called. The first two arguments, +Autosave is disabled by default until `autosave.configure()` is called. The first two arguments, ``directory`` and ``name`` are required. Backup files are periodically written into ``directory`` with the name ``.softsav`` every ``save_period`` seconds, set to 30.0 by default. The directory must exist, and should be configured with the appropriate read/write permissions for the user running the IOC. IOC developers should only need to interface with autosave via the `autosave.configre()` -method and the ``autosave``, ``autosave_fields`` and ``autosave_name`` keyword arguments, +method and the ``autosave`` and ``autosave_fields`` keyword arguments, all other module members are intended for internal use only. In normal operation, loading from a backup is performed once during the @@ -52,7 +51,6 @@ The resulting backup file after running the IOC for a minute is the following: AI.PREC: '0' AI.SCAN: I/O Intr AO: 0.0 - EXTRA-AOUT: 0.0 MINUTESRUN: 1 WAVEFORMOUT: [0, 0, 0, 0] diff --git a/softioc/pythonSoftIoc.py b/softioc/pythonSoftIoc.py index acf8c510..5928b945 100644 --- a/softioc/pythonSoftIoc.py +++ b/softioc/pythonSoftIoc.py @@ -25,13 +25,13 @@ def __init__(self, builder, device, name, **fields): DeviceKeywords = [ 'on_update', 'on_update_name', 'validate', 'always_update', 'initial_value', '_wf_nelm', '_wf_dtype', 'blocking', - 'autosave', 'autosave_fields', 'autosave_name' + 'autosave', 'autosave_fields' ] device_kargs = {} for keyword in DeviceKeywords: if keyword in fields: device_kargs[keyword] = fields.pop(keyword) - device_kargs.setdefault('autosave_name', name) + device_kargs['autosave_name'] = name record = builder(name, **fields) record.address = '@' + record.name diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 0029730b..264161e1 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -195,7 +195,6 @@ def test_autosave_key_names(tmp_path): builder.aOut("DEFAULTNAME", autosave=True) builder.SetDeviceName(DEVICE_NAME) builder.aOut("DEFAULTNAMEAFTERPREFIXSET", autosave=True) - builder.aOut("RENAMEME", autosave=True, autosave_name="CUSTOMNAME") autosave.configure(tmp_path, DEVICE_NAME) autosaver = autosave.Autosave() autosaver._save() @@ -203,7 +202,6 @@ def test_autosave_key_names(tmp_path): saved = yaml.full_load(f) assert "DEFAULTNAME" in saved assert "DEFAULTNAMEAFTERPREFIXSET" in saved - assert "CUSTOMNAME" in saved def check_all_record_types_load_properly(device_name, autosave_dir, conn): From 3c872411df8e05416bd47be29b38867e1fa8623a Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 23 Jul 2024 13:50:47 +0100 Subject: [PATCH 30/53] Add autosave documentation to api.rst, set save period to default directly in configure --- docs/reference/api.rst | 84 ++++++++++++++++++++++++++++++++++++++++++ softioc/autosave.py | 13 +++++-- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/docs/reference/api.rst b/docs/reference/api.rst index d5d9aa97..9a06d038 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -142,6 +142,64 @@ and stderr streams, is sent directly to the terminal. .. autoclass:: softioc.asyncio_dispatcher.AsyncioDispatcher +.. automodule:: softioc.autosave + + Configuring saving and loading of record fields with `softioc.autosave.configure` + --------------------------------------------------------------------------------- + + .. function:: configure(directory, name, save_period=30, backup=True, enabled=True) + Used to set the location of backup files. + Backups are disabled by default unless this method is called. It must be + called prior to :func:`~softioc.builder.LoadDatabase`. + + It has the following arguments: + + .. _directory: + + `directory` + ~~~~~~~~~~~ + The directory where backup files should be saved to and loaded + from. This argument is required. + + .. _name: + + + `name` + ~~~~~~ + The file prefix used for naming the backup files. This is typically set to + be the same as the device prefix. The resulting file name will be + `name`.softsav. This argument is required + + .. _save_period: + + `save_period` + ~~~~~~~~~~~~~ + The period in seconds between each backup attempt, 30.0 by default. + Backup files are only overwritten if any of the field values have changed + since the last backup. + + .. _backup: + + `backup` + ~~~~~~~~ + A boolean that is `True` by default, creates a backup of the latest existing + autosave file that is timestamped at the time that the autosave thread is + started. + In normal operation, the current autosave file is overwritten every save. + + .. _enabled: + + `enabled` + ~~~~~~~~~ + A boolean that is `True` by default, if `False` then no loading will occur + at IOC startup, and the save thread will return without performing any + backups. + + .. seealso:: + `softioc.builder` for how to designate a field for autosave. + + + .. automodule:: softioc.builder Creating Records: `softioc.builder` @@ -260,6 +318,32 @@ and stderr streams, is sent directly to the terminal. .. seealso:: `SetBlocking` for configuring a global default blocking value + .. _autosave: + + `autosave` + ~~~~~~~~~~ + + Available on all record types, when set to `True` it marks the record + value for automatic periodic backing up to a file. Set to `False` by + default. When the IOC is restarted and a backup file exists, the value is + loaded from this file when :func:`~softioc.builder.LoadDatabase` is called. + The saved value takes priority over any value + given in `initial_value`. No backing up will occur unless autosave is + enabled and configured with :func:`~softioc.autosave.configure`. + + .. seealso:: + :func:`~softioc.autosave.configure` for discussion on how to configure saving. + + .. _autosave_fields: + + `autosave_fields` + ~~~~~~~~~~~~~~~~~ + + A list of strings of record fields, (e.g. ["SCAN", "PREC"]) to be saved to + and loaded from a backup file, empty by default. + Works in an identical way to `autosave`. + + For all of these functions any EPICS database field can be assigned a value by passing it as a keyword argument for the corresponding field name (in upper case) or by assigning to the corresponding field of the returned record object. diff --git a/softioc/autosave.py b/softioc/autosave.py index cb32fb66..416b8848 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -12,6 +12,7 @@ SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" +DEFAULT_SAVE_PERIOD = 30.0 def _ndarray_representer(dumper, array): @@ -20,7 +21,9 @@ def _ndarray_representer(dumper, array): ) -def configure(directory, name, save_period=None, backup=True, enabled=True): +def configure( + directory, name, save_period=DEFAULT_SAVE_PERIOD, backup=True, enabled=True +): """This should be called before initialising the IOC. Configures the autosave thread for periodic backing up of PV values. @@ -38,7 +41,7 @@ def configure(directory, name, save_period=None, backup=True, enabled=True): """ Autosave.directory = Path(directory) Autosave.backup_on_load = backup - Autosave.save_period = save_period or Autosave.save_period + Autosave.save_period = save_period Autosave.enabled = enabled Autosave.device_name = name @@ -96,7 +99,7 @@ class Autosave: _last_saved_state = {} _last_saved_time = datetime.now() _stop_event = threading.Event() - save_period = 30.0 + save_period = DEFAULT_SAVE_PERIOD device_name = None directory = None enabled = False @@ -181,7 +184,6 @@ def _set_pvs_from_saved_state(cls): ) traceback.print_exc() - def _state_changed(self, state): return self._last_saved_state.keys() != state.keys() or any( # checks equality for builtins and numpy arrays @@ -233,3 +235,6 @@ def loop(self): return except Exception: traceback.print_exc() + + +__all__ = ["configure"] From cdcb4e9c1103b3ad939a77e16cea37b54b68cca7 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 25 Jul 2024 08:54:12 +0100 Subject: [PATCH 31/53] add autosave to CHANGELOG.rst --- CHANGELOG.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0b472331..bae0034a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,7 +11,7 @@ Unreleased_ ----------- Added: - +- `Add autosave support to all records and record fields <../../pull/163>` - `Add int64In/Out record support <../../pull/161>`_ - `Enable setting alarm status of Out records <../../pull/157>`_ - `Adding the non_interactive_ioc function <../../pull/156>`_ From 2f318387688d680c7370131b1b1ccbc6d894f40d Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 7 Aug 2024 14:37:38 +0100 Subject: [PATCH 32/53] Allow autosave backups to be overwritten without timestamps fix docs --- docs/examples/example_autosave_ioc.py | 4 +- docs/how-to/use-autosave-in-an-ioc.rst | 13 +++--- softioc/autosave.py | 65 +++++++++++++++----------- tests/test_autosave.py | 38 +++++++++------ 4 files changed, 73 insertions(+), 47 deletions(-) diff --git a/docs/examples/example_autosave_ioc.py b/docs/examples/example_autosave_ioc.py index a1a49a19..d778f2ee 100644 --- a/docs/examples/example_autosave_ioc.py +++ b/docs/examples/example_autosave_ioc.py @@ -7,13 +7,13 @@ # Create records, set some of them to autosave, also save some of their fields builder.aOut("AO", autosave=True) -builder.aIn("AI", autosave_fields=["PREC", "SCAN"]) +builder.aIn("AI", autosave_fields=["PREC", "EGU"]) builder.boolIn("BO") builder.WaveformIn("WAVEFORMOUT", [0, 0, 0, 0], autosave=True) minutes = builder.longOut("MINUTESRUN", autosave=True) autosave.configure( - directory="/tmp/autosave-data/MY-DEVICE-PREFIX", + directory="/tmp/autosave-data", name="MY-DEVICE-PREFIX", save_period=20.0 ) diff --git a/docs/how-to/use-autosave-in-an-ioc.rst b/docs/how-to/use-autosave-in-an-ioc.rst index 8ad1df84..36473cb2 100644 --- a/docs/how-to/use-autosave-in-an-ioc.rst +++ b/docs/how-to/use-autosave-in-an-ioc.rst @@ -26,7 +26,7 @@ Autosave is disabled by default until `autosave.configure()` is called. The firs set to 30.0 by default. The directory must exist, and should be configured with the appropriate read/write permissions for the user running the IOC. -IOC developers should only need to interface with autosave via the `autosave.configre()` +IOC developers should only need to interface with autosave via the `autosave.configure()` method and the ``autosave`` and ``autosave_fields`` keyword arguments, all other module members are intended for internal use only. @@ -38,18 +38,19 @@ Users are discouraged from manually editing the backup files while the IOC is running so that the internal state of the autosave thread is consistent with the backup file. -If autosave is enabled and active, a timestamped backup file of the latest existing backup file is created -when the IOC is restarted, e.g. ``.softsave-240717-095004`` (timestamps are in the format yymmdd-HHMMSS). -This can be disabled by passing ``backup_on_load=False`` to `autosave.configure()`. -To disable any autosaving, comment out the `autosave.configre()` call or pass it the keyword argument +If autosave is enabled and active, a timestamped copy of the latest existing autosave backup file is created +when the IOC is restarted, e.g. ``.softsav_240717-095004`` (timestamps are in the format yymmdd-HHMMSS). +If you only wish to store one backup of the autosave file at a time, ``timestamped_backups=False`` can be passed to `autosave.configure()`, +this will create a backup file named ``.softsav.bu``. +To disable any autosaving, comment out the `autosave.configure()` call or pass it the keyword argument ``enabled=False``. The resulting backup file after running the IOC for a minute is the following: .. code-block:: + AI.EGU: '' AI.PREC: '0' - AI.SCAN: I/O Intr AO: 0.0 MINUTESRUN: 1 WAVEFORMOUT: [0, 0, 0, 0] diff --git a/softioc/autosave.py b/softioc/autosave.py index 416b8848..acacf9eb 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -22,7 +22,11 @@ def _ndarray_representer(dumper, array): def configure( - directory, name, save_period=DEFAULT_SAVE_PERIOD, backup=True, enabled=True + directory, + name, + save_period=DEFAULT_SAVE_PERIOD, + timestamped_backups=True, + enabled=True ): """This should be called before initialising the IOC. Configures the autosave thread for periodic backing up of PV values. @@ -34,13 +38,14 @@ def configure( is usually the same as the device prefix. save_period: time in seconds between backups. Backups are only performed if PV values have changed. - backup: creates a backup of the loaded autosave file on load, - timestamped with the time of backup. + timestamped_backups: boolean which determines if backups of existing + autosave files are timestamped on IOC restart. True by default, if + False then backups get overwritten on each IOC restart. enabled: boolean which enables or disables autosave, set to True by default, or False if configure not called. """ Autosave.directory = Path(directory) - Autosave.backup_on_load = backup + Autosave.timestamped_backups = timestamped_backups Autosave.save_period = save_period Autosave.enabled = enabled Autosave.device_name = name @@ -103,7 +108,7 @@ class Autosave: device_name = None directory = None enabled = False - backup_on_load = False + timestamped_backups = True def __init__(self): if not self.enabled: @@ -129,7 +134,7 @@ def __init__(self): self._last_saved_time = datetime.now() @classmethod - def _backup_sav_file(cls): + def __backup_sav_file(cls): if not cls.directory and cls.directory.is_dir(): print( f"Could not back up autosave as {cls.directory} is" @@ -137,9 +142,13 @@ def _backup_sav_file(cls): file=sys.stderr, ) return - sav_path = cls._get_current_sav_path() + sav_path = cls.__get_current_sav_path() + if cls.timestamped_backups: + backup_path = cls.__get_timestamped_backup_sav_path() + else: + backup_path = cls.__get_backup_sav_path() if sav_path.is_file(): - copy2(sav_path, cls._get_timestamped_backup_sav_path()) + copy2(sav_path, backup_path) else: print( f"Could not back up autosave, {sav_path} is not a file", @@ -147,21 +156,26 @@ def _backup_sav_file(cls): ) @classmethod - def _get_timestamped_backup_sav_path(cls): - sav_path = cls._get_current_sav_path() + def __get_timestamped_backup_sav_path(cls): + sav_path = cls.__get_current_sav_path() return sav_path.parent / ( sav_path.name + cls._last_saved_time.strftime("_%y%m%d-%H%M%S") ) @classmethod - def _get_backup_sav_path(cls): + def __get_backup_sav_path(cls): + sav_path = cls.__get_current_sav_path() + return sav_path.parent / (sav_path.name + ".bu") + + @classmethod + def __get_tmp_sav_path(cls): return cls.directory / f"{cls.device_name}.{SAVB_SUFFIX}" @classmethod - def _get_current_sav_path(cls): + def __get_current_sav_path(cls): return cls.directory / f"{cls.device_name}.{SAV_SUFFIX}" - def _get_state(self): + def __get_state(self): state = {} for pv_field, pv in self._pvs.items(): try: @@ -172,7 +186,7 @@ def _get_state(self): return state @classmethod - def _set_pvs_from_saved_state(cls): + def __set_pvs_from_saved_state(cls): for pv_field, value in cls._last_saved_state.items(): try: pv = cls._pvs[pv_field] @@ -184,7 +198,7 @@ def _set_pvs_from_saved_state(cls): ) traceback.print_exc() - def _state_changed(self, state): + def __state_changed(self, state): return self._last_saved_state.keys() != state.keys() or any( # checks equality for builtins and numpy arrays not numpy.array_equal(state[key], self._last_saved_state[key]) @@ -192,15 +206,15 @@ def _state_changed(self, state): ) def _save(self): - state = self._get_state() - if self._state_changed(state): - sav_path = self._get_current_sav_path() - backup_path = self._get_backup_sav_path() - # write to backup file first then use atomic os.rename + state = self.__get_state() + if self.__state_changed(state): + sav_path = self.__get_current_sav_path() + tmp_path = self.__get_tmp_sav_path() + # write to temporary file first then use atomic os.rename # to safely update stored state - with open(backup_path, "w") as backup: + with open(tmp_path, "w") as backup: yaml.dump(state, backup, indent=4) - rename(backup_path, sav_path) + rename(tmp_path, sav_path) self._last_saved_state = state self._last_saved_time = datetime.now() @@ -208,9 +222,8 @@ def _save(self): def _load(cls, path=None): if not cls.enabled or not cls._pvs: return - if cls.backup_on_load: - cls._backup_sav_file() - sav_path = path or cls._get_current_sav_path() + cls.__backup_sav_file() + sav_path = path or cls.__get_current_sav_path() if not sav_path or not sav_path.is_file(): print( f"Could not load autosave values from file {sav_path}", @@ -219,7 +232,7 @@ def _load(cls, path=None): return with open(sav_path, "r") as f: cls._last_saved_state = yaml.full_load(f) - cls._set_pvs_from_saved_state() + cls.__set_pvs_from_saved_state() def stop(self): self._stop_event.set() diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 264161e1..79b985dc 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -19,7 +19,7 @@ def reset_autosave_setup_teardown(): default_device_name = autosave.Autosave.device_name default_directory = autosave.Autosave.directory default_enabled = autosave.Autosave.enabled - default_bol = autosave.Autosave.backup_on_load + default_tb = autosave.Autosave.timestamped_backups yield autosave.Autosave._pvs = default_pvs autosave.Autosave._last_saved_state = default_state @@ -28,7 +28,7 @@ def reset_autosave_setup_teardown(): autosave.Autosave.device_name = default_device_name autosave.Autosave.directory = default_directory autosave.Autosave.enabled = default_enabled - autosave.Autosave.backup_on_load = default_bol + autosave.Autosave.timestamped_backups = default_tb if builder.GetRecordNames().prefix: # reset device name to empty if set builder.SetDeviceName("") @@ -58,6 +58,8 @@ def existing_autosave_dir(tmp_path): } with open(tmp_path / f"{DEVICE_NAME}.softsav", "w") as f: yaml.dump(state, f, indent=4) + with open(tmp_path / f"{DEVICE_NAME}.softsav.bu", "w") as f: + yaml.dump({"OUT-OF-DATE-KEY": "out of date value"}, f, indent=4) return tmp_path @@ -78,7 +80,7 @@ def test_autosave_defaults(): assert autosave.Autosave.device_name is None assert autosave.Autosave.directory is None assert autosave.Autosave.enabled is False - assert autosave.Autosave.backup_on_load is False + assert autosave.Autosave.timestamped_backups is True def test_configure_dir_doesnt_exist(tmp_path): @@ -177,19 +179,29 @@ def test_stop_event(tmp_path): worker.join(timeout=1) -def test_backup_on_load(existing_autosave_dir): - autosave.configure(existing_autosave_dir, DEVICE_NAME, backup=True) +@pytest.mark.parametrize( + "timestamped,regex", + [(False, r"^" + DEVICE_NAME + r"\.softsav_[0-9]{6}-[0-9]{6}$"), + (True, r"^" + DEVICE_NAME + r"\.softsav\.bu$")] + ) +def test_backup_on_load(existing_autosave_dir, timestamped, regex): + autosave.configure( + existing_autosave_dir, + DEVICE_NAME, + timestamped_backups=timestamped + ) # backup only performed if there are any pvs to save builder.aOut("SAVED-AO", autosave=True) autosave.load_autosave() backup_files = list(existing_autosave_dir.glob("*.softsav_*")) - assert len(backup_files) == 1 - # assert backup file is named .softsave_yymmdd-HHMMSS - for file in backup_files: - assert re.match( - r"^" + DEVICE_NAME + r"\.softsav_[0-9]{6}-[0-9]{6}$", file.name - ) - + # assert backup is .softsav_yymmdd-HHMMSS or .softsav.bu + any(re.match(regex, file.name) for file in backup_files) + if not timestamped: + # test that existing .bu file gets overwritten + with open(existing_autosave_dir / f"{DEVICE_NAME}.softsav.bu") as f: + state = yaml.full_load(f) + assert "OUT-OF-DATE-KEY" not in state + assert "SAVED-AO" in state def test_autosave_key_names(tmp_path): builder.aOut("DEFAULTNAME", autosave=True) @@ -206,7 +218,7 @@ def test_autosave_key_names(tmp_path): def check_all_record_types_load_properly(device_name, autosave_dir, conn): builder.SetDeviceName(device_name) - autosave.configure(autosave_dir, device_name, backup=False) + autosave.configure(autosave_dir, device_name) pv_aOut = builder.aOut("SAVED-AO", autosave=True) pv_aIn = builder.aIn("SAVED-AI", autosave=True) pv_boolOut = builder.boolOut("SAVED-BO", autosave=True) From 2b72c41e09397f0c9f306f58bab4773923da7714 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 12 Aug 2024 14:45:34 +0100 Subject: [PATCH 33/53] only use device prefix in full system autosave tests to prevent breaking other test files --- tests/test_autosave.py | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 79b985dc..dd737e35 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -29,8 +29,6 @@ def reset_autosave_setup_teardown(): autosave.Autosave.directory = default_directory autosave.Autosave.enabled = default_enabled autosave.Autosave.timestamped_backups = default_tb - if builder.GetRecordNames().prefix: # reset device name to empty if set - builder.SetDeviceName("") @pytest.fixture @@ -96,7 +94,6 @@ def test_returns_if_init_called_before_configure(): def test_all_record_types_saveable(tmp_path): - builder.SetDeviceName(DEVICE_NAME) autosave.configure(tmp_path, DEVICE_NAME) number_types = [ @@ -138,7 +135,6 @@ def test_all_record_types_saveable(tmp_path): def test_can_save_fields(tmp_path): - builder.SetDeviceName(DEVICE_NAME) builder.aOut("SAVEVAL", autosave=True, autosave_fields=["DISA"]) builder.aOut("DONTSAVEVAL", autosave_fields=["SCAN"]) # we need to patch get_field as we can't call builder.LoadDatabase() @@ -205,7 +201,6 @@ def test_backup_on_load(existing_autosave_dir, timestamped, regex): def test_autosave_key_names(tmp_path): builder.aOut("DEFAULTNAME", autosave=True) - builder.SetDeviceName(DEVICE_NAME) builder.aOut("DEFAULTNAMEAFTERPREFIXSET", autosave=True) autosave.configure(tmp_path, DEVICE_NAME) autosaver = autosave.Autosave() @@ -217,7 +212,6 @@ def test_autosave_key_names(tmp_path): def check_all_record_types_load_properly(device_name, autosave_dir, conn): - builder.SetDeviceName(device_name) autosave.configure(autosave_dir, device_name) pv_aOut = builder.aOut("SAVED-AO", autosave=True) pv_aIn = builder.aIn("SAVED-AI", autosave=True) @@ -308,7 +302,6 @@ def test_actual_ioc_load(existing_autosave_dir): def check_all_record_types_save_properly(device_name, autosave_dir, conn): - builder.SetDeviceName(device_name) autosave.configure(autosave_dir, device_name, save_period=1) builder.aOut("aOut", autosave=True, initial_value=20.0) builder.aIn("aIn", autosave=True, initial_value=20.0) @@ -369,3 +362,32 @@ def test_actual_ioc_save(tmp_path): ioc_process.start() # If we never receive D it probably means an assert failed select_and_recv(parent_conn, "D") + + +def check_autosave_field_names_exclude_builder_prefix( + device_name, tmp_path, conn): + autosave.configure(tmp_path, device_name, save_period=1) + builder.aOut("BEFORE", autosave=True, autosave_fields=["EGU"]) + builder.SetDeviceName(device_name) + builder.aOut("AFTER", autosave=True, autosave_fields=["EGU"]) + builder.LoadDatabase() + softioc.iocInit() + time.sleep(2) + with open(tmp_path / f"{device_name}.softsav", "r") as f: + saved = yaml.full_load(f) + assert "BEFORE" in saved.keys() + assert "AFTER" in saved.keys() + for key in saved: + assert device_name not in key + conn.send("D") + +def test_autosave_field_names_exclude_builder_prefix(tmp_path): + ctx = get_multiprocessing_context() + parent_conn, child_conn = ctx.Pipe() + ioc_process = ctx.Process( + target=check_autosave_field_names_exclude_builder_prefix, + args=(DEVICE_NAME, tmp_path, child_conn), + ) + ioc_process.start() + # If we never receive D it probably means an assert failed + select_and_recv(parent_conn, "D") From facfd218a7059f07f8d6fc455d9f3cbfcc609982 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 21 Aug 2024 14:02:35 +0100 Subject: [PATCH 34/53] Rollback change to strip device prefix from autosave key --- softioc/autosave.py | 2 +- softioc/device.py | 3 +-- softioc/pythonSoftIoc.py | 1 - tests/test_autosave.py | 10 ++++------ 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index acacf9eb..c238a511 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -73,7 +73,7 @@ def add_pv_to_autosave(pv, name, save_val, save_fields): pv: a PV object inheriting ProcessDeviceSupportCore name: the name of the PV which is used to generate the key by which the PV value is saved to and loaded from a backup, - this is typically the signal name without the device prefix + this is typically the same as the PV name. save_val: a boolean that tracks whether to save the VAL field in an autosave backup save_fields: a list of string names of fields associated with the pv diff --git a/softioc/device.py b/softioc/device.py index 02f02a68..a5f01fd8 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -57,10 +57,9 @@ class ProcessDeviceSupportCore(DeviceSupportCore, RecordLookup): def __init__(self, name, **kargs): autosave_enabled = kargs.pop("autosave", False) autosave_fields = kargs.pop("autosave_fields", []) - autosave_name = kargs.pop("autosave_name", name) if autosave_enabled or autosave_fields: add_pv_to_autosave( - self, autosave_name, autosave_enabled, autosave_fields) + self, name, autosave_enabled, autosave_fields) self.__super.__init__(name, **kargs) # Most subclasses (all except waveforms) define a ctypes constructor for the diff --git a/softioc/pythonSoftIoc.py b/softioc/pythonSoftIoc.py index 5928b945..c755e3f4 100644 --- a/softioc/pythonSoftIoc.py +++ b/softioc/pythonSoftIoc.py @@ -31,7 +31,6 @@ def __init__(self, builder, device, name, **fields): for keyword in DeviceKeywords: if keyword in fields: device_kargs[keyword] = fields.pop(keyword) - device_kargs['autosave_name'] = name record = builder(name, **fields) record.address = '@' + record.name diff --git a/tests/test_autosave.py b/tests/test_autosave.py index dd737e35..2a1012ef 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -364,7 +364,7 @@ def test_actual_ioc_save(tmp_path): select_and_recv(parent_conn, "D") -def check_autosave_field_names_exclude_builder_prefix( +def check_autosave_field_names_contain_device_prefix( device_name, tmp_path, conn): autosave.configure(tmp_path, device_name, save_period=1) builder.aOut("BEFORE", autosave=True, autosave_fields=["EGU"]) @@ -376,16 +376,14 @@ def check_autosave_field_names_exclude_builder_prefix( with open(tmp_path / f"{device_name}.softsav", "r") as f: saved = yaml.full_load(f) assert "BEFORE" in saved.keys() - assert "AFTER" in saved.keys() - for key in saved: - assert device_name not in key + assert f"{device_name}:AFTER" in saved.keys() conn.send("D") -def test_autosave_field_names_exclude_builder_prefix(tmp_path): +def test_autosave_field_names_contain_device_prefix(tmp_path): ctx = get_multiprocessing_context() parent_conn, child_conn = ctx.Pipe() ioc_process = ctx.Process( - target=check_autosave_field_names_exclude_builder_prefix, + target=check_autosave_field_names_contain_device_prefix, args=(DEVICE_NAME, tmp_path, child_conn), ) ioc_process.start() From a4cffad7d4f0472a2a35ddebc56549ddcb83bee0 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 21 Aug 2024 14:59:00 +0100 Subject: [PATCH 35/53] Allow autosave.Autosave to be called as a context manager --- softioc/autosave.py | 76 ++++++++++++++++++++++++++---------------- softioc/device.py | 4 +-- tests/test_autosave.py | 46 ++++++++++++++++++++----- 3 files changed, 86 insertions(+), 40 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index c238a511..f584b53c 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -10,6 +10,8 @@ import numpy import yaml +from .device_core import LookupRecordList + SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" DEFAULT_SAVE_PERIOD = 30.0 @@ -20,6 +22,7 @@ def _ndarray_representer(dumper, array): "tag:yaml.org,2002:seq", array.tolist(), flow_style=True ) +yaml.add_representer(numpy.ndarray, _ndarray_representer, Dumper=yaml.Dumper) def configure( directory, @@ -35,7 +38,7 @@ def configure( directory: string or Path giving directory path where autosave backup files are saved and loaded. name: string name of the root used for naming backup files, this - is usually the same as the device prefix. + is usually the same as the device name. save_period: time in seconds between backups. Backups are only performed if PV values have changed. timestamped_backups: boolean which determines if backups of existing @@ -109,29 +112,32 @@ class Autosave: directory = None enabled = False timestamped_backups = True + _loop_started = False + + def __init__(self, autosave=True, autosave_fields=None): + # for use as a context manager + self._save_val = autosave + self._save_fields = autosave_fields or [] + + def __enter__(self): + if self._save_val or self._save_fields: + self._records_before_cm = dict(LookupRecordList()) + else: + self._records_before_cm = {} + + def __exit__(self, A, B, C): + if self._save_val or self._save_fields: + for key, pv in LookupRecordList(): + try: + if key not in self._records_before_cm: + add_pv_to_autosave( + pv, key, self._save_val, self._save_fields) + except Exception: + traceback.print_exc() + self._save_val = False + self._save_fields = [] + self._records_before_cm = None - def __init__(self): - if not self.enabled: - return - yaml.add_representer( - numpy.ndarray, _ndarray_representer, Dumper=yaml.Dumper - ) - if not self.device_name: - raise RuntimeError( - "Device name is not known to autosave thread, " - "call autosave.configure() with keyword argument name" - ) - if not self.directory: - raise RuntimeError( - "Autosave directory is not known, call " - "autosave.configure() with keyword argument " - "directory" - ) - if not self.directory.is_dir(): - raise FileNotFoundError( - f"{self.directory} is not a valid autosave directory" - ) - self._last_saved_time = datetime.now() @classmethod def __backup_sav_file(cls): @@ -219,11 +225,25 @@ def _save(self): self._last_saved_time = datetime.now() @classmethod - def _load(cls, path=None): + def _load(cls): if not cls.enabled or not cls._pvs: return + if not cls.device_name: + raise RuntimeError( + "Device name is not known to autosave thread, " + "call autosave.configure() with keyword argument name" + ) + if not cls.directory: + raise RuntimeError( + "Autosave directory is not known, call " + "autosave.configure() with keyword argument directory" + ) + if not cls.directory.is_dir(): + raise FileNotFoundError( + f"{cls.directory} is not a valid autosave directory" + ) cls.__backup_sav_file() - sav_path = path or cls.__get_current_sav_path() + sav_path = cls.__get_current_sav_path() if not sav_path or not sav_path.is_file(): print( f"Could not load autosave values from file {sav_path}", @@ -238,8 +258,9 @@ def stop(self): self._stop_event.set() def loop(self): - if not self.enabled or not self._pvs: + if not self.enabled or not self._pvs or self._loop_started: return + self._loop_started = True while True: try: self._stop_event.wait(timeout=self.save_period) @@ -248,6 +269,3 @@ def loop(self): return except Exception: traceback.print_exc() - - -__all__ = ["configure"] diff --git a/softioc/device.py b/softioc/device.py index a5f01fd8..2c3d0d33 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -3,7 +3,7 @@ import ctypes from ctypes import * import numpy -from .autosave import add_pv_to_autosave +from . import autosave from . import alarm from . import fields from .imports import ( @@ -58,7 +58,7 @@ def __init__(self, name, **kargs): autosave_enabled = kargs.pop("autosave", False) autosave_fields = kargs.pop("autosave_fields", []) if autosave_enabled or autosave_fields: - add_pv_to_autosave( + autosave.add_pv_to_autosave( self, name, autosave_enabled, autosave_fields) self.__super.__init__(name, **kargs) diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 2a1012ef..091c58f0 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -83,9 +83,10 @@ def test_autosave_defaults(): def test_configure_dir_doesnt_exist(tmp_path): DEVICE_NAME = "MY_DEVICE" + builder.aOut("MY-RECORD", autosave=True) autosave.configure(tmp_path / "subdir-doesnt-exist", DEVICE_NAME) with pytest.raises(FileNotFoundError): - autosave.Autosave() + autosave.load_autosave() def test_returns_if_init_called_before_configure(): @@ -176,15 +177,15 @@ def test_stop_event(tmp_path): @pytest.mark.parametrize( - "timestamped,regex", - [(False, r"^" + DEVICE_NAME + r"\.softsav_[0-9]{6}-[0-9]{6}$"), - (True, r"^" + DEVICE_NAME + r"\.softsav\.bu$")] - ) + "timestamped,regex", + [ + (False, r"^" + DEVICE_NAME + r"\.softsav_[0-9]{6}-[0-9]{6}$"), + (True, r"^" + DEVICE_NAME + r"\.softsav\.bu$"), + ], +) def test_backup_on_load(existing_autosave_dir, timestamped, regex): autosave.configure( - existing_autosave_dir, - DEVICE_NAME, - timestamped_backups=timestamped + existing_autosave_dir, DEVICE_NAME, timestamped_backups=timestamped ) # backup only performed if there are any pvs to save builder.aOut("SAVED-AO", autosave=True) @@ -199,6 +200,7 @@ def test_backup_on_load(existing_autosave_dir, timestamped, regex): assert "OUT-OF-DATE-KEY" not in state assert "SAVED-AO" in state + def test_autosave_key_names(tmp_path): builder.aOut("DEFAULTNAME", autosave=True) builder.aOut("DEFAULTNAMEAFTERPREFIXSET", autosave=True) @@ -211,6 +213,30 @@ def test_autosave_key_names(tmp_path): assert "DEFAULTNAMEAFTERPREFIXSET" in saved +def test_context_manager(tmp_path): + builder.aOut("MANUAL", autosave=True, autosave_fields=["EGU"]) + with autosave.Autosave(True, ["PINI"]): + builder.aOut("AUTOMATIC") + builder.aOut( + "AUTOMATIC-OVERRIDDEN", autosave=False, autosave_fields=["SCAN"] + ) + autosave.configure(tmp_path, DEVICE_NAME) + with patch( + "softioc.device.ProcessDeviceSupportCore.get_field", return_value="0" + ): + autosaver = autosave.Autosave() + autosaver._save() + with open(tmp_path / f"{DEVICE_NAME}.softsav", "r") as f: + saved = yaml.full_load(f) + assert "MANUAL" in saved + assert "MANUAL.EGU" in saved + assert "AUTOMATIC" in saved + assert "AUTOMATIC.PINI" in saved + assert "AUTOMATIC-OVERRIDDEN" in saved + assert "AUTOMATIC-OVERRIDDEN.SCAN" in saved + assert "AUTOMATIC-OVERRIDDEN.PINI" in saved + + def check_all_record_types_load_properly(device_name, autosave_dir, conn): autosave.configure(autosave_dir, device_name) pv_aOut = builder.aOut("SAVED-AO", autosave=True) @@ -365,7 +391,8 @@ def test_actual_ioc_save(tmp_path): def check_autosave_field_names_contain_device_prefix( - device_name, tmp_path, conn): + device_name, tmp_path, conn +): autosave.configure(tmp_path, device_name, save_period=1) builder.aOut("BEFORE", autosave=True, autosave_fields=["EGU"]) builder.SetDeviceName(device_name) @@ -379,6 +406,7 @@ def check_autosave_field_names_contain_device_prefix( assert f"{device_name}:AFTER" in saved.keys() conn.send("D") + def test_autosave_field_names_contain_device_prefix(tmp_path): ctx = get_multiprocessing_context() parent_conn, child_conn = ctx.Pipe() From 38d02f9606e861def4566c35e0819eb4870eba8f Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 22 Aug 2024 09:00:51 +0100 Subject: [PATCH 36/53] Make all Autosave methods class methods --- softioc/autosave.py | 59 ++++++++++++++++++++++-------------------- tests/test_autosave.py | 9 +++---- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index f584b53c..3e95b3a6 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -55,17 +55,16 @@ def configure( def start_autosave_thread(): - autosaver = Autosave() worker = threading.Thread( - target=autosaver.loop, + target=Autosave._loop, ) worker.daemon = True worker.start() - atexit.register(_shutdown_autosave_thread, autosaver, worker) + atexit.register(_shutdown_autosave_thread, worker) -def _shutdown_autosave_thread(autosaver, worker): - autosaver.stop() +def _shutdown_autosave_thread(worker): + Autosave._stop() worker.join() @@ -74,9 +73,8 @@ def add_pv_to_autosave(pv, name, save_val, save_fields): Args: pv: a PV object inheriting ProcessDeviceSupportCore - name: the name of the PV which is used to generate the key - by which the PV value is saved to and loaded from a backup, - this is typically the same as the PV name. + name: the key by which the PV value is saved to and loaded from a + backup, this is typically the same as the PV name. save_val: a boolean that tracks whether to save the VAL field in an autosave backup save_fields: a list of string names of fields associated with the pv @@ -181,9 +179,10 @@ def __get_tmp_sav_path(cls): def __get_current_sav_path(cls): return cls.directory / f"{cls.device_name}.{SAV_SUFFIX}" - def __get_state(self): + @classmethod + def __get_state(cls): state = {} - for pv_field, pv in self._pvs.items(): + for pv_field, pv in cls._pvs.items(): try: state[pv_field] = pv.get() except Exception: @@ -204,25 +203,27 @@ def __set_pvs_from_saved_state(cls): ) traceback.print_exc() - def __state_changed(self, state): - return self._last_saved_state.keys() != state.keys() or any( + @classmethod + def __state_changed(cls, state): + return cls._last_saved_state.keys() != state.keys() or any( # checks equality for builtins and numpy arrays - not numpy.array_equal(state[key], self._last_saved_state[key]) + not numpy.array_equal(state[key], cls._last_saved_state[key]) for key in state ) - def _save(self): - state = self.__get_state() - if self.__state_changed(state): - sav_path = self.__get_current_sav_path() - tmp_path = self.__get_tmp_sav_path() + @classmethod + def _save(cls): + state = cls.__get_state() + if cls.__state_changed(state): + sav_path = cls.__get_current_sav_path() + tmp_path = cls.__get_tmp_sav_path() # write to temporary file first then use atomic os.rename # to safely update stored state with open(tmp_path, "w") as backup: yaml.dump(state, backup, indent=4) rename(tmp_path, sav_path) - self._last_saved_state = state - self._last_saved_time = datetime.now() + cls._last_saved_state = state + cls._last_saved_time = datetime.now() @classmethod def _load(cls): @@ -254,18 +255,20 @@ def _load(cls): cls._last_saved_state = yaml.full_load(f) cls.__set_pvs_from_saved_state() - def stop(self): - self._stop_event.set() + @classmethod + def _stop(cls): + cls._stop_event.set() - def loop(self): - if not self.enabled or not self._pvs or self._loop_started: + @classmethod + def _loop(cls): + if not cls.enabled or not cls._pvs or cls._loop_started: return - self._loop_started = True + cls._loop_started = True while True: try: - self._stop_event.wait(timeout=self.save_period) - self._save() - if self._stop_event.is_set(): # Stop requested + cls._stop_event.wait(timeout=cls.save_period) + cls._save() + if cls._stop_event.is_set(): # Stop requested return except Exception: traceback.print_exc() diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 091c58f0..a6c6718b 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -161,17 +161,16 @@ def test_can_save_fields(tmp_path): def test_stop_event(tmp_path): autosave.configure(tmp_path, DEVICE_NAME) builder.aOut("DUMMYRECORD", autosave=True) - autosaver = autosave.Autosave() worker = threading.Thread( - target=autosaver.loop, + target=autosave.Autosave._loop, ) try: worker.daemon = True worker.start() - assert not autosaver._stop_event.is_set() + assert not autosave.Autosave._stop_event.is_set() assert worker.is_alive() - autosaver.stop() - assert autosaver._stop_event.is_set() + autosave.Autosave._stop() + assert autosave.Autosave._stop_event.is_set() finally: worker.join(timeout=1) From 3b174978670b787a5ac018839b44b09268b93412 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 22 Aug 2024 10:16:17 +0100 Subject: [PATCH 37/53] update docs for autosave api changes reset device name between tests --- docs/examples/example_autosave_ioc.py | 2 ++ docs/how-to/use-autosave-in-an-ioc.rst | 47 +++++++++++++++----------- docs/index.rst | 1 + docs/reference/api.rst | 38 +++++++++++++-------- tests/conftest.py | 8 ++++- tests/sim_records.py | 2 +- 6 files changed, 63 insertions(+), 35 deletions(-) diff --git a/docs/examples/example_autosave_ioc.py b/docs/examples/example_autosave_ioc.py index d778f2ee..6ea589c6 100644 --- a/docs/examples/example_autosave_ioc.py +++ b/docs/examples/example_autosave_ioc.py @@ -10,6 +10,8 @@ builder.aIn("AI", autosave_fields=["PREC", "EGU"]) builder.boolIn("BO") builder.WaveformIn("WAVEFORMOUT", [0, 0, 0, 0], autosave=True) +with autosave.Autosave(True, ["LOPR", "HOPR"]): + builder.aOut("AUTOMATIC-AO", autosave_fields=["EGU"]) minutes = builder.longOut("MINUTESRUN", autosave=True) autosave.configure( diff --git a/docs/how-to/use-autosave-in-an-ioc.rst b/docs/how-to/use-autosave-in-an-ioc.rst index 36473cb2..239870a7 100644 --- a/docs/how-to/use-autosave-in-an-ioc.rst +++ b/docs/how-to/use-autosave-in-an-ioc.rst @@ -1,5 +1,5 @@ Use `softioc.autosave` in an IOC -======================= +================================ `../tutorials/creating-an-ioc` shows how to create a pythonSoftIOC. @@ -16,23 +16,26 @@ VAL fields backed up. Additional record fields in a list passed to ``autosave_fi up, note that this applies even when ``autosave`` is ``False``. The field values get written into a yaml-formatted file containing key-value pairs, -by default the keys are the same as the record name passed as the first argument to the -`builder.()` call, excluding the device name specified in the `builder.SetDeviceName()` -call. +by default the keys are the same as the full PV name, including any device name specified +in :func:`~softioc.builder.SetDeviceName()`. -Autosave is disabled by default until `autosave.configure()` is called. The first two arguments, +Autosave is disabled by default until :func:`~softioc.autosave.configure()` is called. The first two arguments, ``directory`` and ``name`` are required. Backup files are periodically written into ``directory`` with the name ``.softsav`` every ``save_period`` seconds, set to 30.0 by default. The directory must exist, and should be configured with the appropriate read/write permissions for the user running the IOC. -IOC developers should only need to interface with autosave via the `autosave.configure()` -method and the ``autosave`` and ``autosave_fields`` keyword arguments, -all other module members are intended for internal use only. +IOC developers should only need to interface with autosave via the :func:`~softioc.autosave.configure()` +method and the ``autosave`` and ``autosave_fields`` keyword arguments. Alternatively, +PVs can be instantiated inside the :class:`~softioc.autosave.Autosave()` context manager, which +automatically passes the arguments ``autosave`` and ``autosave_fields`` to any PVs created +inside the context manager. If the PV already has ``autosave_fields`` set, the lists +of fields get combined. All other module members are intended for internal use only. In normal operation, loading from a backup is performed once during the -`builder.LoadDatabase()` call, periodic saving to the backup file begins when -`softioc.iocInit()` is called, provided that any PVs are configured to be saved. +:func:`~softioc.builder.LoadDatabase()` call, periodic saving to the backup file begins when +:func:`~softioc.softioc.iocInit()` is called, provided that any PVs are configured to be saved. +Currently, manual loading from a backup at runtime after ioc initialisation is not supported. Saving only occurs when any of the saved field values have changed since the last save. Users are discouraged from manually editing the backup files while the IOC is running so that the internal state of the autosave thread is consistent with @@ -40,20 +43,26 @@ the backup file. If autosave is enabled and active, a timestamped copy of the latest existing autosave backup file is created when the IOC is restarted, e.g. ``.softsav_240717-095004`` (timestamps are in the format yymmdd-HHMMSS). -If you only wish to store one backup of the autosave file at a time, ``timestamped_backups=False`` can be passed to `autosave.configure()`, -this will create a backup file named ``.softsav.bu``. -To disable any autosaving, comment out the `autosave.configure()` call or pass it the keyword argument +If you only wish to store one backup of the autosave file at a time, ``timestamped_backups=False`` +can be passed to :func:`~softioc.autosave.configure()`, this will create a backup file +named ``.softsav.bu``. To disable any autosaving, comment out the +:func:`~softioc.autosave.configure()` call or pass it the keyword argument ``enabled=False``. The resulting backup file after running the IOC for a minute is the following: .. code-block:: - AI.EGU: '' - AI.PREC: '0' - AO: 0.0 - MINUTESRUN: 1 - WAVEFORMOUT: [0, 0, 0, 0] + MY-DEVICE-PREFIX:AI.EGU: '' + MY-DEVICE-PREFIX:AI.PREC: '0' + MY-DEVICE-PREFIX:AO: 0.0 + MY-DEVICE-PREFIX:AUTOMATIC-AO: 0.0 + MY-DEVICE-PREFIX:AUTOMATIC-AO.EGU: '' + MY-DEVICE-PREFIX:AUTOMATIC-AO.HOPR: '0' + MY-DEVICE-PREFIX:AUTOMATIC-AO.LOPR: '0' + MY-DEVICE-PREFIX:MINUTESRUN: 1 + MY-DEVICE-PREFIX:WAVEFORMOUT: [0, 0, 0, 0] AI.EGU: '' + If the IOC is stopped and restarted, the MINUTESRUN record will load its saved value of 1 from the backup. @@ -61,4 +70,4 @@ All non-VAL fields are stored as strings. Waveform type records holding arrays are cast into lists before saving. This example IOC uses cothread, but autosave works identically when using -an asyncio dispatcher. \ No newline at end of file +an asyncio dispatcher. diff --git a/docs/index.rst b/docs/index.rst index 7d99fc9e..b15f5ab2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -59,6 +59,7 @@ Table Of Contents :maxdepth: 1 how-to/use-asyncio-in-an-ioc + how-to/use-autosave-in-an-ioc how-to/make-publishable-ioc how-to/read-data-from-ioc how-to/use-soft-records diff --git a/docs/reference/api.rst b/docs/reference/api.rst index 9a06d038..3ea131a0 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -147,10 +147,11 @@ and stderr streams, is sent directly to the terminal. Configuring saving and loading of record fields with `softioc.autosave.configure` --------------------------------------------------------------------------------- - .. function:: configure(directory, name, save_period=30, backup=True, enabled=True) - Used to set the location of backup files. - Backups are disabled by default unless this method is called. It must be - called prior to :func:`~softioc.builder.LoadDatabase`. + .. function:: configure(directory, name, save_period=30, timestamped_backups=True, enabled=True) + + Used to set the location of backup files. + Backups are disabled by default unless this method is called. It must be + called prior to :func:`~softioc.builder.LoadDatabase`. It has the following arguments: @@ -168,7 +169,7 @@ and stderr streams, is sent directly to the terminal. ~~~~~~ The file prefix used for naming the backup files. This is typically set to be the same as the device prefix. The resulting file name will be - `name`.softsav. This argument is required + `name`.softsav. This argument is required. .. _save_period: @@ -178,14 +179,14 @@ and stderr streams, is sent directly to the terminal. Backup files are only overwritten if any of the field values have changed since the last backup. - .. _backup: + .. _timestamped_backups: - `backup` - ~~~~~~~~ + `timestamped_backups` + ~~~~~~~~~~~~~~~~~~~~~ A boolean that is `True` by default, creates a backup of the latest existing autosave file that is timestamped at the time that the autosave thread is - started. - In normal operation, the current autosave file is overwritten every save. + started. If set to `False`, the backup is not timestamped and gets overwritten + every time the IOC restarts. .. _enabled: @@ -198,6 +199,16 @@ and stderr streams, is sent directly to the terminal. .. seealso:: `softioc.builder` for how to designate a field for autosave. + .. class:: Autosave + + .. method:: __init__(autosave=True, autosave_fields=None) + + To be called as a context manager. Any PVs that are created inside + the context manager have the arguments ``autosave`` and ``autosave_fields`` + passed to them automatically, where ``autosave_fields`` is an optional list of + field names. If the PV already has autosave_fields set, the lists of fields get + combined. + .. automodule:: softioc.builder @@ -318,7 +329,7 @@ and stderr streams, is sent directly to the terminal. .. seealso:: `SetBlocking` for configuring a global default blocking value - .. _autosave: + .. _autosave_arg: `autosave` ~~~~~~~~~~ @@ -339,9 +350,8 @@ and stderr streams, is sent directly to the terminal. `autosave_fields` ~~~~~~~~~~~~~~~~~ - A list of strings of record fields, (e.g. ["SCAN", "PREC"]) to be saved to - and loaded from a backup file, empty by default. - Works in an identical way to `autosave`. + A list of strings of record fields belonging to the PV (e.g. ["EGU", "PREC"]) + to be saved to and loaded from a backup file, empty by default. For all of these functions any EPICS database field can be assigned a value by diff --git a/tests/conftest.py b/tests/conftest.py index 301ccb01..66ab41ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ import pytest from softioc import builder -from softioc.builder import ClearRecords +from softioc.builder import ClearRecords, SetDeviceName, GetRecordNames in_records = [ builder.aIn, @@ -98,12 +98,18 @@ def asyncio_ioc_override(): ioc.kill() aioca_cleanup() +def reset_device_name(): + if GetRecordNames().prefix: + SetDeviceName("") + @pytest.fixture(autouse=True) def clear_records(): """Deletes all records before and after every test""" ClearRecords() + reset_device_name() yield ClearRecords() + reset_device_name() @pytest.fixture(autouse=True) def enable_code_coverage(): diff --git a/tests/sim_records.py b/tests/sim_records.py index b882d58e..7d9daec4 100644 --- a/tests/sim_records.py +++ b/tests/sim_records.py @@ -14,7 +14,6 @@ ioc_name = names.prefix[0] else: ioc_name = 'TS-DI-TEST-01' - SetDeviceName(ioc_name) def on_update(value): print('on_update', repr(value)) @@ -29,6 +28,7 @@ def on_update_name(value, name): def create_records(): global t_ai, t_ao + SetDeviceName(ioc_name) t_ai = aIn('AI', initial_value=12.34) boolIn('BOOLIN', 'True', 'False', initial_value=False) From 75a1e2de3213d1d5d024007a074984ce5765bab1 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 30 Aug 2024 08:33:39 +0100 Subject: [PATCH 38/53] implement changes from review comments --- CHANGELOG.rst | 2 +- docs/examples/example_autosave_ioc.py | 10 +++++----- docs/how-to/use-autosave-in-an-ioc.rst | 22 +++++++++++----------- docs/reference/api.rst | 11 +++++------ softioc/autosave.py | 19 ++++++++----------- softioc/device.py | 2 +- 6 files changed, 31 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bae0034a..c3da414a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,7 +11,7 @@ Unreleased_ ----------- Added: -- `Add autosave support to all records and record fields <../../pull/163>` +- `Add autosave support to all records and record fields <../../pull/163>`_ - `Add int64In/Out record support <../../pull/161>`_ - `Enable setting alarm status of Out records <../../pull/157>`_ - `Adding the non_interactive_ioc function <../../pull/156>`_ diff --git a/docs/examples/example_autosave_ioc.py b/docs/examples/example_autosave_ioc.py index 6ea589c6..5006b8c8 100644 --- a/docs/examples/example_autosave_ioc.py +++ b/docs/examples/example_autosave_ioc.py @@ -9,15 +9,15 @@ builder.aOut("AO", autosave=True) builder.aIn("AI", autosave_fields=["PREC", "EGU"]) builder.boolIn("BO") -builder.WaveformIn("WAVEFORMOUT", [0, 0, 0, 0], autosave=True) +builder.WaveformIn("WAVEFORMIN", [0, 0, 0, 0], autosave=True) with autosave.Autosave(True, ["LOPR", "HOPR"]): builder.aOut("AUTOMATIC-AO", autosave_fields=["EGU"]) -minutes = builder.longOut("MINUTESRUN", autosave=True) +seconds = builder.longOut("SECONDSRUN", autosave=True) autosave.configure( directory="/tmp/autosave-data", name="MY-DEVICE-PREFIX", - save_period=20.0 + save_period=5.0 ) builder.LoadDatabase() @@ -26,8 +26,8 @@ # Start processes required to be run after iocInit def update(): while True: - cothread.Sleep(60) - minutes.set(minutes.get() + 1) + cothread.Sleep(1) + seconds.set(seconds.get() + 1) cothread.Spawn(update) diff --git a/docs/how-to/use-autosave-in-an-ioc.rst b/docs/how-to/use-autosave-in-an-ioc.rst index 239870a7..6a69988b 100644 --- a/docs/how-to/use-autosave-in-an-ioc.rst +++ b/docs/how-to/use-autosave-in-an-ioc.rst @@ -1,4 +1,4 @@ -Use `softioc.autosave` in an IOC +Use `autosave` in an IOC ================================ `../tutorials/creating-an-ioc` shows how to create a pythonSoftIOC. @@ -13,13 +13,13 @@ Records are instantiated as normal and configured for automatic loading and periodic saving to a backup file with the keyword arguments ``autosave`` and ``autosave_fields``. Records with ``autosave=True`` (``False`` by default) have their VAL fields backed up. Additional record fields in a list passed to ``autosave_fields`` will be backed -up, note that this applies even when ``autosave`` is ``False``. +up - note that this applies even when ``autosave`` is ``False``. -The field values get written into a yaml-formatted file containing key-value pairs, -by default the keys are the same as the full PV name, including any device name specified +The field values get written into a yaml-formatted file containing key-value pairs. +By default the keys are the same as the full PV name, including any device name specified in :func:`~softioc.builder.SetDeviceName()`. -Autosave is disabled by default until :func:`~softioc.autosave.configure()` is called. The first two arguments, +Autosave is disabled until :func:`~softioc.autosave.configure()` is called. The first two arguments, ``directory`` and ``name`` are required. Backup files are periodically written into ``directory`` with the name ``.softsav`` every ``save_period`` seconds, set to 30.0 by default. The directory must exist, and should be configured with the appropriate @@ -33,7 +33,7 @@ inside the context manager. If the PV already has ``autosave_fields`` set, the l of fields get combined. All other module members are intended for internal use only. In normal operation, loading from a backup is performed once during the -:func:`~softioc.builder.LoadDatabase()` call, periodic saving to the backup file begins when +:func:`~softioc.builder.LoadDatabase()` call and periodic saving to the backup file begins when :func:`~softioc.softioc.iocInit()` is called, provided that any PVs are configured to be saved. Currently, manual loading from a backup at runtime after ioc initialisation is not supported. Saving only occurs when any of the saved field values have changed since the last save. @@ -49,7 +49,7 @@ named ``.softsav.bu``. To disable any autosaving, comment out the :func:`~softioc.autosave.configure()` call or pass it the keyword argument ``enabled=False``. -The resulting backup file after running the IOC for a minute is the following: +The resulting backup file after running the example IOC for about 30 seconds is the following: .. code-block:: @@ -60,12 +60,12 @@ The resulting backup file after running the IOC for a minute is the following: MY-DEVICE-PREFIX:AUTOMATIC-AO.EGU: '' MY-DEVICE-PREFIX:AUTOMATIC-AO.HOPR: '0' MY-DEVICE-PREFIX:AUTOMATIC-AO.LOPR: '0' - MY-DEVICE-PREFIX:MINUTESRUN: 1 - MY-DEVICE-PREFIX:WAVEFORMOUT: [0, 0, 0, 0] AI.EGU: '' + MY-DEVICE-PREFIX:SECONDSRUN: 29 + MY-DEVICE-PREFIX:WAVEFORMIN: [0, 0, 0, 0] -If the IOC is stopped and restarted, the MINUTESRUN record will load its saved -value of 1 from the backup. +If the IOC is stopped and restarted, the SECONDSRUN record will load its saved +value of 29 from the backup. All non-VAL fields are stored as strings. Waveform type records holding arrays are cast into lists before saving. diff --git a/docs/reference/api.rst b/docs/reference/api.rst index 3ea131a0..f24fac26 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -149,7 +149,7 @@ and stderr streams, is sent directly to the terminal. .. function:: configure(directory, name, save_period=30, timestamped_backups=True, enabled=True) - Used to set the location of backup files. + Used to configure settings for Autosave. Backups are disabled by default unless this method is called. It must be called prior to :func:`~softioc.builder.LoadDatabase`. @@ -169,7 +169,7 @@ and stderr streams, is sent directly to the terminal. ~~~~~~ The file prefix used for naming the backup files. This is typically set to be the same as the device prefix. The resulting file name will be - `name`.softsav. This argument is required. + ``name``.softsav. This argument is required. .. _save_period: @@ -193,8 +193,7 @@ and stderr streams, is sent directly to the terminal. `enabled` ~~~~~~~~~ A boolean that is `True` by default, if `False` then no loading will occur - at IOC startup, and the save thread will return without performing any - backups. + at IOC startup, and no values with be saved to any backup files. .. seealso:: `softioc.builder` for how to designate a field for autosave. @@ -334,7 +333,7 @@ and stderr streams, is sent directly to the terminal. `autosave` ~~~~~~~~~~ - Available on all record types, when set to `True` it marks the record + Available on all record types. When set to `True` it marks the record value for automatic periodic backing up to a file. Set to `False` by default. When the IOC is restarted and a backup file exists, the value is loaded from this file when :func:`~softioc.builder.LoadDatabase` is called. @@ -351,7 +350,7 @@ and stderr streams, is sent directly to the terminal. ~~~~~~~~~~~~~~~~~ A list of strings of record fields belonging to the PV (e.g. ["EGU", "PREC"]) - to be saved to and loaded from a backup file, empty by default. + to be saved to and loaded from a backup file. Empty by default. For all of these functions any EPICS database field can be assigned a value by diff --git a/softioc/autosave.py b/softioc/autosave.py index 3e95b3a6..02fca417 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -37,7 +37,7 @@ def configure( Args: directory: string or Path giving directory path where autosave backup files are saved and loaded. - name: string name of the root used for naming backup files, this + name: string name of the root used for naming backup files. This is usually the same as the device name. save_period: time in seconds between backups. Backups are only performed if PV values have changed. @@ -58,7 +58,6 @@ def start_autosave_thread(): worker = threading.Thread( target=Autosave._loop, ) - worker.daemon = True worker.start() atexit.register(_shutdown_autosave_thread, worker) @@ -74,7 +73,7 @@ def add_pv_to_autosave(pv, name, save_val, save_fields): Args: pv: a PV object inheriting ProcessDeviceSupportCore name: the key by which the PV value is saved to and loaded from a - backup, this is typically the same as the PV name. + backup. This is typically the same as the PV name. save_val: a boolean that tracks whether to save the VAL field in an autosave backup save_fields: a list of string names of fields associated with the pv @@ -82,8 +81,9 @@ def add_pv_to_autosave(pv, name, save_val, save_fields): """ if save_val: Autosave._pvs[name] = _AutosavePV(pv) - for field in save_fields: - Autosave._pvs[f"{name}.{field}"] = _AutosavePV(pv, field) + if save_fields: + for field in save_fields: + Autosave._pvs[f"{name}.{field}"] = _AutosavePV(pv, field) def load_autosave(): @@ -126,12 +126,9 @@ def __enter__(self): def __exit__(self, A, B, C): if self._save_val or self._save_fields: for key, pv in LookupRecordList(): - try: - if key not in self._records_before_cm: - add_pv_to_autosave( - pv, key, self._save_val, self._save_fields) - except Exception: - traceback.print_exc() + if key not in self._records_before_cm: + add_pv_to_autosave( + pv, key, self._save_val, self._save_fields) self._save_val = False self._save_fields = [] self._records_before_cm = None diff --git a/softioc/device.py b/softioc/device.py index 2c3d0d33..9eb372cb 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -56,7 +56,7 @@ class ProcessDeviceSupportCore(DeviceSupportCore, RecordLookup): # all record types can support autosave def __init__(self, name, **kargs): autosave_enabled = kargs.pop("autosave", False) - autosave_fields = kargs.pop("autosave_fields", []) + autosave_fields = kargs.pop("autosave_fields", None) if autosave_enabled or autosave_fields: autosave.add_pv_to_autosave( self, name, autosave_enabled, autosave_fields) From afb79479d02221624e9b3749caaca6f7d1a32bea Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 2 Sep 2024 09:13:50 +0100 Subject: [PATCH 39/53] Check if autosave directory exists on configure call stop autosave IOC test from running forever --- softioc/autosave.py | 9 +++++++-- tests/test_autosave.py | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 02fca417..4f02e8f5 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -47,7 +47,12 @@ def configure( enabled: boolean which enables or disables autosave, set to True by default, or False if configure not called. """ - Autosave.directory = Path(directory) + directory_path = Path(directory) + if not directory_path.is_dir(): + raise FileNotFoundError( + f"{directory} is not a valid autosave directory" + ) + Autosave.directory = directory_path Autosave.timestamped_backups = timestamped_backups Autosave.save_period = save_period Autosave.enabled = enabled @@ -136,7 +141,7 @@ def __exit__(self, A, B, C): @classmethod def __backup_sav_file(cls): - if not cls.directory and cls.directory.is_dir(): + if not cls.directory or not cls.directory.is_dir(): print( f"Could not back up autosave as {cls.directory} is" " not a valid directory", diff --git a/tests/test_autosave.py b/tests/test_autosave.py index a6c6718b..7d5e24b3 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -84,9 +84,8 @@ def test_autosave_defaults(): def test_configure_dir_doesnt_exist(tmp_path): DEVICE_NAME = "MY_DEVICE" builder.aOut("MY-RECORD", autosave=True) - autosave.configure(tmp_path / "subdir-doesnt-exist", DEVICE_NAME) with pytest.raises(FileNotFoundError): - autosave.load_autosave() + autosave.configure(tmp_path / "subdir-doesnt-exist", DEVICE_NAME) def test_returns_if_init_called_before_configure(): @@ -374,6 +373,8 @@ def check_all_record_types_save_properly(device_name, autosave_dir, conn): assert saved["Action"] == 1 assert (saved["WaveformIn"] == numpy.array([1, 2, 3, 4])).all() assert (saved["WaveformOut"] == numpy.array([1, 2, 3, 4])).all() + autosave.Autosave._stop() + # force autosave thread to stop to ensure pytest exits conn.send("D") @@ -403,6 +404,7 @@ def check_autosave_field_names_contain_device_prefix( saved = yaml.full_load(f) assert "BEFORE" in saved.keys() assert f"{device_name}:AFTER" in saved.keys() + autosave.Autosave._stop() conn.send("D") From 773c0a29608814cd273681e68d1b2a58ea844a79 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 2 Sep 2024 10:59:17 +0100 Subject: [PATCH 40/53] Simplify context manager logic when adding pv to autosave --- softioc/autosave.py | 36 +++++++++++++++--------------------- softioc/device.py | 6 +----- tests/test_autosave.py | 4 ++++ 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 4f02e8f5..1ce23453 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -10,8 +10,6 @@ import numpy import yaml -from .device_core import LookupRecordList - SAV_SUFFIX = "softsav" SAVB_SUFFIX = "softsavB" DEFAULT_SAVE_PERIOD = 30.0 @@ -72,18 +70,19 @@ def _shutdown_autosave_thread(worker): worker.join() -def add_pv_to_autosave(pv, name, save_val, save_fields): +def add_pv_to_autosave(pv, name, kargs): """Configures a PV for autosave Args: pv: a PV object inheriting ProcessDeviceSupportCore name: the key by which the PV value is saved to and loaded from a backup. This is typically the same as the PV name. - save_val: a boolean that tracks whether to save the VAL field - in an autosave backup - save_fields: a list of string names of fields associated with the pv - to be saved to and loaded from a backup + kargs: a dictionary containing the optional keys "autosave", a boolean + used to add the VAL field to autosave backups, and "autosave_fields", + a list of string field names to save to the backup file. """ + save_val = kargs.pop("autosave", False) or Autosave._cm_save_val + save_fields = kargs.pop("autosave_fields", []) + Autosave._cm_save_fields if save_val: Autosave._pvs[name] = _AutosavePV(pv) if save_fields: @@ -116,27 +115,22 @@ class Autosave: enabled = False timestamped_backups = True _loop_started = False + _cm_save_val = False + _cm_save_fields = [] + _in_cm = False def __init__(self, autosave=True, autosave_fields=None): # for use as a context manager - self._save_val = autosave - self._save_fields = autosave_fields or [] + Autosave._cm_save_val = autosave + Autosave._cm_save_fields = autosave_fields or [] def __enter__(self): - if self._save_val or self._save_fields: - self._records_before_cm = dict(LookupRecordList()) - else: - self._records_before_cm = {} + Autosave._in_cm = True def __exit__(self, A, B, C): - if self._save_val or self._save_fields: - for key, pv in LookupRecordList(): - if key not in self._records_before_cm: - add_pv_to_autosave( - pv, key, self._save_val, self._save_fields) - self._save_val = False - self._save_fields = [] - self._records_before_cm = None + Autosave._in_cm = False + Autosave._cm_save_val = False + Autosave._cm_save_fields = [] @classmethod diff --git a/softioc/device.py b/softioc/device.py index 9eb372cb..2ebab071 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -55,11 +55,7 @@ class ProcessDeviceSupportCore(DeviceSupportCore, RecordLookup): # all record types can support autosave def __init__(self, name, **kargs): - autosave_enabled = kargs.pop("autosave", False) - autosave_fields = kargs.pop("autosave_fields", None) - if autosave_enabled or autosave_fields: - autosave.add_pv_to_autosave( - self, name, autosave_enabled, autosave_fields) + autosave.add_pv_to_autosave(self, name, kargs) self.__super.__init__(name, **kargs) # Most subclasses (all except waveforms) define a ctypes constructor for the diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 7d5e24b3..85a800af 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -20,6 +20,8 @@ def reset_autosave_setup_teardown(): default_directory = autosave.Autosave.directory default_enabled = autosave.Autosave.enabled default_tb = autosave.Autosave.timestamped_backups + default_cm_save_val = autosave.Autosave._cm_save_val + default_cm_save_fields = autosave.Autosave._cm_save_fields yield autosave.Autosave._pvs = default_pvs autosave.Autosave._last_saved_state = default_state @@ -29,6 +31,8 @@ def reset_autosave_setup_teardown(): autosave.Autosave.directory = default_directory autosave.Autosave.enabled = default_enabled autosave.Autosave.timestamped_backups = default_tb + autosave.Autosave._cm_save_val = default_cm_save_val + autosave.Autosave._cm_save_fields = default_cm_save_fields @pytest.fixture From f4db4f212fd7fc3a6616d08be9989b7c8773ac37 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 4 Sep 2024 08:19:32 +0100 Subject: [PATCH 41/53] Move some configuration attributes and methods out of Autosave class --- softioc/autosave.py | 107 +++++++++++++++++++++++------------------ tests/test_autosave.py | 41 ++++++++-------- 2 files changed, 80 insertions(+), 68 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 1ce23453..a03db6c4 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -20,14 +20,16 @@ def _ndarray_representer(dumper, array): "tag:yaml.org,2002:seq", array.tolist(), flow_style=True ) + yaml.add_representer(numpy.ndarray, _ndarray_representer, Dumper=yaml.Dumper) + def configure( directory, name, save_period=DEFAULT_SAVE_PERIOD, timestamped_backups=True, - enabled=True + enabled=True, ): """This should be called before initialising the IOC. Configures the autosave thread for periodic backing up of PV values. @@ -50,11 +52,19 @@ def configure( raise FileNotFoundError( f"{directory} is not a valid autosave directory" ) - Autosave.directory = directory_path - Autosave.timestamped_backups = timestamped_backups - Autosave.save_period = save_period - Autosave.enabled = enabled - Autosave.device_name = name + AutosaveConfig.directory = directory_path + AutosaveConfig.timestamped_backups = timestamped_backups + AutosaveConfig.save_period = save_period + AutosaveConfig.enabled = enabled + AutosaveConfig.device_name = name + + +class AutosaveConfig: + directory = None + device_name = None + timestamped_backups = True + save_period = DEFAULT_SAVE_PERIOD + enabled = False def start_autosave_thread(): @@ -104,16 +114,35 @@ def __init__(self, pv, field=None): self.set = lambda val: setattr(pv, field, val) +def _get_current_sav_path(): + return ( + AutosaveConfig.directory / f"{AutosaveConfig.device_name}.{SAV_SUFFIX}" + ) + + +def _get_tmp_sav_path(): + return ( + AutosaveConfig.directory / f"{AutosaveConfig.device_name}.{SAVB_SUFFIX}" + ) + + +def _get_timestamped_backup_sav_path(timestamp): + sav_path = _get_current_sav_path() + return sav_path.parent / ( + sav_path.name + timestamp.strftime("_%y%m%d-%H%M%S") + ) + + +def _get_backup_sav_path(): + sav_path = _get_current_sav_path() + return sav_path.parent / (sav_path.name + ".bu") + + class Autosave: _pvs = {} _last_saved_state = {} _last_saved_time = datetime.now() _stop_event = threading.Event() - save_period = DEFAULT_SAVE_PERIOD - device_name = None - directory = None - enabled = False - timestamped_backups = True _loop_started = False _cm_save_val = False _cm_save_fields = [] @@ -132,21 +161,23 @@ def __exit__(self, A, B, C): Autosave._cm_save_val = False Autosave._cm_save_fields = [] - @classmethod def __backup_sav_file(cls): - if not cls.directory or not cls.directory.is_dir(): + if ( + not AutosaveConfig.directory + or not AutosaveConfig.directory.is_dir() + ): print( - f"Could not back up autosave as {cls.directory} is" + f"Could not back up autosave as {AutosaveConfig.directory} is" " not a valid directory", file=sys.stderr, ) return - sav_path = cls.__get_current_sav_path() - if cls.timestamped_backups: - backup_path = cls.__get_timestamped_backup_sav_path() + sav_path = _get_current_sav_path() + if AutosaveConfig.timestamped_backups: + backup_path = _get_timestamped_backup_sav_path(cls._last_saved_time) else: - backup_path = cls.__get_backup_sav_path() + backup_path = _get_backup_sav_path() if sav_path.is_file(): copy2(sav_path, backup_path) else: @@ -155,26 +186,6 @@ def __backup_sav_file(cls): file=sys.stderr, ) - @classmethod - def __get_timestamped_backup_sav_path(cls): - sav_path = cls.__get_current_sav_path() - return sav_path.parent / ( - sav_path.name + cls._last_saved_time.strftime("_%y%m%d-%H%M%S") - ) - - @classmethod - def __get_backup_sav_path(cls): - sav_path = cls.__get_current_sav_path() - return sav_path.parent / (sav_path.name + ".bu") - - @classmethod - def __get_tmp_sav_path(cls): - return cls.directory / f"{cls.device_name}.{SAVB_SUFFIX}" - - @classmethod - def __get_current_sav_path(cls): - return cls.directory / f"{cls.device_name}.{SAV_SUFFIX}" - @classmethod def __get_state(cls): state = {} @@ -211,8 +222,8 @@ def __state_changed(cls, state): def _save(cls): state = cls.__get_state() if cls.__state_changed(state): - sav_path = cls.__get_current_sav_path() - tmp_path = cls.__get_tmp_sav_path() + sav_path = _get_current_sav_path() + tmp_path = _get_tmp_sav_path() # write to temporary file first then use atomic os.rename # to safely update stored state with open(tmp_path, "w") as backup: @@ -223,24 +234,24 @@ def _save(cls): @classmethod def _load(cls): - if not cls.enabled or not cls._pvs: + if not AutosaveConfig.enabled or not cls._pvs: return - if not cls.device_name: + if not AutosaveConfig.device_name: raise RuntimeError( "Device name is not known to autosave thread, " "call autosave.configure() with keyword argument name" ) - if not cls.directory: + if not AutosaveConfig.directory: raise RuntimeError( "Autosave directory is not known, call " "autosave.configure() with keyword argument directory" ) - if not cls.directory.is_dir(): + if not AutosaveConfig.directory.is_dir(): raise FileNotFoundError( - f"{cls.directory} is not a valid autosave directory" + f"{AutosaveConfig.directory} is not a valid autosave directory" ) cls.__backup_sav_file() - sav_path = cls.__get_current_sav_path() + sav_path = _get_current_sav_path() if not sav_path or not sav_path.is_file(): print( f"Could not load autosave values from file {sav_path}", @@ -257,12 +268,12 @@ def _stop(cls): @classmethod def _loop(cls): - if not cls.enabled or not cls._pvs or cls._loop_started: + if not AutosaveConfig.enabled or not cls._pvs or cls._loop_started: return cls._loop_started = True while True: try: - cls._stop_event.wait(timeout=cls.save_period) + cls._stop_event.wait(timeout=AutosaveConfig.save_period) cls._save() if cls._stop_event.is_set(): # Stop requested return diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 85a800af..021beb38 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -13,24 +13,24 @@ @pytest.fixture(autouse=True) def reset_autosave_setup_teardown(): + default_save_period = autosave.AutosaveConfig.save_period + default_device_name = autosave.AutosaveConfig.device_name + default_directory = autosave.AutosaveConfig.directory + default_enabled = autosave.AutosaveConfig.enabled + default_tb = autosave.AutosaveConfig.timestamped_backups default_pvs = autosave.Autosave._pvs.copy() default_state = autosave.Autosave._last_saved_state.copy() - default_save_period = autosave.Autosave.save_period - default_device_name = autosave.Autosave.device_name - default_directory = autosave.Autosave.directory - default_enabled = autosave.Autosave.enabled - default_tb = autosave.Autosave.timestamped_backups default_cm_save_val = autosave.Autosave._cm_save_val default_cm_save_fields = autosave.Autosave._cm_save_fields yield + autosave.AutosaveConfig.save_period = default_save_period + autosave.AutosaveConfig.device_name = default_device_name + autosave.AutosaveConfig.directory = default_directory + autosave.AutosaveConfig.enabled = default_enabled + autosave.AutosaveConfig.timestamped_backups = default_tb autosave.Autosave._pvs = default_pvs autosave.Autosave._last_saved_state = default_state autosave.Autosave._stop_event = threading.Event() - autosave.Autosave.save_period = default_save_period - autosave.Autosave.device_name = default_device_name - autosave.Autosave.directory = default_directory - autosave.Autosave.enabled = default_enabled - autosave.Autosave.timestamped_backups = default_tb autosave.Autosave._cm_save_val = default_cm_save_val autosave.Autosave._cm_save_fields = default_cm_save_fields @@ -66,11 +66,12 @@ def existing_autosave_dir(tmp_path): def test_configure(tmp_path): - assert autosave.Autosave.enabled is False + assert autosave.AutosaveConfig.enabled is False autosave.configure(tmp_path, DEVICE_NAME) - assert autosave.Autosave.device_name == DEVICE_NAME - assert autosave.Autosave.directory == tmp_path - assert autosave.Autosave.enabled is True + assert autosave.AutosaveConfig.device_name == DEVICE_NAME + assert autosave.AutosaveConfig.directory == tmp_path + assert autosave.AutosaveConfig.enabled is True + assert autosave.AutosaveConfig.timestamped_backups is True def test_autosave_defaults(): @@ -78,11 +79,11 @@ def test_autosave_defaults(): assert autosave.Autosave._last_saved_state == {} assert isinstance(autosave.Autosave._stop_event, threading.Event) assert not autosave.Autosave._stop_event.is_set() - assert autosave.Autosave.save_period == 30.0 - assert autosave.Autosave.device_name is None - assert autosave.Autosave.directory is None - assert autosave.Autosave.enabled is False - assert autosave.Autosave.timestamped_backups is True + assert autosave.AutosaveConfig.save_period == 30.0 + assert autosave.AutosaveConfig.device_name is None + assert autosave.AutosaveConfig.directory is None + assert autosave.AutosaveConfig.enabled is False + assert autosave.AutosaveConfig.timestamped_backups is True def test_configure_dir_doesnt_exist(tmp_path): @@ -94,7 +95,7 @@ def test_configure_dir_doesnt_exist(tmp_path): def test_returns_if_init_called_before_configure(): autosave.Autosave() - assert autosave.Autosave.enabled is False + assert autosave.AutosaveConfig.enabled is False def test_all_record_types_saveable(tmp_path): From c99dfb6dd9f33dfced70689c5e79f21f72d99ff4 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 4 Sep 2024 09:54:53 +0100 Subject: [PATCH 42/53] Convert Autosave into threading.local singleton to make context manager thread safe --- softioc/autosave.py | 56 ++++++++++++++++++++++++++++++++---------- tests/test_autosave.py | 25 ++++++++++++++++++- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index a03db6c4..65ce950e 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -88,11 +88,27 @@ def add_pv_to_autosave(pv, name, kargs): name: the key by which the PV value is saved to and loaded from a backup. This is typically the same as the PV name. kargs: a dictionary containing the optional keys "autosave", a boolean - used to add the VAL field to autosave backups, and "autosave_fields", - a list of string field names to save to the backup file. + used to add the VAL field to autosave backups, and + "autosave_fields", a list of string field names to save to the + backup file. """ - save_val = kargs.pop("autosave", False) or Autosave._cm_save_val - save_fields = kargs.pop("autosave_fields", []) + Autosave._cm_save_fields + + autosaver = Autosave() + # instantiate to get thread local class variables via instance + if autosaver._in_cm: + # non-None autosave argument to PV takes priority over context manager + autosave_karg = kargs.pop("autosave", None) + save_val = ( + autosave_karg + if autosave_karg is not None + else autosaver._cm_save_val + ) + save_fields = ( + kargs.pop("autosave_fields", []) + autosaver._cm_save_fields + ) + else: + save_val = kargs.pop("autosave", False) + save_fields = kargs.pop("autosave_fields", []) if save_val: Autosave._pvs[name] = _AutosavePV(pv) if save_fields: @@ -138,7 +154,9 @@ def _get_backup_sav_path(): return sav_path.parent / (sav_path.name + ".bu") -class Autosave: +class Autosave(threading.local): + _instance = None + _singleton_lock = threading.Lock() _pvs = {} _last_saved_state = {} _last_saved_time = datetime.now() @@ -148,18 +166,30 @@ class Autosave: _cm_save_fields = [] _in_cm = False - def __init__(self, autosave=True, autosave_fields=None): - # for use as a context manager - Autosave._cm_save_val = autosave - Autosave._cm_save_fields = autosave_fields or [] + def __new__(cls, autosave=None, autosave_fields=None): + # Make Autosave a Singleton class so that we have thread local + # class variables when accessed via instance + if cls._instance is None: + with cls._singleton_lock: + # Another thread could have created the instance + # before we acquired the lock. So check that the + # instance is still nonexistent. + if not cls._instance: + cls._instance = super().__new__(cls) + if autosave is not None: + cls._instance._cm_save_val = autosave + if autosave_fields is not None: + cls._instance._cm_save_fields = autosave_fields or [] + return cls._instance def __enter__(self): - Autosave._in_cm = True + self._in_cm = True def __exit__(self, A, B, C): - Autosave._in_cm = False - Autosave._cm_save_val = False - Autosave._cm_save_fields = [] + self._in_cm = False + self._cm_save_val = False + self._cm_save_fields = [] + self._instance = None @classmethod def __backup_sav_file(cls): diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 021beb38..50840834 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -22,6 +22,7 @@ def reset_autosave_setup_teardown(): default_state = autosave.Autosave._last_saved_state.copy() default_cm_save_val = autosave.Autosave._cm_save_val default_cm_save_fields = autosave.Autosave._cm_save_fields + default_instance = autosave.Autosave._instance yield autosave.AutosaveConfig.save_period = default_save_period autosave.AutosaveConfig.device_name = default_device_name @@ -33,6 +34,7 @@ def reset_autosave_setup_teardown(): autosave.Autosave._stop_event = threading.Event() autosave.Autosave._cm_save_val = default_cm_save_val autosave.Autosave._cm_save_fields = default_cm_save_fields + autosave.Autosave._instance = default_instance @pytest.fixture @@ -235,7 +237,7 @@ def test_context_manager(tmp_path): assert "MANUAL.EGU" in saved assert "AUTOMATIC" in saved assert "AUTOMATIC.PINI" in saved - assert "AUTOMATIC-OVERRIDDEN" in saved + assert "AUTOMATIC-OVERRIDDEN" not in saved assert "AUTOMATIC-OVERRIDDEN.SCAN" in saved assert "AUTOMATIC-OVERRIDDEN.PINI" in saved @@ -423,3 +425,24 @@ def test_autosave_field_names_contain_device_prefix(tmp_path): ioc_process.start() # If we never receive D it probably means an assert failed select_and_recv(parent_conn, "D") + +def test_context_manager_thread_safety(tmp_path): + autosave.configure(tmp_path, DEVICE_NAME) + + def create_pv_in_thread(name, wait): + time.sleep(wait) + builder.aOut(name, autosave=False) + + pv_thread_before_cm = threading.Thread( + target=create_pv_in_thread, args=["PV-FROM-THREAD-BEFORE", 1]) + pv_thread_in_cm = threading.Thread( + target=create_pv_in_thread, args=["PV-FROM-THREAD-DURING", 0]) + pv_thread_before_cm.start() + with autosave.Autosave(True, ["EGU"]): + builder.aOut("PV-FROM-CM") + pv_thread_in_cm.start() + pv_thread_in_cm.join() + pv_thread_before_cm.join() + + assert "PV-FROM-THREAD-BEFORE" not in autosave.Autosave._pvs + assert "PV-FROM-THREAD-DURING" not in autosave.Autosave._pvs From 847b82528aa3e19da9332a1ce57813c7d47a8c84 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 4 Sep 2024 11:04:23 +0100 Subject: [PATCH 43/53] Move singleton threading logic to _AutosaveContext class --- softioc/autosave.py | 66 ++++++++++++++++++++++++------------------ tests/test_autosave.py | 27 ++++++++++++----- 2 files changed, 57 insertions(+), 36 deletions(-) diff --git a/softioc/autosave.py b/softioc/autosave.py index 65ce950e..38647663 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -93,18 +93,18 @@ def add_pv_to_autosave(pv, name, kargs): backup file. """ - autosaver = Autosave() + context = _AutosaveContext() # instantiate to get thread local class variables via instance - if autosaver._in_cm: + if context._in_cm: # non-None autosave argument to PV takes priority over context manager autosave_karg = kargs.pop("autosave", None) save_val = ( autosave_karg if autosave_karg is not None - else autosaver._cm_save_val + else context._val ) save_fields = ( - kargs.pop("autosave_fields", []) + autosaver._cm_save_fields + kargs.pop("autosave_fields", []) + context._fields ) else: save_val = kargs.pop("autosave", False) @@ -153,43 +153,53 @@ def _get_backup_sav_path(): sav_path = _get_current_sav_path() return sav_path.parent / (sav_path.name + ".bu") - -class Autosave(threading.local): +class _AutosaveContext(threading.local): _instance = None - _singleton_lock = threading.Lock() + _lock = threading.Lock() + _val = None + _fields = None + _in_cm = False + def __new__(cls, val=None, fields=None): + if cls._instance is None: + with cls._lock: + if not cls._instance: + cls._instance = super().__new__(cls) + if cls._instance._in_cm: + if val is not None: + cls._instance._val = val + if fields is not None: + cls._instance._fields = fields or [] + return cls._instance + + def reset(self): + self._fields = None + self._val = None + self._in_cm = False + +class Autosave: _pvs = {} _last_saved_state = {} _last_saved_time = datetime.now() _stop_event = threading.Event() _loop_started = False - _cm_save_val = False - _cm_save_fields = [] - _in_cm = False - def __new__(cls, autosave=None, autosave_fields=None): - # Make Autosave a Singleton class so that we have thread local - # class variables when accessed via instance - if cls._instance is None: - with cls._singleton_lock: - # Another thread could have created the instance - # before we acquired the lock. So check that the - # instance is still nonexistent. - if not cls._instance: - cls._instance = super().__new__(cls) + def __init__(self, autosave=None, autosave_fields=None): + context = _AutosaveContext() + if context._in_cm: + raise RuntimeError( + "Can not instantiate Autosave when already in context manager") if autosave is not None: - cls._instance._cm_save_val = autosave + context._val = autosave if autosave_fields is not None: - cls._instance._cm_save_fields = autosave_fields or [] - return cls._instance + context._fields = autosave_fields def __enter__(self): - self._in_cm = True + context = _AutosaveContext() + context._in_cm = True def __exit__(self, A, B, C): - self._in_cm = False - self._cm_save_val = False - self._cm_save_fields = [] - self._instance = None + context = _AutosaveContext() + context.reset() @classmethod def __backup_sav_file(cls): diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 50840834..364fb9b1 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -1,5 +1,5 @@ from conftest import get_multiprocessing_context, select_and_recv -from softioc import autosave, builder, softioc +from softioc import autosave, builder, softioc, device_core from unittest.mock import patch import pytest import threading @@ -20,9 +20,9 @@ def reset_autosave_setup_teardown(): default_tb = autosave.AutosaveConfig.timestamped_backups default_pvs = autosave.Autosave._pvs.copy() default_state = autosave.Autosave._last_saved_state.copy() - default_cm_save_val = autosave.Autosave._cm_save_val - default_cm_save_fields = autosave.Autosave._cm_save_fields - default_instance = autosave.Autosave._instance + default_cm_save_val = autosave._AutosaveContext._val + default_cm_save_fields = autosave._AutosaveContext._fields + default_instance = autosave._AutosaveContext._instance yield autosave.AutosaveConfig.save_period = default_save_period autosave.AutosaveConfig.device_name = default_device_name @@ -32,9 +32,9 @@ def reset_autosave_setup_teardown(): autosave.Autosave._pvs = default_pvs autosave.Autosave._last_saved_state = default_state autosave.Autosave._stop_event = threading.Event() - autosave.Autosave._cm_save_val = default_cm_save_val - autosave.Autosave._cm_save_fields = default_cm_save_fields - autosave.Autosave._instance = default_instance + autosave._AutosaveContext._val = default_cm_save_val + autosave._AutosaveContext._fields = default_cm_save_fields + autosave._AutosaveContext._instance = default_instance @pytest.fixture @@ -432,7 +432,7 @@ def test_context_manager_thread_safety(tmp_path): def create_pv_in_thread(name, wait): time.sleep(wait) builder.aOut(name, autosave=False) - + pv_thread_before_cm = threading.Thread( target=create_pv_in_thread, args=["PV-FROM-THREAD-BEFORE", 1]) pv_thread_in_cm = threading.Thread( @@ -446,3 +446,14 @@ def create_pv_in_thread(name, wait): assert "PV-FROM-THREAD-BEFORE" not in autosave.Autosave._pvs assert "PV-FROM-THREAD-DURING" not in autosave.Autosave._pvs + assert device_core.LookupRecord("PV-FROM-THREAD-BEFORE") + assert device_core.LookupRecord("PV-FROM-THREAD-DURING") + +def test_nested_context_managers_raises(tmp_path): + autosave.configure(tmp_path, DEVICE_NAME) + with autosave.Autosave(False, ["SCAN"]): + with pytest.raises(RuntimeError): + with autosave.Autosave(True, []): + builder.aOut("MY-PV") + with pytest.raises(RuntimeError): + autosave.Autosave() From e4b950de0fca4bf90e1e5d9e34b4e7ad1a9190f4 Mon Sep 17 00:00:00 2001 From: James Souter Date: Thu, 5 Sep 2024 09:22:37 +0100 Subject: [PATCH 44/53] Fix docs rendering and add seealso links to PV kwargs to autosave API docs --- docs/how-to/use-autosave-in-an-ioc.rst | 2 +- docs/reference/api.rst | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/how-to/use-autosave-in-an-ioc.rst b/docs/how-to/use-autosave-in-an-ioc.rst index 6a69988b..4ce16345 100644 --- a/docs/how-to/use-autosave-in-an-ioc.rst +++ b/docs/how-to/use-autosave-in-an-ioc.rst @@ -1,4 +1,4 @@ -Use `autosave` in an IOC +Use `softioc.autosave` in an IOC ================================ `../tutorials/creating-an-ioc` shows how to create a pythonSoftIOC. diff --git a/docs/reference/api.rst b/docs/reference/api.rst index f24fac26..b624eb06 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -198,6 +198,10 @@ and stderr streams, is sent directly to the terminal. .. seealso:: `softioc.builder` for how to designate a field for autosave. + :ref:`autosave`, the keyword argument used to initialise PVs with the VAL field tracked by autosave. + + `autosave_fields`, the keyword argument used to initialise PVs with designated fields tracked by autosave. + .. class:: Autosave .. method:: __init__(autosave=True, autosave_fields=None) @@ -328,10 +332,10 @@ and stderr streams, is sent directly to the terminal. .. seealso:: `SetBlocking` for configuring a global default blocking value - .. _autosave_arg: + .. _autosave: - `autosave` - ~~~~~~~~~~ + :ref:`autosave` + ~~~~~~~~~~~~~~~ Available on all record types. When set to `True` it marks the record value for automatic periodic backing up to a file. Set to `False` by From f4d3ec7fdccb54dbc7f19b05876bb41f9d240e41 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 9 Sep 2024 08:21:55 +0100 Subject: [PATCH 45/53] Use single autosave kwarg instead of autosave and autosave_fields --- docs/examples/example_autosave_ioc.py | 6 +- docs/how-to/use-autosave-in-an-ioc.rst | 19 +++--- docs/reference/api.rst | 42 ++++++------ softioc/autosave.py | 93 ++++++++++++++------------ softioc/device.py | 3 +- softioc/pythonSoftIoc.py | 2 +- tests/test_autosave.py | 28 ++++---- 7 files changed, 103 insertions(+), 90 deletions(-) diff --git a/docs/examples/example_autosave_ioc.py b/docs/examples/example_autosave_ioc.py index 5006b8c8..dab84165 100644 --- a/docs/examples/example_autosave_ioc.py +++ b/docs/examples/example_autosave_ioc.py @@ -7,11 +7,11 @@ # Create records, set some of them to autosave, also save some of their fields builder.aOut("AO", autosave=True) -builder.aIn("AI", autosave_fields=["PREC", "EGU"]) +builder.aIn("AI", autosave=["PREC", "EGU"]) builder.boolIn("BO") builder.WaveformIn("WAVEFORMIN", [0, 0, 0, 0], autosave=True) -with autosave.Autosave(True, ["LOPR", "HOPR"]): - builder.aOut("AUTOMATIC-AO", autosave_fields=["EGU"]) +with autosave.Autosave(["VAL", "LOPR", "HOPR"]): + builder.aOut("AUTOMATIC-AO", autosave=["EGU"]) seconds = builder.longOut("SECONDSRUN", autosave=True) autosave.configure( diff --git a/docs/how-to/use-autosave-in-an-ioc.rst b/docs/how-to/use-autosave-in-an-ioc.rst index 4ce16345..f6353ed1 100644 --- a/docs/how-to/use-autosave-in-an-ioc.rst +++ b/docs/how-to/use-autosave-in-an-ioc.rst @@ -10,10 +10,12 @@ Example IOC .. literalinclude:: ../examples/example_autosave_ioc.py Records are instantiated as normal and configured for automatic loading and -periodic saving to a backup file with the keyword arguments ``autosave`` and ``autosave_fields``. -Records with ``autosave=True`` (``False`` by default) have their -VAL fields backed up. Additional record fields in a list passed to ``autosave_fields`` will be backed -up - note that this applies even when ``autosave`` is ``False``. +periodic saving to a backup file with use of the keyword argument ``autosave``. +``autosave`` resolves to a list of strings, which are the names of fields to be +tracked by autosave. By default ``autosave=False``, which disables autosave for that PV. +Setting ``autosave=True`` is equivalent to passing ``["VAL"]``. Note that ``"VAL"`` must be +explicitly passed when tracking other fields, e.g. ``["VAL", "LOPR", "HOPR"]``. +``autosave`` can also accept a single string field name as an argument. The field values get written into a yaml-formatted file containing key-value pairs. By default the keys are the same as the full PV name, including any device name specified @@ -26,10 +28,11 @@ set to 30.0 by default. The directory must exist, and should be configured with read/write permissions for the user running the IOC. IOC developers should only need to interface with autosave via the :func:`~softioc.autosave.configure()` -method and the ``autosave`` and ``autosave_fields`` keyword arguments. Alternatively, +method and the ``autosave`` keyword argument. Alternatively, PVs can be instantiated inside the :class:`~softioc.autosave.Autosave()` context manager, which -automatically passes the arguments ``autosave`` and ``autosave_fields`` to any PVs created -inside the context manager. If the PV already has ``autosave_fields`` set, the lists +automatically passes the ``autosave`` argument to any PVs created +inside the context manager. If any fields are already specified by the ``autosave`` keyword +argument of PV's initialisation call the lists of fields get combined. All other module members are intended for internal use only. In normal operation, loading from a backup is performed once during the @@ -44,7 +47,7 @@ the backup file. If autosave is enabled and active, a timestamped copy of the latest existing autosave backup file is created when the IOC is restarted, e.g. ``.softsav_240717-095004`` (timestamps are in the format yymmdd-HHMMSS). If you only wish to store one backup of the autosave file at a time, ``timestamped_backups=False`` -can be passed to :func:`~softioc.autosave.configure()`, this will create a backup file +can be passed to :func:`~softioc.autosave.configure()` when it is called, this will create a backup file named ``.softsav.bu``. To disable any autosaving, comment out the :func:`~softioc.autosave.configure()` call or pass it the keyword argument ``enabled=False``. diff --git a/docs/reference/api.rst b/docs/reference/api.rst index b624eb06..40b7b04b 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -196,21 +196,21 @@ and stderr streams, is sent directly to the terminal. at IOC startup, and no values with be saved to any backup files. .. seealso:: - `softioc.builder` for how to designate a field for autosave. - :ref:`autosave`, the keyword argument used to initialise PVs with the VAL field tracked by autosave. + :ref:`autosave`, the builder keyword argument used to designate PV fields for autosave - `autosave_fields`, the keyword argument used to initialise PVs with designated fields tracked by autosave. + :class:`Autosave` for how to add fields to autosave inside a context manager. .. class:: Autosave - .. method:: __init__(autosave=True, autosave_fields=None) + .. method:: __init__(autosave=True) To be called as a context manager. Any PVs that are created inside - the context manager have the arguments ``autosave`` and ``autosave_fields`` - passed to them automatically, where ``autosave_fields`` is an optional list of - field names. If the PV already has autosave_fields set, the lists of fields get - combined. + the context manager have the fields passed to the ``autosave`` argument of + the context manager added to autosave tracking. The options for ``autosave`` + are identical to the ones described in the builder keyword argument + :ref:`autosave`. If a PV already has :ref:`autosave` set, the two lists of fields + get combined into a single set. @@ -337,24 +337,24 @@ and stderr streams, is sent directly to the terminal. :ref:`autosave` ~~~~~~~~~~~~~~~ - Available on all record types. When set to `True` it marks the record - value for automatic periodic backing up to a file. Set to `False` by - default. When the IOC is restarted and a backup file exists, the value is + Available on all record types. + Resoles to a list of string field names, when not empty it marks the record + fields for automatic periodic backing up to a file. Set to `False` by + default. When the IOC is restarted and a backup file exists, the saved values are loaded from this file when :func:`~softioc.builder.LoadDatabase` is called. - The saved value takes priority over any value - given in `initial_value`. No backing up will occur unless autosave is + The saved values takes priority over any initial field value passed to the PV + in `initial_value` or ``**fields``. No backing up will occur unless autosave is enabled and configured with :func:`~softioc.autosave.configure`. - .. seealso:: - :func:`~softioc.autosave.configure` for discussion on how to configure saving. - - .. _autosave_fields: + The options for the argument are: - `autosave_fields` - ~~~~~~~~~~~~~~~~~ + * ``True``, which is equivalent to ``["VAL"]`` + * ``False``, which is equivalent to ``[]`` and disables autosave tracking for the PV + * A list of field names such as ``["VAL", "LOPR", "HOPR"]``, note that ``"VAL"`` must be explicitly provided + * A single field name such as ``"EGU"`` which is equivalent to passing ``["EGU"]``. - A list of strings of record fields belonging to the PV (e.g. ["EGU", "PREC"]) - to be saved to and loaded from a backup file. Empty by default. + .. seealso:: + :func:`~softioc.autosave.configure` for discussion on how to configure saving. For all of these functions any EPICS database field can be assigned a value by diff --git a/softioc/autosave.py b/softioc/autosave.py index 38647663..08d14b65 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -80,40 +80,41 @@ def _shutdown_autosave_thread(worker): worker.join() -def add_pv_to_autosave(pv, name, kargs): +def _parse_autosave_fields(fields): + if fields is False: + return [] + elif fields is True: + return ["VAL"] + elif isinstance(fields, list): + return fields + elif isinstance(fields, str): + return [fields] + else: + raise ValueError(f"Could not parse autosave fields argument: {fields}") + + +def add_pv_to_autosave(pv, name, fields): """Configures a PV for autosave Args: pv: a PV object inheriting ProcessDeviceSupportCore name: the key by which the PV value is saved to and loaded from a backup. This is typically the same as the PV name. - kargs: a dictionary containing the optional keys "autosave", a boolean - used to add the VAL field to autosave backups, and - "autosave_fields", a list of string field names to save to the - backup file. + fields: used to determine which fields of a PV are tracked by autosave. + The allowed options are a single string such as "VAL" or "EGU", + a list of strings such as ["VAL", "EGU"], a boolean True which + evaluates to ["VAL"] or False to track no fields. If the PV is + created inside an Autosave context manager, the fields passed to the + context manager are also tracked by autosave. """ - context = _AutosaveContext() + fields = set(_parse_autosave_fields(fields)) # instantiate to get thread local class variables via instance - if context._in_cm: - # non-None autosave argument to PV takes priority over context manager - autosave_karg = kargs.pop("autosave", None) - save_val = ( - autosave_karg - if autosave_karg is not None - else context._val - ) - save_fields = ( - kargs.pop("autosave_fields", []) + context._fields - ) - else: - save_val = kargs.pop("autosave", False) - save_fields = kargs.pop("autosave_fields", []) - if save_val: - Autosave._pvs[name] = _AutosavePV(pv) - if save_fields: - for field in save_fields: - Autosave._pvs[f"{name}.{field}"] = _AutosavePV(pv, field) + if context._in_cm: # _fields should always be a list if in context manager + fields.update(context._fields) + for field in fields: + field_name = name if field == "VAL" else f"{name}.{field}" + Autosave._pvs[field_name] = _AutosavePV(pv, field) def load_autosave(): @@ -121,8 +122,8 @@ def load_autosave(): class _AutosavePV: - def __init__(self, pv, field=None): - if not field or field == "VAL": + def __init__(self, pv, field): + if field == "VAL": self.get = pv.get self.set = pv.set else: @@ -153,29 +154,27 @@ def _get_backup_sav_path(): sav_path = _get_current_sav_path() return sav_path.parent / (sav_path.name + ".bu") + class _AutosaveContext(threading.local): _instance = None _lock = threading.Lock() - _val = None _fields = None _in_cm = False - def __new__(cls, val=None, fields=None): + + def __new__(cls, fields=None): if cls._instance is None: with cls._lock: if not cls._instance: cls._instance = super().__new__(cls) - if cls._instance._in_cm: - if val is not None: - cls._instance._val = val - if fields is not None: - cls._instance._fields = fields or [] + if cls._instance._in_cm and fields is not None: + cls._instance._fields = fields or [] return cls._instance def reset(self): self._fields = None - self._val = None self._in_cm = False + class Autosave: _pvs = {} _last_saved_state = {} @@ -183,15 +182,27 @@ class Autosave: _stop_event = threading.Event() _loop_started = False - def __init__(self, autosave=None, autosave_fields=None): + def __init__(self, fields=True): + """ + When called as a context manager, any PVs created in the context have + the fields provided by the fields argument added to autosave backups. + + Args: + fields: a list of string field names to be periodically saved to a + backup file, which are loaded from on IOC restart. + The allowed options are a single string such as "VAL" or "EGU", + a list of strings such as ["VAL", "EGU"], a boolean True which + evaluates to ["VAL"] or False to track no additional fields. + If the autosave keyword is already specified in a PV's + initialisation, the list of fields to track are combined. + """ context = _AutosaveContext() if context._in_cm: raise RuntimeError( - "Can not instantiate Autosave when already in context manager") - if autosave is not None: - context._val = autosave - if autosave_fields is not None: - context._fields = autosave_fields + "Can not instantiate Autosave when already in context manager" + ) + fields = _parse_autosave_fields(fields) + context._fields = fields def __enter__(self): context = _AutosaveContext() diff --git a/softioc/device.py b/softioc/device.py index 2ebab071..c2916046 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -55,7 +55,8 @@ class ProcessDeviceSupportCore(DeviceSupportCore, RecordLookup): # all record types can support autosave def __init__(self, name, **kargs): - autosave.add_pv_to_autosave(self, name, kargs) + autosave_fields = kargs.pop("autosave", False) + autosave.add_pv_to_autosave(self, name, autosave_fields) self.__super.__init__(name, **kargs) # Most subclasses (all except waveforms) define a ctypes constructor for the diff --git a/softioc/pythonSoftIoc.py b/softioc/pythonSoftIoc.py index c755e3f4..8d04606f 100644 --- a/softioc/pythonSoftIoc.py +++ b/softioc/pythonSoftIoc.py @@ -25,7 +25,7 @@ def __init__(self, builder, device, name, **fields): DeviceKeywords = [ 'on_update', 'on_update_name', 'validate', 'always_update', 'initial_value', '_wf_nelm', '_wf_dtype', 'blocking', - 'autosave', 'autosave_fields' + 'autosave' ] device_kargs = {} for keyword in DeviceKeywords: diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 364fb9b1..4ca36908 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -20,7 +20,6 @@ def reset_autosave_setup_teardown(): default_tb = autosave.AutosaveConfig.timestamped_backups default_pvs = autosave.Autosave._pvs.copy() default_state = autosave.Autosave._last_saved_state.copy() - default_cm_save_val = autosave._AutosaveContext._val default_cm_save_fields = autosave._AutosaveContext._fields default_instance = autosave._AutosaveContext._instance yield @@ -32,7 +31,6 @@ def reset_autosave_setup_teardown(): autosave.Autosave._pvs = default_pvs autosave.Autosave._last_saved_state = default_state autosave.Autosave._stop_event = threading.Event() - autosave._AutosaveContext._val = default_cm_save_val autosave._AutosaveContext._fields = default_cm_save_fields autosave._AutosaveContext._instance = default_instance @@ -142,8 +140,8 @@ def test_all_record_types_saveable(tmp_path): def test_can_save_fields(tmp_path): - builder.aOut("SAVEVAL", autosave=True, autosave_fields=["DISA"]) - builder.aOut("DONTSAVEVAL", autosave_fields=["SCAN"]) + builder.aOut("SAVEVAL", autosave=["VAL", "DISA"]) + builder.aOut("DONTSAVEVAL", autosave=["SCAN"]) # we need to patch get_field as we can't call builder.LoadDatabase() # and softioc.iocInit() in unit tests with patch( @@ -219,11 +217,11 @@ def test_autosave_key_names(tmp_path): def test_context_manager(tmp_path): - builder.aOut("MANUAL", autosave=True, autosave_fields=["EGU"]) - with autosave.Autosave(True, ["PINI"]): + builder.aOut("MANUAL", autosave=["VAL", "EGU"]) + with autosave.Autosave(["VAL", "PINI"]): builder.aOut("AUTOMATIC") builder.aOut( - "AUTOMATIC-OVERRIDDEN", autosave=False, autosave_fields=["SCAN"] + "AUTOMATIC-EXTRA-FIELD", autosave=["SCAN"] ) autosave.configure(tmp_path, DEVICE_NAME) with patch( @@ -237,9 +235,9 @@ def test_context_manager(tmp_path): assert "MANUAL.EGU" in saved assert "AUTOMATIC" in saved assert "AUTOMATIC.PINI" in saved - assert "AUTOMATIC-OVERRIDDEN" not in saved - assert "AUTOMATIC-OVERRIDDEN.SCAN" in saved - assert "AUTOMATIC-OVERRIDDEN.PINI" in saved + assert "AUTOMATIC-EXTRA-FIELD" in saved + assert "AUTOMATIC-EXTRA-FIELD.SCAN" in saved + assert "AUTOMATIC-EXTRA-FIELD.PINI" in saved def check_all_record_types_load_properly(device_name, autosave_dir, conn): @@ -401,9 +399,9 @@ def check_autosave_field_names_contain_device_prefix( device_name, tmp_path, conn ): autosave.configure(tmp_path, device_name, save_period=1) - builder.aOut("BEFORE", autosave=True, autosave_fields=["EGU"]) + builder.aOut("BEFORE", autosave=["VAL", "EGU"]) builder.SetDeviceName(device_name) - builder.aOut("AFTER", autosave=True, autosave_fields=["EGU"]) + builder.aOut("AFTER", autosave=["VAL", "EGU"]) builder.LoadDatabase() softioc.iocInit() time.sleep(2) @@ -438,7 +436,7 @@ def create_pv_in_thread(name, wait): pv_thread_in_cm = threading.Thread( target=create_pv_in_thread, args=["PV-FROM-THREAD-DURING", 0]) pv_thread_before_cm.start() - with autosave.Autosave(True, ["EGU"]): + with autosave.Autosave(["VAL", "EGU"]): builder.aOut("PV-FROM-CM") pv_thread_in_cm.start() pv_thread_in_cm.join() @@ -451,9 +449,9 @@ def create_pv_in_thread(name, wait): def test_nested_context_managers_raises(tmp_path): autosave.configure(tmp_path, DEVICE_NAME) - with autosave.Autosave(False, ["SCAN"]): + with autosave.Autosave(["SCAN"]): with pytest.raises(RuntimeError): - with autosave.Autosave(True, []): + with autosave.Autosave(False): builder.aOut("MY-PV") with pytest.raises(RuntimeError): autosave.Autosave() From 22c719494005eaad966c99fc056c5a3d970bd989 Mon Sep 17 00:00:00 2001 From: James Souter Date: Mon, 9 Sep 2024 10:57:16 +0100 Subject: [PATCH 46/53] Bump github actions versions to v4 --- .github/workflows/code.yml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index 68d590bf..5a999657 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -17,10 +17,10 @@ jobs: runs-on: "ubuntu-latest" steps: - name: Checkout Source - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: "3.7" @@ -34,7 +34,7 @@ jobs: runs-on: "ubuntu-latest" steps: - name: Checkout Source - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: # require history to get back to last tag for version number of branches fetch-depth: 0 @@ -44,9 +44,9 @@ jobs: run: pipx run build --sdist . - name: Upload Sdist - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: dist + name: dist-${{ matrix.os }}-${{ matrix.python }} path: dist/* build: @@ -75,14 +75,14 @@ jobs: steps: - name: Checkout Source - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: # require history to get back to last tag for version number of branches fetch-depth: 0 submodules: true - name: Install Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: "3.7" @@ -106,20 +106,20 @@ jobs: CIBW_SKIP: "*-musllinux*" # epicscorelibs doesn't build on musllinux platforms - name: Upload Wheel - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: dist + name: dist-${{ matrix.os }}-${{ matrix.python }} path: dist/softioc* - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v4 with: name: ${{ matrix.os }}/${{ matrix.python }} directory: dist - name: Upload Unit Test Results if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: Unit Test Results (${{ matrix.os }}-${{ matrix.python }}) path: dist/pytest-results.xml @@ -132,7 +132,7 @@ jobs: steps: - name: Download Artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v4 with: path: artifacts @@ -152,7 +152,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/download-artifact@v2 + - uses: actions/download-artifact@v4 with: name: dist path: dist @@ -167,7 +167,7 @@ jobs: # upload to PyPI and make a release on every tag if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') steps: - - uses: actions/download-artifact@v2 + - uses: actions/download-artifact@v4 with: name: dist path: dist From 49fb971fe701d83e54fb0607b96f28dae535ca83 Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 10 Sep 2024 09:26:29 +0100 Subject: [PATCH 47/53] Add test for interaction between autosave kwarg and context manager --- docs/how-to/use-autosave-in-an-ioc.rst | 4 ++-- tests/test_autosave.py | 27 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/how-to/use-autosave-in-an-ioc.rst b/docs/how-to/use-autosave-in-an-ioc.rst index f6353ed1..8a41c91d 100644 --- a/docs/how-to/use-autosave-in-an-ioc.rst +++ b/docs/how-to/use-autosave-in-an-ioc.rst @@ -32,8 +32,8 @@ method and the ``autosave`` keyword argument. Alternatively, PVs can be instantiated inside the :class:`~softioc.autosave.Autosave()` context manager, which automatically passes the ``autosave`` argument to any PVs created inside the context manager. If any fields are already specified by the ``autosave`` keyword -argument of PV's initialisation call the lists -of fields get combined. All other module members are intended for internal use only. +argument of a PV's initialisation call the lists of fields to track get combined. +All other module members are intended for internal use only. In normal operation, loading from a backup is performed once during the :func:`~softioc.builder.LoadDatabase()` call and periodic saving to the backup file begins when diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 4ca36908..254f69e6 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -455,3 +455,30 @@ def test_nested_context_managers_raises(tmp_path): builder.aOut("MY-PV") with pytest.raises(RuntimeError): autosave.Autosave() + +def test_autosave_arguments(tmp_path): + autosave.configure(tmp_path, DEVICE_NAME) + builder.aOut("TRUE", autosave=True) + builder.aOut("FIELDS", autosave=["LOPR", "HOPR"]) + builder.aOut("FALSE", autosave=False) + assert set(autosave.Autosave._pvs) == {"TRUE", "FIELDS.LOPR", "FIELDS.HOPR"} + autosave.Autosave._pvs = {} + builder.ClearRecords() + with autosave.Autosave(): # True by default + builder.aOut("AUTO-TRUE", autosave=False) + builder.aOut("FIELDS", autosave=["LOPR", "HOPR"]) + assert set(autosave.Autosave._pvs) == { + "AUTO-TRUE", "FIELDS", "FIELDS.LOPR", "FIELDS.HOPR"} + autosave.Autosave._pvs = {} + builder.ClearRecords() + with autosave.Autosave(["EGU"]): + builder.aOut("AUTO-FALSE") + builder.aOut("FIELDS", autosave=["PINI"]) + assert set(autosave.Autosave._pvs) == { + "AUTO-FALSE.EGU", "FIELDS.EGU", "FIELDS.PINI"} + autosave.Autosave._pvs = {} + builder.ClearRecords() + with autosave.Autosave(False): + builder.aOut("AUTO-FALSE") + builder.aOut("AUTO-TRUE", autosave=True) + assert set(autosave.Autosave._pvs) == {"AUTO-TRUE"} From eac52e14396501d6d4d6fa235491b5d5baa0c91d Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 10 Sep 2024 09:37:45 +0100 Subject: [PATCH 48/53] Add test case for single string autosave kwarg --- tests/test_autosave.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 254f69e6..49ec46cd 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -461,7 +461,10 @@ def test_autosave_arguments(tmp_path): builder.aOut("TRUE", autosave=True) builder.aOut("FIELDS", autosave=["LOPR", "HOPR"]) builder.aOut("FALSE", autosave=False) - assert set(autosave.Autosave._pvs) == {"TRUE", "FIELDS.LOPR", "FIELDS.HOPR"} + builder.aOut("SINGLE-FIELD", autosave="EGU") + builder.aOut("AUTO-VAL", autosave="VAL") + assert set(autosave.Autosave._pvs) == { + "TRUE", "FIELDS.LOPR", "FIELDS.HOPR", "SINGLE-FIELD.EGU", "AUTO-VAL"} autosave.Autosave._pvs = {} builder.ClearRecords() with autosave.Autosave(): # True by default @@ -482,3 +485,8 @@ def test_autosave_arguments(tmp_path): builder.aOut("AUTO-FALSE") builder.aOut("AUTO-TRUE", autosave=True) assert set(autosave.Autosave._pvs) == {"AUTO-TRUE"} + autosave.Autosave._pvs = {} + builder.ClearRecords() + with autosave.Autosave("LOPR"): # single field + builder.aOut("FIELDS", autosave="HOPR") + assert set(autosave.Autosave._pvs) == {"FIELDS.HOPR", "FIELDS.LOPR"} From 63410986fe9cf23bc6c4012e7d49b227c3efad70 Mon Sep 17 00:00:00 2001 From: James Souter Date: Tue, 10 Sep 2024 15:26:11 +0100 Subject: [PATCH 49/53] setting autosave=False in PV overrides context manager --- docs/reference/api.rst | 15 ++++++++++----- softioc/autosave.py | 9 ++++++--- softioc/device.py | 2 +- tests/test_autosave.py | 4 ++-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/reference/api.rst b/docs/reference/api.rst index 40b7b04b..6bbee443 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -210,7 +210,9 @@ and stderr streams, is sent directly to the terminal. the context manager added to autosave tracking. The options for ``autosave`` are identical to the ones described in the builder keyword argument :ref:`autosave`. If a PV already has :ref:`autosave` set, the two lists of fields - get combined into a single set. + get combined into a single set. If the PV's :ref:`autosave` keyword is set + explicitly to ``False``, the fields specified in the context manager's argument + are not tracked. @@ -338,8 +340,8 @@ and stderr streams, is sent directly to the terminal. ~~~~~~~~~~~~~~~ Available on all record types. - Resoles to a list of string field names, when not empty it marks the record - fields for automatic periodic backing up to a file. Set to `False` by + Resolves to a list of string field names. When not empty it marks the record + fields for automatic periodic backing up to a file. Set to `None` by default. When the IOC is restarted and a backup file exists, the saved values are loaded from this file when :func:`~softioc.builder.LoadDatabase` is called. The saved values takes priority over any initial field value passed to the PV @@ -349,13 +351,16 @@ and stderr streams, is sent directly to the terminal. The options for the argument are: * ``True``, which is equivalent to ``["VAL"]`` - * ``False``, which is equivalent to ``[]`` and disables autosave tracking for the PV + * ``False``, which is equivalent to ``[]`` and disables all autosave tracking for the PV, even inside an :class:`~softioc.autosave.Autosave` context manager + * ``None``, similar to ``False`` but does not overload any fields specified in an :class:`~softioc.autosave.Autosave` context manager * A list of field names such as ``["VAL", "LOPR", "HOPR"]``, note that ``"VAL"`` must be explicitly provided - * A single field name such as ``"EGU"`` which is equivalent to passing ``["EGU"]``. + * A single field name such as ``"EGU"`` which is equivalent to passing ``["EGU"]`` .. seealso:: :func:`~softioc.autosave.configure` for discussion on how to configure saving. + :class:`~softioc.autosave.Autosave` for how to track PVs with autosave inside a context manager. + For all of these functions any EPICS database field can be assigned a value by passing it as a keyword argument for the corresponding field name (in upper diff --git a/softioc/autosave.py b/softioc/autosave.py index 08d14b65..48576f02 100644 --- a/softioc/autosave.py +++ b/softioc/autosave.py @@ -81,7 +81,7 @@ def _shutdown_autosave_thread(worker): def _parse_autosave_fields(fields): - if fields is False: + if not fields: return [] elif fields is True: return ["VAL"] @@ -107,9 +107,12 @@ def add_pv_to_autosave(pv, name, fields): created inside an Autosave context manager, the fields passed to the context manager are also tracked by autosave. """ - context = _AutosaveContext() + if fields is False: + # if autosave=False explicitly set, override context manager + return fields = set(_parse_autosave_fields(fields)) - # instantiate to get thread local class variables via instance + # instantiate context to get thread local class variables via instance + context = _AutosaveContext() if context._in_cm: # _fields should always be a list if in context manager fields.update(context._fields) for field in fields: diff --git a/softioc/device.py b/softioc/device.py index c2916046..332de014 100644 --- a/softioc/device.py +++ b/softioc/device.py @@ -55,7 +55,7 @@ class ProcessDeviceSupportCore(DeviceSupportCore, RecordLookup): # all record types can support autosave def __init__(self, name, **kargs): - autosave_fields = kargs.pop("autosave", False) + autosave_fields = kargs.pop("autosave", None) autosave.add_pv_to_autosave(self, name, autosave_fields) self.__super.__init__(name, **kargs) diff --git a/tests/test_autosave.py b/tests/test_autosave.py index 49ec46cd..a0d16313 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -471,7 +471,7 @@ def test_autosave_arguments(tmp_path): builder.aOut("AUTO-TRUE", autosave=False) builder.aOut("FIELDS", autosave=["LOPR", "HOPR"]) assert set(autosave.Autosave._pvs) == { - "AUTO-TRUE", "FIELDS", "FIELDS.LOPR", "FIELDS.HOPR"} + "FIELDS", "FIELDS.LOPR", "FIELDS.HOPR"} autosave.Autosave._pvs = {} builder.ClearRecords() with autosave.Autosave(["EGU"]): @@ -482,7 +482,7 @@ def test_autosave_arguments(tmp_path): autosave.Autosave._pvs = {} builder.ClearRecords() with autosave.Autosave(False): - builder.aOut("AUTO-FALSE") + builder.aOut("AUTO-DEFAULT") builder.aOut("AUTO-TRUE", autosave=True) assert set(autosave.Autosave._pvs) == {"AUTO-TRUE"} autosave.Autosave._pvs = {} From 0d00d7520f4572e362fcce10e4f9ba0f885b8746 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 11 Sep 2024 10:01:56 +0100 Subject: [PATCH 50/53] add pyyaml dependency to setup.py --- Pipfile.lock | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 3 +- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/Pipfile.lock b/Pipfile.lock index 474775f3..ae346b24 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -152,6 +152,51 @@ ], "version": "==1.3.1" }, + "pyyaml": { + "hashes": [ + "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf", + "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", + "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", + "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", + "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", + "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", + "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", + "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", + "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", + "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", + "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", + "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782", + "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", + "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", + "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", + "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", + "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1", + "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", + "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", + "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", + "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", + "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", + "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d", + "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", + "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7", + "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", + "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", + "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358", + "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", + "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", + "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", + "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f", + "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", + "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" + ], + "version": "==6.0" + }, "scipy": { "hashes": [ "sha256:01b38dec7e9f897d4db04f8de4e20f0f5be3feac98468188a0f47a991b796055", @@ -691,6 +736,51 @@ ], "version": "==2022.4" }, + "pyyaml": { + "hashes": [ + "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf", + "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", + "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", + "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", + "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", + "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", + "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", + "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", + "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", + "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", + "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", + "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782", + "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", + "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", + "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", + "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", + "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1", + "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", + "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", + "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", + "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", + "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", + "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d", + "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", + "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7", + "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", + "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", + "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358", + "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", + "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", + "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", + "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f", + "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", + "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" + ], + "version": "==6.0" + }, "requests": { "hashes": [ "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", diff --git a/setup.py b/setup.py index 480d5c21..117387ab 100644 --- a/setup.py +++ b/setup.py @@ -106,7 +106,8 @@ def install_for_development(self): epicscorelibs.version.abi_requires(), "pvxslibs>=1.2.4", "numpy<2.0", - "epicsdbbuilder>=1.4" + "epicsdbbuilder>=1.4", + "pyyaml>=6.0" ], zip_safe = False, # setuptools_dso is not compatible with eggs! ) From 4822ac86e41d6839681fabc8688f00952d0918e4 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 11 Sep 2024 10:21:29 +0100 Subject: [PATCH 51/53] use threading.Event to remove timing element from autosave context manager test --- tests/test_autosave.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_autosave.py b/tests/test_autosave.py index a0d16313..faf8d0ce 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -426,17 +426,17 @@ def test_autosave_field_names_contain_device_prefix(tmp_path): def test_context_manager_thread_safety(tmp_path): autosave.configure(tmp_path, DEVICE_NAME) - - def create_pv_in_thread(name, wait): - time.sleep(wait) + in_cm_event = threading.Event() + def create_pv_in_thread(name): + in_cm_event.wait() builder.aOut(name, autosave=False) - pv_thread_before_cm = threading.Thread( - target=create_pv_in_thread, args=["PV-FROM-THREAD-BEFORE", 1]) + target=create_pv_in_thread, args=["PV-FROM-THREAD-BEFORE"]) pv_thread_in_cm = threading.Thread( - target=create_pv_in_thread, args=["PV-FROM-THREAD-DURING", 0]) + target=create_pv_in_thread, args=["PV-FROM-THREAD-DURING"]) pv_thread_before_cm.start() with autosave.Autosave(["VAL", "EGU"]): + in_cm_event.set() builder.aOut("PV-FROM-CM") pv_thread_in_cm.start() pv_thread_in_cm.join() From a2e2ccf474bc2a23926b94e5339d0ed00f40d9c3 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 11 Sep 2024 11:00:21 +0100 Subject: [PATCH 52/53] CI fixes --- .github/workflows/code.yml | 12 ++++++++++-- tests/test_autosave.py | 9 ++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index 5a999657..beae53c6 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -46,7 +46,7 @@ jobs: - name: Upload Sdist uses: actions/upload-artifact@v4 with: - name: dist-${{ matrix.os }}-${{ matrix.python }} + name: dist path: dist/* build: @@ -56,6 +56,11 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] python: [cp37, cp38, cp39, cp310] + exclude: + # MacOS 14.4.1 for arm64 doesn't support Python < 3.8 + - os: macos-latest + python: "cp37" + include: # Put coverage and results files in the project directory for mac - os: macos-latest @@ -69,6 +74,9 @@ jobs: - os: ubuntu-latest cov_file: /output/coverage.xml results_file: /output/pytest-results.xml + # MacOS 13 required for Python < 3.8 + - os: macos-13 + python: "cp37" name: build/${{ matrix.os }}/${{ matrix.python }} runs-on: ${{ matrix.os }} @@ -84,7 +92,7 @@ jobs: - name: Install Python uses: actions/setup-python@v4 with: - python-version: "3.7" + python-version: "3.12" - name: Install Python Dependencies # Pin cibuildwheel due to https://github.com/pypa/cibuildwheel/issues/962 diff --git a/tests/test_autosave.py b/tests/test_autosave.py index faf8d0ce..a1dda923 100644 --- a/tests/test_autosave.py +++ b/tests/test_autosave.py @@ -1,5 +1,5 @@ from conftest import get_multiprocessing_context, select_and_recv -from softioc import autosave, builder, softioc, device_core +from softioc import autosave, builder, softioc, device_core, asyncio_dispatcher from unittest.mock import patch import pytest import threading @@ -356,7 +356,8 @@ def check_all_record_types_save_properly(device_name, autosave_dir, conn): builder.WaveformIn("WaveformIn", [1, 2, 3, 4], autosave=True) builder.WaveformOut("WaveformOut", [1, 2, 3, 4], autosave=True) builder.LoadDatabase() - softioc.iocInit() + dispatcher = asyncio_dispatcher.AsyncioDispatcher() + softioc.iocInit(dispatcher) # wait long enough to ensure one save has occurred time.sleep(2) with open(autosave_dir / f"{device_name}.softsav", "r") as f: @@ -403,7 +404,8 @@ def check_autosave_field_names_contain_device_prefix( builder.SetDeviceName(device_name) builder.aOut("AFTER", autosave=["VAL", "EGU"]) builder.LoadDatabase() - softioc.iocInit() + dispatcher = asyncio_dispatcher.AsyncioDispatcher() + softioc.iocInit(dispatcher) time.sleep(2) with open(tmp_path / f"{device_name}.softsav", "r") as f: saved = yaml.full_load(f) @@ -427,6 +429,7 @@ def test_autosave_field_names_contain_device_prefix(tmp_path): def test_context_manager_thread_safety(tmp_path): autosave.configure(tmp_path, DEVICE_NAME) in_cm_event = threading.Event() + def create_pv_in_thread(name): in_cm_event.wait() builder.aOut(name, autosave=False) From 8b6413bc06bd8027cb35125057ff711348c8fa15 Mon Sep 17 00:00:00 2001 From: James Souter Date: Fri, 13 Sep 2024 08:49:13 +0100 Subject: [PATCH 53/53] CI fix for macos python3.7 --- .github/workflows/code.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index beae53c6..2d407c93 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -77,6 +77,8 @@ jobs: # MacOS 13 required for Python < 3.8 - os: macos-13 python: "cp37" + cov_file: "{project}/dist/coverage.xml" + results_file: "{project}/dist/pytest-results.xml" name: build/${{ matrix.os }}/${{ matrix.python }} runs-on: ${{ matrix.os }}