Implement file installation

This commit is contained in:
Kivi Kaitaniemi
2025-12-13 04:38:58 +02:00
parent aaa4a0ad43
commit 786968feac
10 changed files with 787 additions and 34 deletions
+11
View File
@@ -12,6 +12,17 @@ class SourceError(Exception):
super().__init__(message) 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): class InvalidOnDisableError(Exception):
""" """
Error raised when trying to create a Module with an invalid on_disable method. Error raised when trying to create a Module with an invalid on_disable method.
+158
View File
@@ -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
+35 -25
View File
@@ -91,7 +91,9 @@ class File:
except KeyError as error: except KeyError as error:
raise errors.GroupNotFoundError(group) from 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. 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. writing. Ignored for binary files and when ``bin_file`` is True.
Returns: 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. False if the existing file already contained the desired contents.
Raises: Raises:
@@ -137,25 +139,28 @@ class File:
assert gid is not None, "If uid is set, then gid is set." assert gid is not None, "If uid is set, then gid is set."
os.chown(dirct, uid, gid) 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." assert self.gid is not None, "If uid is set, then gid is set."
os.chown(target, self.uid, self.gid) os.chown(target, self.uid, self.gid)
os.chmod(target, self.permissions) if not dry_run:
os.chmod(target, self.permissions)
return changed 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) # 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 self.source_file is not None and (self.bin_file or len(variables) == 0):
if os.path.exists(target): if os.path.exists(target):
with open(self.source_file, "rb") as src, open(target, "rb") as dst: with open(self.source_file, "rb") as src, open(target, "rb") as dst:
if src.read() == dst.read(): if src.read() == dst.read():
return False return False
shutil.copy(self.source_file, target) if not dry_run:
shutil.copy(self.source_file, target)
return True return True
# Case 2: binary content from memory # Case 2: binary content from memory
@@ -165,8 +170,9 @@ class File:
with open(target, "rb") as file: with open(target, "rb") as file:
if file.read() == desired_bytes: if file.read() == desired_bytes:
return False return False
with open(target, "wb") as file: if not dry_run:
file.write(desired_bytes) with open(target, "wb") as file:
file.write(desired_bytes)
return True return True
# From here on: text modes with possible substitutions # From here on: text modes with possible substitutions
@@ -184,8 +190,9 @@ class File:
if file.read() == content: if file.read() == content:
return False return False
with open(target, "wt", encoding=self.encoding) as file: if not dry_run:
file.write(content) with open(target, "wt", encoding=self.encoding) as file:
file.write(content)
return True return True
# Case 4: text content from in-memory string with substitutions # Case 4: text content from in-memory string with substitutions
@@ -199,8 +206,9 @@ class File:
if file.read() == content: if file.read() == content:
return False return False
with open(target, "wt", encoding=self.encoding) as file: if not dry_run:
file.write(content) with open(target, "wt", encoding=self.encoding) as file:
file.write(content)
return True return True
@@ -274,7 +282,7 @@ class Directory:
target_directory: str, target_directory: str,
variables: typing.Optional[dict[str, str]] = None, variables: typing.Optional[dict[str, str]] = None,
dry_run: bool = False, 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. 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. *would* be processed is returned.
Returns: Returns:
list[str] tuple[list[str], list[str]]
When ``dry_run`` is ``False``, paths of files that were created or whose contents The first list contains always every file in the source, the second list depends on
were modified. ``dry_run``
When ``dry_run`` is ``True``, paths of all files that would be considered for When ``dry_run`` is ``False``, the second list contains paths of files that were
creation or modification (no changes are actually performed). 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: Raises:
OSError OSError
@@ -312,6 +323,7 @@ class Directory:
UnicodeEncodeError UnicodeEncodeError
If text content cannot be encoded using ``encoding``. If text content cannot be encoded using ``encoding``.
""" """
checked = []
changed_or_created = [] changed_or_created = []
original_wd = os.getcwd() original_wd = os.getcwd()
try: try:
@@ -328,13 +340,11 @@ class Directory:
permissions=self.permissions, permissions=self.permissions,
) )
target = os.path.normpath(os.path.join(target_directory, src_path)) 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) changed_or_created.append(target)
else:
if file.copy_to(target, variables):
changed_or_created.append(target)
finally: finally:
os.chdir(original_wd) os.chdir(original_wd)
return changed_or_created return checked, changed_or_created
+20
View File
@@ -71,6 +71,26 @@ class Module:
imports. If you must use imports, define them inside this method. 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: def __hash__(self) -> int:
return hash(self.name) return hash(self.name)
+9
View File
@@ -1,6 +1,7 @@
import os import os
import shutil import shutil
import sys import sys
import traceback
import typing import typing
import decman.config as config import decman.config as config
@@ -94,6 +95,14 @@ def print_error(error_msg: str):
print(f"{_tag()} {_red('ERROR')}: {error_msg}") 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): def print_warning(msg: str):
""" """
Prints a warning to the user. Prints a warning to the user.
+64
View File
@@ -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)
+10 -3
View File
@@ -1,6 +1,7 @@
import importlib.metadata as metadata import importlib.metadata as metadata
import decman.core.module as module import decman.core.module as module
import decman.core.store as cstore
class Plugin: class Plugin:
@@ -23,14 +24,20 @@ class Plugin:
""" """
return True 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. Ensures that the state managed by this plugin is present.
Set ``dry_run`` to only print changes applying this plugin would cause. 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. Processes a module.
""" """
+344
View File
@@ -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 = "<src>",
):
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
+33 -6
View File
@@ -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 # 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 = { expected_paths = {
str(dst_dir / "a.txt"), str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"), str(dst_dir / "sub" / "b.txt"),
} }
assert set(changed1) == expected_paths 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 / "a.txt").read_text(encoding="utf-8") == "A=1"
assert (dst_dir / "sub" / "b.txt").read_text(encoding="utf-8") == "B=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 # 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 changed2 == []
assert set(checked2) == expected_paths
def test_directory_copy_to_detects_changes_via_variables(tmp_path: Path) -> None: 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)) d = fs.Directory(source_directory=str(src_dir))
# Initial materialization # 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) == { assert set(changed1) == {
str(dst_dir / "a.txt"), str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"), str(dst_dir / "sub" / "b.txt"),
} }
# Change variables -> both fs change # 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) == { assert set(changed2) == {
str(dst_dir / "a.txt"), str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.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_a = (dst_dir / "a.txt").read_text(encoding="utf-8")
before_b = (dst_dir / "sub" / "b.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), str(dst_dir),
variables={"{{X}}": "2"}, variables={"{{X}}": "2"},
dry_run=True, dry_run=True,
@@ -234,7 +236,7 @@ def test_directory_copy_to_restores_working_directory(tmp_path: Path) -> None:
original_cwd = os.getcwd() original_cwd = os.getcwd()
try: 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) == { assert set(changed) == {
str(dst_dir / "a.txt"), str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"), str(dst_dir / "sub" / "b.txt"),
@@ -242,3 +244,28 @@ def test_directory_copy_to_restores_working_directory(tmp_path: Path) -> None:
finally: finally:
# Ensure the implementation restored CWD # Ensure the implementation restored CWD
assert os.getcwd() == original_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
+103
View File
@@ -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