mirror of
https://github.com/kiviktnm/decman.git
synced 2026-09-19 12:08:28 +00:00
Implement file installation
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user