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
+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
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
+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