From 1b4497f3b05cfd114d820eff93e0f9a1f824aae8 Mon Sep 17 00:00:00 2001 From: James Souter Date: Wed, 7 Aug 2024 14:37:38 +0100 Subject: [PATCH] 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)