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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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