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)
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.
+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
+26 -16
View File
@@ -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,24 +139,27 @@ class File:
assert gid is not None, "If uid is set, then gid is set."
os.chown(dirct, uid, 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)
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
if not dry_run:
shutil.copy(self.source_file, target)
return True
@@ -165,6 +170,7 @@ class File:
with open(target, "rb") as file:
if file.read() == desired_bytes:
return False
if not dry_run:
with open(target, "wb") as file:
file.write(desired_bytes)
return True
@@ -184,6 +190,7 @@ class File:
if file.read() == content:
return False
if not dry_run:
with open(target, "wt", encoding=self.encoding) as file:
file.write(content)
return True
@@ -199,6 +206,7 @@ class File:
if file.read() == content:
return False
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:
changed_or_created.append(target)
else:
if file.copy_to(target, variables):
if file.copy_to(target, variables, dry_run):
changed_or_created.append(target)
finally:
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.
"""
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)
+9
View File
@@ -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.
+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 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.
"""
+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