diff --git a/src/decman/core/error.py b/src/decman/core/error.py index b1460fd..acf570e 100644 --- a/src/decman/core/error.py +++ b/src/decman/core/error.py @@ -12,6 +12,17 @@ class SourceError(Exception): super().__init__(message) +class FSInstallationFailedError(Exception): + """ + Error raised when trying to install a file/directory to a target. + """ + + def __init__(self, target: str, source: str, reason: str): + self.source = source + self.target = target + super().__init__(f"Failed to install file from {source} to {target}: {reason}") + + class InvalidOnDisableError(Exception): """ Error raised when trying to create a Module with an invalid on_disable method. diff --git a/src/decman/core/file_manager.py b/src/decman/core/file_manager.py new file mode 100644 index 0000000..f2743e0 --- /dev/null +++ b/src/decman/core/file_manager.py @@ -0,0 +1,158 @@ +import os +import typing + +import decman.core.error as errors +import decman.core.fs as fs +import decman.core.module as module +import decman.core.output as output +import decman.core.store as _store + + +def update_files( + store: _store.Store, + modules: set[module.Module], + files: dict[str, fs.File], + directories: dict[str, fs.Directory], + dry_run: bool = False, +) -> bool: + output.print_summary("Installing files.") + + all_checked_files = [] + all_changed_files = [] + store.ensure("all_files", []) + + try: + output.print_debug("Applying common files.") + checked, changed = _install_files(files, dry_run=dry_run) + all_checked_files += checked + all_changed_files += changed + + output.print_debug("Applying common directories.") + checked, changed = _install_directories(directories, dry_run=dry_run) + all_checked_files += checked + all_changed_files += changed + + for mod in modules: + module_changed_files = [] + + output.print_debug(f"Applying files in module '{mod.name}'.") + checked, changed = _install_files( + mod.files(), + variables=mod.file_variables(), + dry_run=dry_run, + ) + all_checked_files += checked + module_changed_files += changed + + output.print_debug(f"Applying directories in module '{mod.name}'.") + checked, changed = _install_directories( + mod.directories(), + variables=mod.file_variables(), + dry_run=dry_run, + ) + all_checked_files += checked + module_changed_files += changed + + if len(module_changed_files) > 0: + output.print_debug( + f"Module '{mod.name}' set to changed due to modified \ + files: {', '.join(module_changed_files)}" + ) + mod._changed = True + all_changed_files += module_changed_files + except errors.FSInstallationFailedError as error: + output.print_error(str(error)) + output.print_traceback() + return False + + to_remove = [] + for file in store["all_files"]: + if file not in all_checked_files: + to_remove.append(file) + + output.print_list("Updated files:", all_changed_files, elements_per_line=1) + output.print_list("Removing files:", to_remove, elements_per_line=1) + + if not dry_run: + for file in to_remove: + try: + os.remove(file) + except OSError as error: + output.print_warning(f"Failed to remove file: '{file}': {error.strerror}.") + store["all_files"] = all_checked_files + + return True + + +def _install_files( + files: dict[str, fs.File], + variables: typing.Optional[dict[str, str]] = None, + dry_run: bool = False, +) -> tuple[list[str], list[str]]: + checked_files = [] + changed_files = [] + + for target_filename, file in files.items(): + output.print_debug(f"Checking file {target_filename}.") + checked_files.append(target_filename) + + try: + if file.copy_to(target_filename, variables=variables, dry_run=dry_run): + changed_files.append(target_filename) + except FileNotFoundError as error: + raise errors.FSInstallationFailedError( + target_filename, file.source_file or "content", "Source file doesn't exist." + ) from error + except OSError as error: + raise errors.FSInstallationFailedError( + target_filename, file.source_file or "content", error.strerror or str(error) + ) from error + except UnicodeEncodeError as error: + raise errors.FSInstallationFailedError( + target_filename, file.source_file or "content", "Unicode encoding failed." + ) from error + except UnicodeDecodeError as error: + raise errors.FSInstallationFailedError( + target_filename, file.source_file or "content", "Unicode decoding failed." + ) from error + + return checked_files, changed_files + + +def _install_directories( + directories: dict[str, fs.Directory], + variables: typing.Optional[dict[str, str]] = None, + dry_run: bool = False, +) -> tuple[list[str], list[str]]: + checked_files = [] + changed_files = [] + + for target_dirname, directory in directories.items(): + output.print_debug(f"Checking directory {target_dirname}.") + try: + checked, changed = directory.copy_to( + target_dirname, variables=variables, dry_run=dry_run + ) + except FileNotFoundError as error: + raise errors.FSInstallationFailedError( + target_dirname, + directory.source_directory, + "Source directory doesn't exist.", + ) from error + except OSError as error: + raise errors.FSInstallationFailedError( + target_dirname, directory.source_directory, error.strerror or str(error) + ) from error + except UnicodeEncodeError as error: + raise errors.FSInstallationFailedError( + target_dirname, directory.source_directory, "Unicode encoding failed." + ) from error + except UnicodeDecodeError as error: + raise errors.FSInstallationFailedError( + target_dirname, directory.source_directory, "Unicode decoding failed." + ) from error + + checked_files += checked + changed_files += changed + + return checked_files, changed_files diff --git a/src/decman/core/fs.py b/src/decman/core/fs.py index 6338938..851e097 100644 --- a/src/decman/core/fs.py +++ b/src/decman/core/fs.py @@ -91,7 +91,9 @@ class File: except KeyError as error: raise errors.GroupNotFoundError(group) from error - def copy_to(self, target: str, variables: typing.Optional[dict[str, str]] = None) -> bool: + def copy_to( + self, target: str, variables: typing.Optional[dict[str, str]] = None, dry_run: bool = False + ) -> bool: """ Copies the contents of this file to the target file if they differ. @@ -104,7 +106,7 @@ class File: writing. Ignored for binary files and when ``bin_file`` is True. Returns: - True if the file contents were created or modified. + True if the file contents were/would be created or modified. False if the existing file already contained the desired contents. Raises: @@ -137,25 +139,28 @@ class File: assert gid is not None, "If uid is set, then gid is set." os.chown(dirct, uid, gid) - create_missing_dirs(target_directory, self.uid, self.gid) + if not dry_run: + create_missing_dirs(target_directory, self.uid, self.gid) - changed = self._write_content(target, variables) + changed = self._write_content(target, variables, dry_run) - if self.uid is not None: + if self.uid is not None and not dry_run: assert self.gid is not None, "If uid is set, then gid is set." os.chown(target, self.uid, self.gid) - os.chmod(target, self.permissions) + if not dry_run: + os.chmod(target, self.permissions) return changed - def _write_content(self, target: str, variables: dict[str, str]): + def _write_content(self, target: str, variables: dict[str, str], dry_run: bool): # Case 1: copy from source file directly (binary or no substitutions) if self.source_file is not None and (self.bin_file or len(variables) == 0): if os.path.exists(target): with open(self.source_file, "rb") as src, open(target, "rb") as dst: if src.read() == dst.read(): return False - shutil.copy(self.source_file, target) + if not dry_run: + shutil.copy(self.source_file, target) return True # Case 2: binary content from memory @@ -165,8 +170,9 @@ class File: with open(target, "rb") as file: if file.read() == desired_bytes: return False - with open(target, "wb") as file: - file.write(desired_bytes) + if not dry_run: + with open(target, "wb") as file: + file.write(desired_bytes) return True # From here on: text modes with possible substitutions @@ -184,8 +190,9 @@ class File: if file.read() == content: return False - with open(target, "wt", encoding=self.encoding) as file: - file.write(content) + if not dry_run: + with open(target, "wt", encoding=self.encoding) as file: + file.write(content) return True # Case 4: text content from in-memory string with substitutions @@ -199,8 +206,9 @@ class File: if file.read() == content: return False - with open(target, "wt", encoding=self.encoding) as file: - file.write(content) + if not dry_run: + with open(target, "wt", encoding=self.encoding) as file: + file.write(content) return True @@ -274,7 +282,7 @@ class Directory: target_directory: str, variables: typing.Optional[dict[str, str]] = None, dry_run: bool = False, - ) -> list[str]: + ) -> tuple[list[str], list[str]]: """ Copies the files in this directory to the target directory. Only replaces files that differ. @@ -292,12 +300,15 @@ class Directory: *would* be processed is returned. Returns: - list[str] - When ``dry_run`` is ``False``, paths of files that were created or whose contents - were modified. + tuple[list[str], list[str]] + The first list contains always every file in the source, the second list depends on + ``dry_run`` - When ``dry_run`` is ``True``, paths of all files that would be considered for - creation or modification (no changes are actually performed). + When ``dry_run`` is ``False``, the second list contains paths of files that were + created or whose contents were modified. + + When ``dry_run`` is ``True``, the second list contains paths of all files that would + be considered for creation or modification (no changes are actually performed). Raises: OSError @@ -312,6 +323,7 @@ class Directory: UnicodeEncodeError If text content cannot be encoded using ``encoding``. """ + checked = [] changed_or_created = [] original_wd = os.getcwd() try: @@ -328,13 +340,11 @@ class Directory: permissions=self.permissions, ) target = os.path.normpath(os.path.join(target_directory, src_path)) + checked.append(target) - if dry_run: + if file.copy_to(target, variables, dry_run): changed_or_created.append(target) - else: - if file.copy_to(target, variables): - changed_or_created.append(target) finally: os.chdir(original_wd) - return changed_or_created + return checked, changed_or_created diff --git a/src/decman/core/module.py b/src/decman/core/module.py index 313f260..f6483b9 100644 --- a/src/decman/core/module.py +++ b/src/decman/core/module.py @@ -71,6 +71,26 @@ class Module: imports. If you must use imports, define them inside this method. """ + def files(self) -> dict[str, fs.File]: + """ + Override this method to return files that should be installed as a part of this module. + """ + return {} + + def directories(self) -> dict[str, fs.Directory]: + """ + Override this method to return directories that should be installed as a part of this + module. + """ + return {} + + def file_variables(self) -> dict[str, str]: + """ + Override this method to return variables that should replaced with a new value inside + this module's text files. + """ + return {} + def __hash__(self) -> int: return hash(self.name) diff --git a/src/decman/core/output.py b/src/decman/core/output.py index 3f43ea5..5b4ccfe 100644 --- a/src/decman/core/output.py +++ b/src/decman/core/output.py @@ -1,6 +1,7 @@ import os import shutil import sys +import traceback import typing import decman.config as config @@ -94,6 +95,14 @@ def print_error(error_msg: str): print(f"{_tag()} {_red('ERROR')}: {error_msg}") +def print_traceback(): + """ + Prints the traceback to debug output. + """ + for line in traceback.format_exc().splitlines(): + print_debug(line) + + def print_warning(msg: str): """ Prints a warning to the user. diff --git a/src/decman/core/store.py b/src/decman/core/store.py new file mode 100644 index 0000000..0414a25 --- /dev/null +++ b/src/decman/core/store.py @@ -0,0 +1,64 @@ +import json +import os +import pathlib +import tempfile +import typing + + +class Store: + """ + Key-value store for saving decman state. + """ + + def __init__(self, path: str, dry_run: bool = False) -> None: + self._store: dict[str, typing.Any] = {} + self._path = pathlib.Path(path) + self._dry_run = dry_run + + if self._path.exists(): + with self._path.open("rt", encoding="utf-8") as file: + self._store = json.load(file) + + def __getitem__(self, key: str) -> typing.Any: + return self._store[key] + + def __setitem__(self, key: str, value: typing.Any) -> None: + self._store[key] = value + + def get(self, key: str, default: typing.Any = None) -> typing.Any: + return self._store.get(key, default) + + def ensure(self, key: str, default: typing.Any = None): + if key not in self._store: + self._store = default + + def __enter__(self) -> "Store": + return self + + def __exit__(self, exc_type, exc, tb): + self.save() + return False + + def save(self) -> None: + """ + Saves the store to the defined path. + """ + if self._dry_run: + return + + os.makedirs(self._path.parent, exist_ok=True) + + with tempfile.NamedTemporaryFile( + "wt", + encoding="utf-8", + dir=self._path.parent, + delete=False, + ) as tmp: + json.dump(self._store, tmp, indent=2) + tmp.flush() + os.fsync(tmp.fileno()) + + os.replace(tmp.name, self._path) + + def __repr__(self) -> str: + return repr(self._store) diff --git a/src/decman/plugins/__init__.py b/src/decman/plugins/__init__.py index e146509..c0c0c61 100644 --- a/src/decman/plugins/__init__.py +++ b/src/decman/plugins/__init__.py @@ -1,6 +1,7 @@ import importlib.metadata as metadata import decman.core.module as module +import decman.core.store as cstore class Plugin: @@ -23,14 +24,20 @@ class Plugin: """ return True - def apply(self, dry_run: bool = False): + def apply(self, store: cstore.Store, dry_run: bool = False) -> bool: """ Ensures that the state managed by this plugin is present. Set ``dry_run`` to only print changes applying this plugin would cause. - """ - def process_module(self, module: module.Module): + This method must not raise exceptions. Instead it should return False to indicate a + failure. The method should handle it's exceptions and print them to the user. + + Returns ``True`` when applying was successful, ``False`` when it failed. + """ + return True + + def process_module(self, store: cstore.Store, module: module.Module): """ Processes a module. """ diff --git a/tests/test_decman_core_file_manager.py b/tests/test_decman_core_file_manager.py new file mode 100644 index 0000000..b89ef96 --- /dev/null +++ b/tests/test_decman_core_file_manager.py @@ -0,0 +1,344 @@ +import os + +import pytest + +import decman.core.error as errors +import decman.core.output as output +from decman.core.file_manager import _install_directories, _install_files, update_files + + +class DummyFile: + def __init__(self, result=True, exc: BaseException | None = None): + self.result = result + self.exc = exc + self.source_file = None + self.calls: list[tuple[str, dict | None, bool]] = [] + + def copy_to(self, target: str, variables=None, dry_run: bool = False) -> bool: + self.calls.append((target, variables, dry_run)) + if self.exc is not None: + raise self.exc + return self.result + + +class DummyDirectory: + def __init__( + self, + checked: list[str] | None = None, + changed: list[str] | None = None, + exc: BaseException | None = None, + source_directory: str = "", + ): + self.checked = checked or [] + self.changed = changed or [] + self.exc = exc + self.source_directory = source_directory + self.calls: list[tuple[str, dict | None, bool]] = [] + + def copy_to(self, target: str, variables=None, dry_run: bool = False): + self.calls.append((target, variables, dry_run)) + if self.exc is not None: + raise self.exc + return self.checked, self.changed + + +class DummyModule: + def __init__( + self, + name: str, + file_map: dict[str, DummyFile] | None = None, + dir_map: dict[str, DummyDirectory] | None = None, + file_vars: dict[str, str] | None = None, + ): + self.name = name + self._file_map = file_map or {} + self._dir_map = dir_map or {} + self._file_vars = file_vars or {} + self._changed = False + + def files(self): + return self._file_map + + def directories(self): + return self._dir_map + + def file_variables(self): + return self._file_vars + + +class DummyStore: + def __init__(self, initial: dict | None = None): + self._data = dict(initial or {}) + + def __getitem__(self, key): + return self._data[key] + + def __setitem__(self, key, value): + self._data[key] = value + + def ensure(self, key, default): + self._data.setdefault(key, default) + + +# ---- _install_files ------------------------------------------------------- + + +def test_install_files_non_dry_run_tracks_checked_and_changed(): + f1 = DummyFile(result=True) + f2 = DummyFile(result=False) + files = { + "/tmp/file1": f1, + "/tmp/file2": f2, + } + + checked, changed = _install_files(files, variables={"X": "1"}, dry_run=False) + + assert checked == ["/tmp/file1", "/tmp/file2"] + assert changed == ["/tmp/file1"] + + assert f1.calls == [("/tmp/file1", {"X": "1"}, False)] + assert f2.calls == [("/tmp/file2", {"X": "1"}, False)] + + +def test_install_files_dry_run_uses_dry_run_flag_and_respects_return_value(): + f1 = DummyFile(result=True) + f2 = DummyFile(result=False) + files = { + "/tmp/file1": f1, + "/tmp/file2": f2, + } + + checked, changed = _install_files(files, variables=None, dry_run=True) + + assert checked == ["/tmp/file1", "/tmp/file2"] + assert changed == ["/tmp/file1"] # only ones that "would" change + + assert f1.calls == [("/tmp/file1", None, True)] + assert f2.calls == [("/tmp/file2", None, True)] + + +@pytest.mark.parametrize( + "exc", + [ + FileNotFoundError("nope"), + OSError("boom"), + UnicodeEncodeError("utf-8", "x", 0, 1, "bad"), + UnicodeDecodeError("utf-8", b"x", 0, 1, "bad"), + ], +) +def test_install_files_wraps_exceptions(exc): + f = DummyFile(exc=exc) + files = {"/tmp/file": f} + + with pytest.raises(errors.FSInstallationFailedError) as e: + _install_files(files, dry_run=False) + + msg = str(e.value) + assert "/tmp/file" in msg + assert "content" in msg or "Source file doesn't exist." in msg + + +# ---- _install_directories ------------------------------------------------- + + +def test_install_directories_aggregates_checked_and_changed(): + d1 = DummyDirectory( + checked=["/tmp/d1/a", "/tmp/d1/b"], + changed=["/tmp/d1/a"], + source_directory="/src/d1", + ) + d2 = DummyDirectory( + checked=["/tmp/d2/a"], + changed=["/tmp/d2/a"], + source_directory="/src/d2", + ) + dirs = { + "/tmp/d1": d1, + "/tmp/d2": d2, + } + + checked, changed = _install_directories(dirs, variables={"Y": "2"}, dry_run=False) + + assert checked == ["/tmp/d1/a", "/tmp/d1/b", "/tmp/d2/a"] + assert changed == ["/tmp/d1/a", "/tmp/d2/a"] + + # dry_run flag and variables propagated + assert d1.calls == [("/tmp/d1", {"Y": "2"}, False)] + assert d2.calls == [("/tmp/d2", {"Y": "2"}, False)] + + +@pytest.mark.parametrize( + "exc", + [ + FileNotFoundError("nope"), + OSError("boom"), + UnicodeEncodeError("utf-8", "x", 0, 1, "bad"), + UnicodeDecodeError("utf-8", b"x", 0, 1, "bad"), + ], +) +def test_install_directories_wraps_exceptions(exc): + d = DummyDirectory(exc=exc, source_directory="/src") + dirs = {"/tmp/d": d} + + with pytest.raises(errors.FSInstallationFailedError) as e: + _install_directories(dirs, dry_run=False) + + msg = str(e.value) + assert "/tmp/d" in msg + assert "/src" in msg + + +# ---- update_files --------------------------------------------------------- + + +def test_update_files_success_updates_store_and_removes_stale_files(monkeypatch): + # Prepare common files/dirs + common_file = DummyFile(result=True) + common_dir = DummyDirectory( + checked=["/etc/app/config.d/a.conf"], + changed=["/etc/app/config.d/a.conf"], + source_directory="/src/config.d", + ) + + # Module with its own file + mod_file = DummyFile(result=True) + m = DummyModule( + name="mod1", + file_map={"/etc/app/mod1.conf": mod_file}, + dir_map={}, + file_vars={"FOO": "bar"}, + ) + + # Store already has some files, including one stale file + store = DummyStore( + {"all_files": ["/etc/app/common.conf", "/etc/app/mod1.conf", "/etc/app/stale.conf"]} + ) + + removed = [] + + def fake_remove(path): + removed.append(path) + + monkeypatch.setattr(os, "remove", fake_remove) + + # Run + ok = update_files( + store=store, + modules={m}, + files={"/etc/app/common.conf": common_file}, + directories={"/etc/app/config.d": common_dir}, + dry_run=False, + ) + + assert ok is True + + # common + dir content + module file were re-checked + assert set(store["all_files"]) == { + "/etc/app/common.conf", + "/etc/app/config.d/a.conf", + "/etc/app/mod1.conf", + } + + # stale file should be removed + assert removed == ["/etc/app/stale.conf"] + + # module marked changed because its file changed + assert m._changed is True + + # copy_to called for all files with correct dry_run flag + assert common_file.calls == [("/etc/app/common.conf", None, False)] + assert mod_file.calls == [("/etc/app/mod1.conf", {"FOO": "bar"}, False)] + + +def test_update_files_dry_run_does_not_touch_store_or_remove(monkeypatch): + common_file = DummyFile(result=True) + common_dir = DummyDirectory( + checked=["/etc/app/config.d/a.conf"], + changed=["/etc/app/config.d/a.conf"], + source_directory="/src/config.d", + ) + m = DummyModule( + name="mod1", + file_map={"/etc/app/mod1.conf": DummyFile(result=True)}, + dir_map={}, + ) + + store = DummyStore({"all_files": ["/etc/app/common.conf", "/etc/app/stale.conf"]}) + removed = [] + + def fake_remove(path): + removed.append(path) + + monkeypatch.setattr(os, "remove", fake_remove) + + ok = update_files( + store=store, + modules={m}, + files={"/etc/app/common.conf": common_file}, + directories={"/etc/app/config.d": common_dir}, + dry_run=True, + ) + + assert ok is True + + # Store unchanged + assert store["all_files"] == ["/etc/app/common.conf", "/etc/app/stale.conf"] + + # No removals + assert removed == [] + + # copy_to called with dry_run=True + assert common_file.calls == [("/etc/app/common.conf", None, True)] + + +def test_update_files_propagates_fsinstallation_error_and_does_not_modify_store(monkeypatch): + # Use real store.Store to ensure interface compatibility if you prefer + store = DummyStore({"all_files": ["/etc/app/keep.conf"]}) + + # Fake failing _install_files + def failing_install_files(*args, **kwargs): + raise errors.FSInstallationFailedError("/etc/app/broken.conf", "content", "fail") + + # Capture deletes + removed = [] + + def fake_remove(path): + removed.append(path) + + # Spy on output error/traceback so they exist but don't blow up + error_msgs = [] + + def fake_print_error(msg): + error_msgs.append(msg) + + traces = [] + + def fake_print_traceback(): + traces.append(True) + + import decman.core.file_manager as fm_mod + + monkeypatch.setattr(fm_mod, "_install_files", failing_install_files) + monkeypatch.setattr(os, "remove", fake_remove) + monkeypatch.setattr(output, "print_error", fake_print_error) + monkeypatch.setattr(output, "print_traceback", fake_print_traceback) + + ok = update_files( + store=store, + modules=set(), + files={"/etc/app/broken.conf": DummyFile()}, + directories={}, + dry_run=False, + ) + + assert ok is False + + # Store unchanged + assert store["all_files"] == ["/etc/app/keep.conf"] + + # No deletions attempted + assert removed == [] + + # Error and traceback were logged + assert error_msgs + assert traces diff --git a/tests/test_decman_core_fs.py b/tests/test_decman_core_fs.py index 85384f2..f8f6af9 100644 --- a/tests/test_decman_core_fs.py +++ b/tests/test_decman_core_fs.py @@ -151,19 +151,21 @@ def test_directory_copy_to_creates_and_is_idempotent(tmp_path: Path) -> None: ) # First run: both fs should be created and reported as changed - changed1 = d.copy_to(str(dst_dir), variables={"{{X}}": "1"}) + checked1, changed1 = d.copy_to(str(dst_dir), variables={"{{X}}": "1"}) expected_paths = { str(dst_dir / "a.txt"), str(dst_dir / "sub" / "b.txt"), } assert set(changed1) == expected_paths + assert set(checked1) == expected_paths assert (dst_dir / "a.txt").read_text(encoding="utf-8") == "A=1" assert (dst_dir / "sub" / "b.txt").read_text(encoding="utf-8") == "B=1" # Second run with same variables: no fs should be reported as changed - changed2 = d.copy_to(str(dst_dir), variables={"{{X}}": "1"}) + checked2, changed2 = d.copy_to(str(dst_dir), variables={"{{X}}": "1"}) assert changed2 == [] + assert set(checked2) == expected_paths def test_directory_copy_to_detects_changes_via_variables(tmp_path: Path) -> None: @@ -175,14 +177,14 @@ def test_directory_copy_to_detects_changes_via_variables(tmp_path: Path) -> None d = fs.Directory(source_directory=str(src_dir)) # Initial materialization - changed1 = d.copy_to(str(dst_dir), variables={"{{X}}": "alpha"}) + _checked, changed1 = d.copy_to(str(dst_dir), variables={"{{X}}": "alpha"}) assert set(changed1) == { str(dst_dir / "a.txt"), str(dst_dir / "sub" / "b.txt"), } # Change variables -> both fs change - changed2 = d.copy_to(str(dst_dir), variables={"{{X}}": "beta"}) + _checked, changed2 = d.copy_to(str(dst_dir), variables={"{{X}}": "beta"}) assert set(changed2) == { str(dst_dir / "a.txt"), str(dst_dir / "sub" / "b.txt"), @@ -207,7 +209,7 @@ def test_directory_copy_to_dry_run(tmp_path: Path) -> None: before_a = (dst_dir / "a.txt").read_text(encoding="utf-8") before_b = (dst_dir / "sub" / "b.txt").read_text(encoding="utf-8") - changed_dry = d.copy_to( + _checked, changed_dry = d.copy_to( str(dst_dir), variables={"{{X}}": "2"}, dry_run=True, @@ -234,7 +236,7 @@ def test_directory_copy_to_restores_working_directory(tmp_path: Path) -> None: original_cwd = os.getcwd() try: - changed = d.copy_to(str(dst_dir), variables={"{{X}}": "x"}) + _checked, changed = d.copy_to(str(dst_dir), variables={"{{X}}": "x"}) assert set(changed) == { str(dst_dir / "a.txt"), str(dst_dir / "sub" / "b.txt"), @@ -242,3 +244,28 @@ def test_directory_copy_to_restores_working_directory(tmp_path: Path) -> None: finally: # Ensure the implementation restored CWD assert os.getcwd() == original_cwd + + +def test_file_copy_to_dry_run(tmp_path): + target = tmp_path / "file.txt" + f = fs.File(content="hello", permissions=0o600) + + # 1) Dry-run on non-existent file: would create -> returns True, no file written + assert not target.exists() + changed = f.copy_to(str(target), dry_run=True) + assert changed is True + assert not target.exists() + + # 2) Actually create the file + changed_real = f.copy_to(str(target), dry_run=False) + assert changed_real is True + assert target.exists() + assert target.read_text(encoding="utf-8") == "hello" + + # 3) Dry-run with same desired content: would NOT modify -> returns False, file unchanged + mtime_before = target.stat().st_mtime + changed_again = f.copy_to(str(target), dry_run=True) + assert changed_again is False + assert target.read_text(encoding="utf-8") == "hello" + # mtime must not change in dry-run + assert target.stat().st_mtime == mtime_before diff --git a/tests/test_decman_core_store.py b/tests/test_decman_core_store.py new file mode 100644 index 0000000..e895913 --- /dev/null +++ b/tests/test_decman_core_store.py @@ -0,0 +1,103 @@ +import json +from pathlib import Path + +import pytest + +from decman.core.store import Store + + +def test_store_initially_empty_when_file_missing(tmp_path: Path) -> None: + path = tmp_path / "store.json" + assert not path.exists() + + store = Store(path) + + assert store.get("missing") is None + with pytest.raises(KeyError): + _ = store["missing"] + + +def test_store_loads_existing_file(tmp_path: Path) -> None: + path = tmp_path / "store.json" + original = {"foo": "bar", "number": 123} + path.write_text(json.dumps(original), encoding="utf-8") + + store = Store(path) + + assert store["foo"] == "bar" + assert store["number"] == 123 + # underlying representation is dict-like + assert json.loads(path.read_text(encoding="utf-8")) == original + + +def test_setitem_and_getitem_roundtrip(tmp_path: Path) -> None: + path = tmp_path / "store.json" + + store = Store(path) + store["foo"] = "bar" + store["number"] = 123 + + assert store["foo"] == "bar" + assert store["number"] == 123 + + +def test_get_with_default(tmp_path: Path) -> None: + path = tmp_path / "store.json" + + store = Store(path) + store["present"] = "value" + + assert store.get("present") == "value" + assert store.get("missing") is None + assert store.get("missing", "default") == "default" + + +def test_save_creates_parent_directory_and_persists(tmp_path: Path) -> None: + # use nested directory to ensure parent creation is exercised + path = tmp_path / "nested" / "store.json" + + store = Store(path) + store["foo"] = "bar" + + store.save() + + assert path.is_file() + data = json.loads(path.read_text(encoding="utf-8")) + assert data == {"foo": "bar"} + + +def test_context_manager_saves_on_normal_exit(tmp_path: Path) -> None: + path = tmp_path / "store.json" + + with Store(path) as store: + store["foo"] = "bar" + store["number"] = 123 + + assert path.is_file() + data = json.loads(path.read_text(encoding="utf-8")) + assert data == {"foo": "bar", "number": 123} + + +def test_context_manager_saves_even_on_exception(tmp_path: Path) -> None: + path = tmp_path / "store.json" + + with pytest.raises(RuntimeError): + with Store(path) as store: + store["foo"] = "bar" + raise RuntimeError("boom") + + # file should still be written despite the exception + assert path.is_file() + data = json.loads(path.read_text(encoding="utf-8")) + assert data == {"foo": "bar"} + + +def test_repr_matches_underlying_dict(tmp_path: Path) -> None: + path = tmp_path / "store.json" + + store = Store(path) + store["foo"] = "bar" + store["number"] = 123 + + expected = repr({"foo": "bar", "number": 123}) + assert repr(store) == expected