diff --git a/datafiles/config.py b/datafiles/config.py index 5fb52e27..1279fdc0 100644 --- a/datafiles/config.py +++ b/datafiles/config.py @@ -15,6 +15,7 @@ class Meta: datafile_manual: bool = False datafile_defaults: bool = False datafile_infer: bool = False + datafile_rename: bool = False def load(obj) -> Meta: @@ -30,5 +31,7 @@ def load(obj) -> Meta: meta.datafile_defaults = obj.Meta.datafile_defaults with suppress(AttributeError): meta.datafile_infer = obj.Meta.datafile_infer + with suppress(AttributeError): + meta.datafile_rename = obj.Meta.datafile_rename return meta diff --git a/datafiles/decorators.py b/datafiles/decorators.py index 3bc12874..c4458d6f 100644 --- a/datafiles/decorators.py +++ b/datafiles/decorators.py @@ -13,6 +13,7 @@ def datafile( manual: bool = Meta.datafile_manual, defaults: bool = Meta.datafile_defaults, infer: bool = Meta.datafile_infer, + rename: bool = Meta.datafile_rename, **kwargs, ): """Synchronize a data class to the specified path.""" @@ -36,6 +37,7 @@ def decorator(cls=None): manual=manual, defaults=defaults, infer=infer, + rename=rename, ) return decorator diff --git a/datafiles/mapper.py b/datafiles/mapper.py index af90d80b..f3c439ae 100644 --- a/datafiles/mapper.py +++ b/datafiles/mapper.py @@ -15,7 +15,7 @@ from . import config, formats, hooks from .converters import Converter, List, map_type, resolve from .types import Missing, Trilean -from .utils import display, get_default_field_value, recursive_update, write +from .utils import display, get_default_field_value, recursive_update, remove, write class Mapper: @@ -28,6 +28,7 @@ def __init__( manual: bool, defaults: bool, infer: bool, + rename: bool, root: Optional[Mapper] = None, ) -> None: assert manual is not None @@ -40,6 +41,7 @@ def __init__( self.attrs = attrs self._pattern = pattern self._manual = manual + self._rename = rename self.defaults = defaults self._infer = infer self._last_load = 0.0 @@ -264,6 +266,21 @@ def save(self, *, include_default_values: Trilean = None, _log=True) -> None: self._root.save(include_default_values=include_default_values, _log=_log) return + # Determine whether the expected filepath of the file has changed (which + # happens as a result of modifying attributes that compose the filename). + # Note that this behaviour is gated behind rename=True flag. + file_rename_required = False + original_path = self.path + + if self._rename: + with hooks.disabled(): # hooks have to be disabled to prevent infinite loop + if "path" in self.__dict__: + del self.__dict__["path"] # invalidate the cached property + + # This call of self.path updates the value since the cache is invalidated + if self.path != original_path: + file_rename_required = True + if self.path: if self.exists and self._frozen: raise dataclasses.FrozenInstanceError( @@ -279,6 +296,8 @@ def save(self, *, include_default_values: Trilean = None, _log=True) -> None: text = self._get_text(include_default_values=include_default_values) write(self.path, text, display=True) + if self._rename and file_rename_required: + remove(original_path) self.modified = False @@ -306,6 +325,7 @@ def create_mapper(obj, root=None) -> Mapper: attrs=attrs or {}, pattern=pattern, manual=meta.datafile_manual, + rename=meta.datafile_rename, defaults=meta.datafile_defaults, infer=meta.datafile_infer, root=root, diff --git a/datafiles/model.py b/datafiles/model.py index 580de27d..54816da5 100644 --- a/datafiles/model.py +++ b/datafiles/model.py @@ -45,7 +45,14 @@ def objects(cls) -> Manager: # pylint: disable=no-self-argument def create_model( - cls, *, attrs=None, manual=None, pattern=None, defaults=None, infer=None + cls, + *, + attrs=None, + manual=None, + pattern=None, + defaults=None, + infer=None, + rename=None, ): """Patch model attributes on to an existing dataclass.""" log.debug(f"Converting {cls} to a datafile model") @@ -68,6 +75,8 @@ def create_model( m.datafile_defaults = defaults if not hasattr(cls, "Meta") and infer is not None: m.datafile_infer = infer + if not hasattr(cls, "Meta") and rename is not None: + m.datafile_rename = rename cls.Meta = m diff --git a/datafiles/tests/test_mapper.py b/datafiles/tests/test_mapper.py index be754aa5..50a9fcf0 100644 --- a/datafiles/tests/test_mapper.py +++ b/datafiles/tests/test_mapper.py @@ -31,6 +31,7 @@ def mapper(): manual=Meta.datafile_manual, defaults=Meta.datafile_defaults, infer=Meta.datafile_infer, + rename=Meta.datafile_rename, ) def describe_path(): diff --git a/datafiles/utils.py b/datafiles/utils.py index d7b310f5..5f7a7a9a 100644 --- a/datafiles/utils.py +++ b/datafiles/utils.py @@ -141,6 +141,14 @@ def read(filename: str, *, display=False) -> str: return text +def remove(filename_or_path: Union[str, Path]) -> None: + """Remove a given file, if it exists.""" + filepath = Path(filename_or_path) + if filepath.exists(): + filepath.unlink() + log.debug("Removed filepath: %s", str(filepath)) + + def display(path: Path, data: Dict) -> None: """Display data read from a file.""" message = f"Data from file: {path}"