diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9b38853 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/README.md b/README.md index b415c7f..f5c175f 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,29 @@ when OverlayFS is active, it requests a writable reboot before persisting those files and then restores the configured overlay state. Raspberry Pi Desktop/PCManFM, XFCE, and GNOME are supported. +Python packages are installed into /data/nirj/python-venv. Package versions +must be exact. Packages are installed from PyPI as wheels; direct URLs, Git +repositories, editable installs, and arbitrary pip options are not accepted. + +Once that environment exists, the agent configures the existing `/home/jam` +account to use it in Bash and desktop login sessions, and sets VS Code's +`python.defaultInterpreterPath` to `/data/nirj/python-venv/bin/python`. +Log out and back in after the first application to pick up the session PATH. +The Python extension must be installed in VS Code; workspaces with an already +selected interpreter may need **Python: Select Interpreter** once. Explicitly +activated virtual environments take precedence in new shells. System Python +at `/usr/bin/python3` is unchanged. Existing shell configuration, VS Code +settings (including comments), and file ownership are preserved. + +Package operation failures are logged and recorded in the agent state's +`errors` list. Independent APT/Python operations continue, and shortcuts are +only created for applications confirmed installed. A partial update leaves +`ready: false` and does not promote the target manifest to current. The agent +still starts, with the root left writable if an update required disabling +OverlayFS. Retry with `nirj-agent update apply` or restart the service after +fixing the package/repository issue; there is no periodic retry loop. +Explicit apply commands return a failure exit status for partial updates. + The manifest can also place agent-managed application launchers on the `jam` user's desktop. Each shortcut must include its corresponding APT package: @@ -108,7 +131,11 @@ schema: 1 apt: packages: - code + - python3-venv - sonic-pi +python: + packages: + jamkit: "0.1.0" desktop: shortcuts: - vscode diff --git a/pyproject.toml b/pyproject.toml index d36daf0..992c311 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.11" dependencies = [ "Pillow>=11.0,<12", "PyYAML>=6.0,<7", + "packaging>=24,<27", ] [project.optional-dependencies] diff --git a/src/nirj_agent/cli/main.py b/src/nirj_agent/cli/main.py index c0f03a7..ebd5c85 100644 --- a/src/nirj_agent/cli/main.py +++ b/src/nirj_agent/cli/main.py @@ -21,7 +21,12 @@ ) from nirj_agent.manifests.github import GitHubManifestClient, ManifestDownloadError from nirj_agent.manifests.parser import ManifestError -from nirj_agent.providers import AptProvider, AptProviderError +from nirj_agent.providers import ( + AptProvider, + AptProviderError, + PipProvider, + PipProviderError, +) from nirj_agent.services.apply import ApplyError, apply_manifest from nirj_agent.services.boot import boot_prep from nirj_agent.services.desktop import ( @@ -49,6 +54,7 @@ EXPECTED_ERRORS = ( ApplyError, AptProviderError, + PipProviderError, ConfigError, FileStoreError, JsonStoreError, @@ -180,6 +186,7 @@ def main(argv: Sequence[str] | None = None) -> int: paths=paths, client=GitHubManifestClient(), package_provider=AptProvider(), + python_provider=PipProvider(paths.python_environment), overlay=OverlayManager(), ) print(json.dumps(asdict(result), indent=2)) @@ -197,9 +204,12 @@ def main(argv: Sequence[str] | None = None) -> int: paths=paths, client=GitHubManifestClient(), package_provider=AptProvider(), + python_provider=PipProvider(paths.python_environment), overlay=OverlayManager(), ) print(json.dumps(asdict(result), indent=2)) + if result.action == "update_failed": + return 1 return 194 if result.reboot_requested else 0 if args.command == "overlay": @@ -225,26 +235,45 @@ def main(argv: Sequence[str] | None = None) -> int: return _watch_wallpaper(paths) if args.command == "plan": - plan = create_plan(paths=paths, package_provider=AptProvider()) - print(json.dumps({ - "changes_required": plan.changes_required, - "install": plan.install, - "remove": plan.remove, - "unchanged": plan.unchanged, - }, indent=2)) + plan = create_plan( + paths=paths, + package_provider=AptProvider(), + python_provider=PipProvider(paths.python_environment), + ) + + print( + json.dumps( + { + "changes_required": plan.changes_required, + "apt": asdict(plan.apt), + "python": asdict(plan.python), + }, + indent=2, + ) + ) return 0 if args.command == "apply": if not _require_root(args.root, "Package application"): return 1 - result = apply_manifest(paths=paths, package_provider=AptProvider()) - print(json.dumps({ - "manifest_hash": result.state.manifest_hash, - "last_apply": result.state.last_apply, - "install": result.plan.install, - "remove": result.plan.remove, - "ready": result.state.ready, - }, indent=2)) + result = apply_manifest( + paths=paths, + package_provider=AptProvider(), + python_provider=PipProvider(paths.python_environment), + ) + print( + json.dumps( + { + "manifest_hash": result.state.manifest_hash, + "last_apply": result.state.last_apply, + "apt": asdict(result.plan.apt), + "python": asdict(result.plan.python), + "ready": result.state.ready, + }, + indent=2, + ) + ) + return 0 if args.command == "manifest" and args.manifest_command == "refresh": @@ -258,7 +287,8 @@ def main(argv: Sequence[str] | None = None) -> int: "sha256": document.sha256, "source": document.source_url, "cache": str(paths.manifest_cache), - "packages": len(document.manifest.apt.packages), + "apt_packages": len(document.manifest.apt.packages), + "python_packages": len(document.manifest.python.packages), }, indent=2)) return 0 except EXPECTED_ERRORS as exc: diff --git a/src/nirj_agent/manifests/__init__.py b/src/nirj_agent/manifests/__init__.py index 81cc967..af03b6a 100644 --- a/src/nirj_agent/manifests/__init__.py +++ b/src/nirj_agent/manifests/__init__.py @@ -4,6 +4,7 @@ DesktopManifest, Manifest, ManifestDocument, + PythonManifest, ) from .parser import ManifestError, load_manifest, parse_manifest @@ -16,4 +17,5 @@ "SUPPORTED_DESKTOP_SHORTCUTS", "load_manifest", "parse_manifest", + "PythonManifest", ] diff --git a/src/nirj_agent/manifests/models.py b/src/nirj_agent/manifests/models.py index d9c2fdf..cdc2446 100644 --- a/src/nirj_agent/manifests/models.py +++ b/src/nirj_agent/manifests/models.py @@ -10,6 +10,11 @@ class AptManifest: packages: tuple[str, ...] +@dataclass(frozen=True) +class PythonManifest: + packages: tuple[tuple[str, str], ...] + + @dataclass(frozen=True) class DesktopManifest: shortcuts: tuple[str, ...] @@ -19,10 +24,12 @@ class DesktopManifest: class Manifest: schema: int apt: AptManifest + python: PythonManifest desktop: DesktopManifest overlay_enabled: bool background_enabled: bool + @dataclass(frozen=True) class ManifestDocument: manifest: Manifest diff --git a/src/nirj_agent/manifests/parser.py b/src/nirj_agent/manifests/parser.py index ad4f3b7..594328e 100644 --- a/src/nirj_agent/manifests/parser.py +++ b/src/nirj_agent/manifests/parser.py @@ -1,15 +1,18 @@ +import re from collections.abc import Mapping from pathlib import Path -import re from typing import Any import yaml +from packaging.utils import InvalidName, canonicalize_name +from packaging.version import InvalidVersion, Version from .models import ( SUPPORTED_DESKTOP_SHORTCUTS, AptManifest, DesktopManifest, Manifest, + PythonManifest, ) @@ -17,9 +20,11 @@ r"^[a-z0-9][a-z0-9+.-]*(?::[a-z0-9][a-z0-9-]*)?$" ) + class ManifestError(ValueError): pass + def parse_manifest(content: bytes, source: str = "") -> Manifest: try: text = content.decode("utf-8") @@ -35,6 +40,7 @@ def parse_manifest(content: bytes, source: str = "") -> Manifest: return manifest_from_mapping(data, source) + def load_manifest(path: Path) -> Manifest: try: content = path.read_bytes() @@ -43,8 +49,7 @@ def load_manifest(path: Path) -> Manifest: return parse_manifest(content, str(path)) - - + def manifest_from_mapping(data: object, source: str) -> Manifest: root = require_mapping(data, "manifest", source) @@ -59,7 +64,7 @@ def manifest_from_mapping(data: object, source: str) -> Manifest: raise ManifestError( f"Unsupported manifest schema from {source}: {schema}" ) - + apt = require_mapping(root.get("apt", {}), "apt", source) packages = apt.get("packages", []) @@ -71,6 +76,51 @@ def manifest_from_mapping(data: object, source: str) -> Manifest: f"apt.packages in {source} must be a list of package names" ) + python = require_mapping(root.get("python", {}), "python", source) + raw_python_packages = require_mapping( + python.get("packages", {}), + "python.packages", + source, + ) + + python_packages: dict[str, str] = {} + + for raw_name, raw_version in raw_python_packages.items(): + if not isinstance(raw_name, str) or not raw_name.strip(): + raise ManifestError( + f"python.packages in {source} contains an invalid package name" + ) + + if not isinstance(raw_version, str) or not raw_version.strip(): + raise ManifestError( + f"Python package {raw_name!r} in {source} must have an " + "exact version" + ) + + try: + name = canonicalize_name(raw_name, validate=True) + except InvalidName as exc: + raise ManifestError( + f"python.packages in {source} contains invalid package " + f"name {raw_name!r}" + ) from exc + + try: + version = str(Version(raw_version)) + except InvalidVersion as exc: + raise ManifestError( + f"Python package {raw_name!r} in {source} has invalid " + f"version {raw_version!r}" + ) from exc + + if name in python_packages: + raise ManifestError( + f"python.packages in {source} contains duplicate normalized " + f"package name {name!r}" + ) + + python_packages[name] = version + desktop = require_mapping(root.get("desktop", {}), "desktop", source) shortcuts = desktop.get("shortcuts", []) @@ -103,6 +153,9 @@ def manifest_from_mapping(data: object, source: str) -> Manifest: enforce=read_boolean(apt, "enforce", False, source), packages=tuple(dict.fromkeys(packages)), ), + python=PythonManifest( + packages=tuple(sorted(python_packages.items())), + ), desktop=DesktopManifest( shortcuts=tuple(dict.fromkeys(shortcuts)), ), @@ -114,6 +167,7 @@ def manifest_from_mapping(data: object, source: str) -> Manifest: ), ) + def require_mapping( value: object, field: str, diff --git a/src/nirj_agent/providers/__init__.py b/src/nirj_agent/providers/__init__.py index d1d89a1..7ed9b1c 100644 --- a/src/nirj_agent/providers/__init__.py +++ b/src/nirj_agent/providers/__init__.py @@ -1,5 +1,11 @@ """System integration providers.""" from .apt import AptProvider, AptProviderError +from .pip import PipProvider, PipProviderError -__all__ = ["AptProvider", "AptProviderError"] +__all__ = [ + "AptProvider", + "AptProviderError", + "PipProvider", + "PipProviderError", +] diff --git a/src/nirj_agent/providers/pip.py b/src/nirj_agent/providers/pip.py new file mode 100644 index 0000000..6ba8104 --- /dev/null +++ b/src/nirj_agent/providers/pip.py @@ -0,0 +1,159 @@ +import json +import subprocess +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from packaging.utils import canonicalize_name + + +class PipProviderError(RuntimeError): + pass + + +class PipProvider: + def __init__( + self, + environment: Path, + runner: Callable[..., Any] = subprocess.run, + base_python: str = "/usr/bin/python3", + command_timeout: int = 1800, + ) -> None: + self.environment = environment + self.runner = runner + self.base_python = base_python + self.command_timeout = command_timeout + + @property + def python(self) -> Path: + return self.environment / "bin/python" + + def list_installed(self) -> dict[str, str]: + if not self.python.exists(): + return {} + + result = self._run( + [ + str(self.python), + "-m", + "pip", + "--disable-pip-version-check", + "list", + "--format=json", + ], + "Python package query", + timeout=30, + ) + + try: + packages = json.loads(result.stdout) + except (TypeError, json.JSONDecodeError) as exc: + raise PipProviderError( + "python package query returned invalid JSON" + ) from exc + + if not isinstance(packages, list): + raise PipProviderError( + "Python package query returned an invalid package list" + ) + + installed: dict[str, str] = {} + + for package in packages: + if not isinstance(package, dict): + raise PipProviderError( + "Python package query returned an invalid package entry" + ) + + name = package.get("name") + version = package.get("version") + + if not isinstance(name, str) or not isinstance(version, str): + raise PipProviderError( + "Python package query returned an invalid package entry" + ) + + installed[canonicalize_name(name)] = version + + return installed + + def install(self, requirements: tuple[str, ...]) -> None: + if not requirements: + return + + self.ensure_environment() + self._run( + [ + str(self.python), + "-m", + "pip", + "--disable-pip-version-check", + "install", + "--only-binary=:all:", + *requirements, + ], + "Python package installation", + ) + + def remove(self, packages: tuple[str, ...]) -> None: + if not packages or not self.python.exists(): + return + + self._run( + [ + str(self.python), + "-m", + "pip", + "--disable-pip-version-check", + "uninstall", + "--yes", + *packages, + ], + "Python package removal", + ) + + def ensure_environment(self) -> None: + if self.python.exists(): + return + + self.environment.parent.mkdir(parents=True, exist_ok=True) + + self._run( + [ + self.base_python, + "-m", + "venv", + str(self.environment), + ], + "Python environment creation", + ) + + if not self.python.exists(): + raise PipProviderError( + f"Python environment creation did not produce {self.python}" + ) + + def _run( + self, + command: list[str], + operation: str, + timeout: int | None = None, + ) -> Any: + try: + result = self.runner( + command, + check=False, + capture_output=True, + text=True, + timeout=timeout or self.command_timeout, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise PipProviderError( + f"Unable to run {operation}: {exc}" + ) from exc + + if result.returncode != 0: + error = result.stderr.strip() or f"exit code {result.returncode}" + raise PipProviderError(f"{operation} failed: {error}") + + return result diff --git a/src/nirj_agent/services/__init__.py b/src/nirj_agent/services/__init__.py index 7abefe4..7820a83 100644 --- a/src/nirj_agent/services/__init__.py +++ b/src/nirj_agent/services/__init__.py @@ -1,15 +1,26 @@ """Application orchestration services.""" from .apply import ApplyError, ApplyResult, apply_manifest +from .manifest import refresh_manifest from .plan import PlanError, create_plan -from .reconciliation import PackagePlan, build_package_plan +from .reconciliation import ( + PackagePlan, + PythonPackagePlan, + ReconciliationPlan, + build_package_plan, + build_python_package_plan, +) __all__ = [ "ApplyError", "ApplyResult", "PackagePlan", "PlanError", + "PythonPackagePlan", + "ReconciliationPlan", "apply_manifest", "build_package_plan", + "build_python_package_plan", "create_plan", + "refresh_manifest", ] diff --git a/src/nirj_agent/services/apply.py b/src/nirj_agent/services/apply.py index 082e058..996bc24 100644 --- a/src/nirj_agent/services/apply.py +++ b/src/nirj_agent/services/apply.py @@ -1,4 +1,5 @@ import hashlib +import logging from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timezone @@ -6,19 +7,32 @@ from nirj_agent.config import DeviceType, load_config from nirj_agent.manifests import parse_manifest +from nirj_agent.providers import AptProviderError, PipProviderError from nirj_agent.state import AgentState, load_state, save_state from nirj_agent.storage.files import read_bytes from nirj_agent.storage.lock import exclusive_lock from nirj_agent.storage.paths import AgentPaths from .desktop_shortcuts import reconcile_desktop_shortcuts -from .reconciliation import PackagePlan, build_package_plan +from .python_setup import reconcile_python_setup +from .reconciliation import ( + ReconciliationPlan, + build_package_plan, + build_python_package_plan, +) class ApplyError(RuntimeError): pass +class PartialApplyError(ApplyError): + """Independent operations finished, but the target is not fully applied.""" + + +logger = logging.getLogger(__name__) + + class PackageApplyProvider(Protocol): def list_installed(self) -> set[str]: ... @@ -29,15 +43,24 @@ def install(self, packages: tuple[str, ...]) -> None: ... def remove(self, packages: tuple[str, ...]) -> None: ... +class PythonPackageApplyProvider(Protocol): + def list_installed(self) -> dict[str, str]: ... + + def install(self, requirements: tuple[str, ...]) -> None: ... + + def remove(self, packages: tuple[str, ...]) -> None: ... + + @dataclass(frozen=True) class ApplyResult: - plan: PackagePlan + plan: ReconciliationPlan state: AgentState def apply_manifest( paths: AgentPaths, package_provider: PackageApplyProvider, + python_provider: PythonPackageApplyProvider, clock: Callable[[], datetime] | None = None, ) -> ApplyResult: now = clock or (lambda: datetime.now(timezone.utc)) @@ -53,30 +76,112 @@ def apply_manifest( content = read_bytes(paths.manifest_cache) manifest = parse_manifest(content, str(paths.manifest_cache)) previous_state = load_state(paths.state) - installed = package_provider.list_installed() - plan = build_package_plan( + + apt_plan = build_package_plan( manifest=manifest, - installed_packages=installed, + installed_packages=package_provider.list_installed(), previously_managed_packages=set(previous_state.packages), ) - if plan.install: - package_provider.update() - package_provider.install(plan.install) + python_plan = build_python_package_plan( + manifest=manifest, + installed_packages=python_provider.list_installed(), + previously_managed_packages={ + name for name, _version in previous_state.python_packages + }, + ) - if plan.remove: - package_provider.remove(plan.remove) + plan = ReconciliationPlan( + apt=apt_plan, + python=python_plan, + ) - reconcile_desktop_shortcuts(paths, manifest.desktop.shortcuts) + errors: list[str] = [] + + def attempt(operation: Callable[[], None]) -> None: + try: + operation() + except (AptProviderError, PipProviderError) as exc: + logger.error("Manifest operation failed: %s", exc) + errors.append(str(exc)) + + # APT runs first because python3-venv may itself be a desired APT package. + if apt_plan.install: + def install_apt() -> None: + package_provider.update() + package_provider.install(apt_plan.install) + + attempt(install_apt) + + if apt_plan.remove: + attempt(lambda: package_provider.remove(apt_plan.remove)) + + apt_packages = apt_plan.desired + available_packages = set(apt_plan.desired) + if errors: + # A failed batch may have partially succeeded. Only observed packages + # can support shortcuts or be recorded as successfully installed. + try: + available_packages = package_provider.list_installed() + apt_packages = tuple(sorted( + available_packages & (set(previous_state.packages) | set(apt_plan.desired)) + )) + except AptProviderError as exc: + logger.error("Could not verify APT packages: %s", exc) + errors.append(str(exc)) + available_packages = set() + # Retain ownership for a later retry when inventory is unknown. + apt_packages = tuple(sorted(set(previous_state.packages) | set(apt_plan.desired))) + + python_error_start = len(errors) + if python_plan.install: + desired_requirements = tuple( + f"{name}=={version}" + for name, version in python_plan.desired + ) + attempt(lambda: python_provider.install(desired_requirements)) + + if python_plan.remove: + attempt(lambda: python_provider.remove(python_plan.remove)) + + python_packages = python_plan.desired + if len(errors) > python_error_start: + managed = dict(previous_state.python_packages) | dict(python_plan.desired) + try: + installed = python_provider.list_installed() + python_packages = tuple(sorted( + (name, version) for name, version in installed.items() if name in managed + )) + except PipProviderError as exc: + logger.error("Could not verify Python packages: %s", exc) + errors.append(str(exc)) + python_packages = tuple(sorted(managed.items())) + + reconcile_python_setup(paths) + required_packages = {"vscode": "code", "sonic-pi": "sonic-pi"} + shortcuts = tuple( + shortcut for shortcut in manifest.desktop.shortcuts + if required_packages[shortcut] in available_packages + ) + for shortcut in set(manifest.desktop.shortcuts) - set(shortcuts): + message = f"Skipped {shortcut} shortcut: required package is not confirmed installed" + logger.error(message) + errors.append(message) + reconcile_desktop_shortcuts(paths, shortcuts) applied_at = now().astimezone(timezone.utc) state = AgentState( - manifest_hash=hashlib.sha256(content).hexdigest(), - last_apply=applied_at.isoformat().replace("+00:00", "Z"), - packages=plan.desired, + manifest_hash=previous_state.manifest_hash if errors else hashlib.sha256(content).hexdigest(), + last_apply=previous_state.last_apply if errors else applied_at.isoformat().replace("+00:00", "Z"), + packages=apt_packages, overlay_enabled=False, ready=False, + python_packages=python_packages, + errors=tuple(errors), ) save_state(state, paths.state) + if errors: + raise PartialApplyError("; ".join(errors)) + return ApplyResult(plan=plan, state=state) diff --git a/src/nirj_agent/services/boot.py b/src/nirj_agent/services/boot.py index 58e1804..deded14 100644 --- a/src/nirj_agent/services/boot.py +++ b/src/nirj_agent/services/boot.py @@ -3,7 +3,7 @@ from nirj_agent.config import load_config from nirj_agent.manifests.github import GitHubManifestClient -from nirj_agent.providers import AptProvider +from nirj_agent.providers import AptProvider, PipProvider from nirj_agent.state import load_state, save_state from nirj_agent.storage.paths import AgentPaths from nirj_agent.update import ( @@ -18,6 +18,7 @@ reconcile_desktop_setup, ) from .overlay import OverlayManager +from .apply import PartialApplyError from .update import apply_target, check_for_update from .wallpaper import set_wallpaper_state @@ -35,6 +36,7 @@ def boot_prep( paths: AgentPaths, client: GitHubManifestClient, package_provider: AptProvider, + python_provider: PipProvider, overlay: OverlayManager, ) -> BootPrepResult: target_hash = None @@ -70,13 +72,14 @@ def boot_prep( config.background_enabled, config.device.asset_id, package_provider, + python_provider, overlay, ) _consume_overlay_disabled_once(paths, overlay_disabled_once) return result check = check_for_update(paths, client, persist_target=True) - if check.update_available: + if check.update_available or load_state(paths.state).errors: save_update_state( UpdateState(UpdatePhase.PENDING, check.target_hash), paths.update_state, @@ -98,6 +101,7 @@ def boot_prep( config.background_enabled, config.device.asset_id, package_provider, + python_provider, overlay, ) _consume_overlay_disabled_once(paths, overlay_disabled_once) @@ -132,6 +136,16 @@ def boot_prep( return BootPrepResult("disabling_overlay", True) _consume_overlay_disabled_once(paths, overlay_disabled_once) return BootPrepResult("ready", False) + except PartialApplyError as exc: + save_update_state( + UpdateState(UpdatePhase.FAILED, target_hash, str(exc)), + paths.update_state, + ) + if config is not None: + _set_wallpaper(paths, config.background_enabled, "failed", config.device.asset_id) + # Let the startup script launch the agent. Keep the target unpromoted + # and the root writable so the next update attempt can finish it. + return BootPrepResult("update_failed", False) except Exception as exc: save_update_state( UpdateState(UpdatePhase.FAILED, target_hash, str(exc)), @@ -153,6 +167,7 @@ def _apply_and_restore( background_enabled: bool, asset_code: str, package_provider: AptProvider, + python_provider: PipProvider, overlay: OverlayManager, ) -> BootPrepResult: pending = load_update_state(paths.update_state) @@ -160,7 +175,7 @@ def _apply_and_restore( replace(pending, state=UpdatePhase.APPLYING, error=None), paths.update_state, ) - apply_target(paths, package_provider) + apply_target(paths, package_provider, python_provider) save_update_state(UpdateState(), paths.update_state) _set_wallpaper(paths, background_enabled, "ready", asset_code) if overlay_desired: diff --git a/src/nirj_agent/services/desktop_setup.py b/src/nirj_agent/services/desktop_setup.py index dfdcb17..097e8f0 100644 --- a/src/nirj_agent/services/desktop_setup.py +++ b/src/nirj_agent/services/desktop_setup.py @@ -2,6 +2,7 @@ from nirj_agent.storage.files import FileStoreError, write_bytes from nirj_agent.storage.paths import AgentPaths +from .python_setup import python_setup_needs_reconcile, reconcile_python_setup AUTOSTART_CONTENT = b"""[Desktop Entry] @@ -21,6 +22,8 @@ def desktop_setup_needs_reconcile( paths: AgentPaths, enabled: bool, ) -> bool: + if python_setup_needs_reconcile(paths): + return True if not enabled: return paths.wallpaper_autostart.exists() @@ -33,6 +36,7 @@ def desktop_setup_needs_reconcile( def reconcile_desktop_setup(paths: AgentPaths, enabled: bool) -> None: try: + reconcile_python_setup(paths) if enabled: source = _read_required(paths.source_background) write_bytes(paths.base_background, source) diff --git a/src/nirj_agent/services/overlay.py b/src/nirj_agent/services/overlay.py index 3a8e269..847cd1b 100644 --- a/src/nirj_agent/services/overlay.py +++ b/src/nirj_agent/services/overlay.py @@ -33,11 +33,16 @@ def __init__( def status(self) -> OverlayStatus: filesystem = self._run(["findmnt", "-n", "-o", "FSTYPE", "/"]) - configured_result = self._run( - ["raspi-config", "nonint", "get_overlay_now"], - check=False, - ) - configured = configured_result.returncode == 0 + try: + configured_result = self._run( + ["raspi-config", "nonint", "get_overlay_now"], + check=False, + ) + configured = configured_result.returncode == 0 + except OverlayError as exc: + if not isinstance(exc.__cause__, FileNotFoundError): + raise + configured = None return OverlayStatus( active=filesystem.stdout.strip() == "overlay", configured=configured, diff --git a/src/nirj_agent/services/plan.py b/src/nirj_agent/services/plan.py index 4670f99..4a9e0a1 100644 --- a/src/nirj_agent/services/plan.py +++ b/src/nirj_agent/services/plan.py @@ -5,7 +5,11 @@ from nirj_agent.state import load_state from nirj_agent.storage.paths import AgentPaths -from .reconciliation import PackagePlan, build_package_plan +from .reconciliation import ( + ReconciliationPlan, + build_package_plan, + build_python_package_plan, +) class PlanError(RuntimeError): @@ -16,10 +20,15 @@ class InstalledPackageProvider(Protocol): def list_installed(self) -> set[str]: ... +class InstalledPythonPackageProvider(Protocol): + def list_installed(self) -> dict[str, str]: ... + + def create_plan( paths: AgentPaths, package_provider: InstalledPackageProvider, -) -> PackagePlan: + python_provider: InstalledPythonPackageProvider, +) -> ReconciliationPlan: config = load_config(paths.config) if config.device.type is DeviceType.LAPTOP_WINDOWS: @@ -29,10 +38,22 @@ def create_plan( manifest = load_manifest(paths.manifest_cache) state = load_state(paths.state) - installed = package_provider.list_installed() - return build_package_plan( + apt_plan = build_package_plan( manifest=manifest, - installed_packages=installed, + installed_packages=package_provider.list_installed(), previously_managed_packages=set(state.packages), ) + + python_plan = build_python_package_plan( + manifest=manifest, + installed_packages=python_provider.list_installed(), + previously_managed_packages={ + name for name, _version in state.python_packages + }, + ) + + return ReconciliationPlan( + apt=apt_plan, + python=python_plan, + ) diff --git a/src/nirj_agent/services/python_setup.py b/src/nirj_agent/services/python_setup.py new file mode 100644 index 0000000..8b3bfe1 --- /dev/null +++ b/src/nirj_agent/services/python_setup.py @@ -0,0 +1,125 @@ +"""Make the managed Python environment available to the classroom user.""" + +import json +import os +from pathlib import Path +import re +import shlex +import stat + +from nirj_agent.storage.files import write_bytes +from nirj_agent.storage.paths import AgentPaths + + +START = "# BEGIN NIRJ managed Python" +END = "# END NIRJ managed Python" +SETTING = "python.defaultInterpreterPath" +# Keep string literals intact when recognizing JSONC comments and punctuation. +TOKENS = re.compile(r'"(?:\\.|[^"\\])*"|//[^\n]*|/\*[\s\S]*?\*/|\s+|[^\s"{}\[\],:]+|[{}\[\],:]') + + +def _settings(content: str, interpreter: str) -> str: + tokens = [ + match for match in TOKENS.finditer(content) + if not match[0].isspace() and not match[0].startswith(("//", "/*")) + ] + normalized = "".join( + token[0] for index, token in enumerate(tokens) + if not (token[0] == "," and index + 1 < len(tokens) + and tokens[index + 1][0] in ("}", "]")) + ) + data = json.loads(normalized) + if not isinstance(data, dict): + raise ValueError("VS Code settings must be a JSON object") + depth = 0 + replacements = [] + for index, token in enumerate(tokens): + value = token[0] + if (depth == 1 and value.startswith('"') + and json.loads(value) == SETTING + and tokens[index + 1][0] == ":"): + start = index + 2 + end = start + nested = 0 + while end < len(tokens): + item = tokens[end][0] + if nested == 0 and item in (",", "}"): + break + nested += (item in ("{", "[")) - (item in ("}", "]")) + end += 1 + replacements.append((tokens[start].start(), tokens[end - 1].end())) + depth += (value in ("{", "[")) - (value in ("}", "]")) + encoded = json.dumps(interpreter) + if replacements: + for start, end in reversed(replacements): + content = content[:start] + encoded + content[end:] + return content + position = tokens[0].end() + entry = f'\n "{SETTING}": {encoded}' + ("," if data else "") + "\n" + return content[:position] + entry + content[position:] + + +def _shell(content: str, bin_dir: str) -> str: + directory = shlex.quote(bin_dir) + block = f'''{START} +if [ -x {directory}/python ] && [ -z "${{VIRTUAL_ENV:-}}" ]; then + case "$PATH" in + {directory}|{directory}:*) ;; + *) export PATH={directory}:"$PATH" ;; + esac +fi +{END} +''' + if START in content or END in content: + pattern = re.compile(r"(?m)^" + re.escape(START) + r"\n[\s\S]*?^" + re.escape(END) + r"\n?") + if content.count(START) != 1 or content.count(END) != 1 or not pattern.search(content): + raise ValueError("Malformed NIRJ managed Python block") + return pattern.sub(lambda _: block, content) + return content + ("\n" if content and not content.endswith("\n") else "") + "\n" + block + + +def _changes(paths: AgentPaths) -> list[tuple[Path, bytes]]: + home = paths.desktop_dir.parent + if not home.is_dir() or not (paths.python_environment / "bin/python").exists(): + return [] + shell_files = [home / name for name in (".profile", ".bashrc", ".xprofile")] + # Bash ignores .profile when either of these already exists. + shell_files.extend(home / name for name in (".bash_profile", ".bash_login") if (home / name).exists()) + settings = home / ".config/Code/User/settings.json" + changes = [] + for path in [*shell_files, settings]: + content = path.read_text() if path.exists() else ("{}\n" if path == settings else "") + desired = (_settings(content, str(paths.python_environment / "bin/python")) + if path == settings else _shell(content, str(paths.python_environment / "bin"))) + if content != desired: + changes.append((path, desired.encode())) + return changes + + +def python_setup_needs_reconcile(paths: AgentPaths) -> bool: + return bool(_changes(paths)) + + +def reconcile_python_setup(paths: AgentPaths) -> None: + changes = _changes(paths) + if not changes: + return + owner = paths.desktop_dir.parent.stat() + for path, content in changes: + previous = path.stat() if path.exists() else None + missing = [] + parent = path.parent + while not parent.exists(): + missing.append(parent) + parent = parent.parent + for directory in reversed(missing): + directory.mkdir(mode=0o755) + if os.geteuid() == 0: + os.chown(directory, owner.st_uid, owner.st_gid) + # Follow existing user symlinks instead of replacing them. + target = path.resolve() + write_bytes(target, content) + target.chmod(stat.S_IMODE(previous.st_mode) if previous else 0o644) + if os.geteuid() == 0: + os.chown(target, previous.st_uid if previous else owner.st_uid, + previous.st_gid if previous else owner.st_gid) diff --git a/src/nirj_agent/services/reconciliation.py b/src/nirj_agent/services/reconciliation.py index 685ef78..b8ce6a5 100644 --- a/src/nirj_agent/services/reconciliation.py +++ b/src/nirj_agent/services/reconciliation.py @@ -15,6 +15,28 @@ def changes_required(self) -> bool: return bool(self.install or self.remove) +@dataclass(frozen=True) +class PythonPackagePlan: + desired: tuple[tuple[str, str], ...] + install: tuple[str, ...] + remove: tuple[str, ...] + unchanged: tuple[tuple[str, str], ...] + + @property + def changes_required(self) -> bool: + return bool(self.install or self.remove) + + +@dataclass(frozen=True) +class ReconciliationPlan: + apt: PackagePlan + python: PythonPackagePlan + + @property + def changes_required(self) -> bool: + return self.apt.changes_required or self.python.changes_required + + def build_package_plan( manifest: Manifest, installed_packages: set[str], @@ -35,3 +57,32 @@ def build_package_plan( remove=tuple(sorted(remove)), unchanged=tuple(sorted(unchanged)), ) + + +def build_python_package_plan( + manifest: Manifest, + installed_packages: dict[str, str], + previously_managed_packages: set[str], +) -> PythonPackagePlan: + desired = dict(manifest.python.packages) + + install = tuple( + f"{name}=={version}" + for name, version in sorted(desired.items()) + if installed_packages.get(name) != version + ) + + unchanged = tuple( + (name, version) + for name, version in sorted(desired.items()) + if installed_packages.get(name) == version + ) + + remove = tuple(sorted(previously_managed_packages - desired.keys())) + + return PythonPackagePlan( + desired=tuple(sorted(desired.items())), + install=install, + remove=remove, + unchanged=unchanged, + ) diff --git a/src/nirj_agent/services/update.py b/src/nirj_agent/services/update.py index 2680100..5be0615 100644 --- a/src/nirj_agent/services/update.py +++ b/src/nirj_agent/services/update.py @@ -4,7 +4,7 @@ from nirj_agent.config import load_config from nirj_agent.manifests.github import GitHubManifestClient from nirj_agent.manifests.parser import parse_manifest -from nirj_agent.providers import AptProvider +from nirj_agent.providers import AptProvider, PipProvider from nirj_agent.services.apply import ApplyResult, apply_manifest from nirj_agent.services.manifest import refresh_manifest from nirj_agent.state import load_state, save_state @@ -50,8 +50,9 @@ def check_for_update( def apply_target( paths: AgentPaths, package_provider: AptProvider, + python_provider: PipProvider, ) -> ApplyResult: - result = apply_manifest(paths, package_provider) + result = apply_manifest(paths, package_provider, python_provider) content = read_bytes(paths.target_manifest) write_bytes(paths.current_manifest, content) ready_state = replace(result.state, ready=True) diff --git a/src/nirj_agent/services/windows.py b/src/nirj_agent/services/windows.py index 2fbbfdf..38544b3 100644 --- a/src/nirj_agent/services/windows.py +++ b/src/nirj_agent/services/windows.py @@ -60,6 +60,8 @@ def _unsupported_settings(manifest: Manifest) -> tuple[str, ...]: unsupported.append("apt.enforce") if manifest.apt.packages: unsupported.append("apt.packages") + if manifest.python.packages: + unsupported.append("python.packages") if manifest.desktop.shortcuts: unsupported.append("desktop.shortcuts") if manifest.overlay_enabled: diff --git a/src/nirj_agent/state/models.py b/src/nirj_agent/state/models.py index 66614e9..ed81e9c 100644 --- a/src/nirj_agent/state/models.py +++ b/src/nirj_agent/state/models.py @@ -8,3 +8,5 @@ class AgentState: packages: tuple[str, ...] overlay_enabled: bool ready: bool + python_packages: tuple[tuple[str, str], ...] = () + errors: tuple[str, ...] = () diff --git a/src/nirj_agent/state/store.py b/src/nirj_agent/state/store.py index 85ced7e..60c45b9 100644 --- a/src/nirj_agent/state/store.py +++ b/src/nirj_agent/state/store.py @@ -12,17 +12,32 @@ def load_state(path: Path = STATE_PATH) -> AgentState: return AgentState(None, None, (), False, False) data = read_yaml(path) + raw_python_packages = data.get("python_packages", {}) + + if not isinstance(raw_python_packages, dict): + raw_python_packages = {} + + python_packages = tuple( + sorted( + (str(name), str(version)) + for name, version in raw_python_packages.items() + ) + ) + return AgentState( manifest_hash=data.get("manifest_hash"), last_apply=data.get("last_apply"), packages=tuple(data.get("packages", [])), overlay_enabled=bool(data.get("overlay", {}).get("enabled", False)), ready=bool(data.get("ready", False)), + python_packages=python_packages, + errors=tuple(data.get("errors", [])), ) def save_state(state: AgentState, path: Path = STATE_PATH) -> None: data = asdict(state) data["packages"] = list(state.packages) + data["python_packages"] = dict(state.python_packages) data["overlay"] = {"enabled": data.pop("overlay_enabled")} write_yaml(path, data) diff --git a/src/nirj_agent/storage/paths.py b/src/nirj_agent/storage/paths.py index 015a559..d9bc6f6 100644 --- a/src/nirj_agent/storage/paths.py +++ b/src/nirj_agent/storage/paths.py @@ -20,6 +20,7 @@ class AgentPaths: source_background: Path wallpaper_autostart: Path desktop_dir: Path + python_environment: Path @classmethod def system(cls) -> "AgentPaths": @@ -64,6 +65,7 @@ def system(cls) -> "AgentPaths": "/etc/xdg/autostart/nirj-wallpaper.desktop" ), desktop_dir=Path("/home/jam/Desktop"), + python_environment=Path("/data/nirj/python-venv"), ) @classmethod @@ -84,6 +86,7 @@ def windows(cls, root: Path, public_desktop: Path) -> "AgentPaths": source_background=root / "agent-repo/assets/background-base.png", wallpaper_autostart=root / "config/wallpaper", desktop_dir=public_desktop, + python_environment=root / "python-venv", ) @classmethod @@ -113,6 +116,7 @@ def sandbox(cls, root: Path) -> "AgentPaths": root / "etc/xdg/autostart/nirj-wallpaper.desktop" ), desktop_dir=root / "home/jam/Desktop", + python_environment=data_root / "python-venv", ) diff --git a/tests/manifests/test_parser.py b/tests/manifests/test_parser.py index 0577711..63ce61d 100644 --- a/tests/manifests/test_parser.py +++ b/tests/manifests/test_parser.py @@ -13,6 +13,10 @@ - code - thonny - git +python: + packages: + jamkit: "0.1.0" + Requests: "2.32.5" overlay: enabled: false background: @@ -29,6 +33,10 @@ def test_parse_manifest_from_bytes() -> None: assert manifest.schema == 1 assert manifest.apt.enforce is True assert manifest.apt.packages == ("code", "thonny", "git") + assert manifest.python.packages == ( + ("jamkit", "0.1.0"), + ("requests", "2.32.5"), + ) assert manifest.desktop.shortcuts == ("vscode",) assert manifest.overlay_enabled is False assert manifest.background_enabled is True @@ -64,7 +72,7 @@ def test_parse_manifest_requires_integer_schema(schema: bytes) -> None: @pytest.mark.parametrize( - "section", [b"apt", b"overlay", b"background", b"desktop"] + "section", [b"apt", b"python", b"overlay", b"background", b"desktop"] ) def test_parse_manifest_requires_section_mappings(section: bytes) -> None: content = b"schema: 1\n" + section + b": []\n" @@ -148,3 +156,35 @@ def test_parse_manifest_requires_sonic_pi_for_shortcut() -> None: match="requires sonic-pi in apt.packages", ): parse_manifest(content) + +@pytest.mark.parametrize( + "packages", + [ + [], + "jamkit==0.1.0", + {"": "0.1.0"}, + {"jamkit": ""}, + {"jamkit": 1}, + {"jamkit": "not a version"}, + {"--index-url": "1.0"}, + {"bad name": "1.0"}, + ], +) +def test_parse_manifest_rejects_invalid_python_packages(packages) -> None: + import yaml + + content = yaml.safe_dump( + { + "schema": 1, + "python": {"packages": packages}, + } + ).encode() + + with pytest.raises(ManifestError, match="python.packages|Python package"): + parse_manifest(content) + + +def test_parse_manifest_defaults_to_no_python_packages() -> None: + manifest = parse_manifest(b"schema: 1\n") + + assert manifest.python.packages == () diff --git a/tests/providers/test_pip.py b/tests/providers/test_pip.py new file mode 100644 index 0000000..3bae4f7 --- /dev/null +++ b/tests/providers/test_pip.py @@ -0,0 +1,147 @@ +import json +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from nirj_agent.providers import PipProvider, PipProviderError + + +def completed( + stdout: str = "", + stderr: str = "", + returncode: int = 0, +) -> SimpleNamespace: + return SimpleNamespace( + stdout=stdout, + stderr=stderr, + returncode=returncode, + ) + + +def test_list_installed_returns_empty_when_environment_is_missing( + tmp_path: Path, +) -> None: + calls = [] + provider = PipProvider( + tmp_path / "venv", + runner=lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + assert provider.list_installed() == {} + assert calls == [] + + +def test_list_installed_normalizes_package_names(tmp_path: Path) -> None: + environment = tmp_path / "venv" + python = environment / "bin/python" + python.parent.mkdir(parents=True) + python.touch() + + runner = lambda *_args, **_kwargs: completed( + stdout=json.dumps( + [ + {"name": "JamKit", "version": "0.1.0"}, + {"name": "example_package", "version": "2.0"}, + ] + ) + ) + + provider = PipProvider(environment, runner=runner) + + assert provider.list_installed() == { + "jamkit": "0.1.0", + "example-package": "2.0", + } + + +def test_install_creates_environment_and_installs_exact_requirements( + tmp_path: Path, +) -> None: + environment = tmp_path / "venv" + calls: list[list[str]] = [] + + def runner(command, **_kwargs): + calls.append(command) + if command[:3] == ["/usr/bin/python3", "-m", "venv"]: + python = environment / "bin/python" + python.parent.mkdir(parents=True) + python.touch() + return completed() + + provider = PipProvider(environment, runner=runner) + provider.install(("jamkit==0.1.0",)) + + assert calls[0] == [ + "/usr/bin/python3", + "-m", + "venv", + str(environment), + ] + assert calls[1] == [ + str(environment / "bin/python"), + "-m", + "pip", + "--disable-pip-version-check", + "install", + "--only-binary=:all:", + "jamkit==0.1.0", + ] + + +def test_remove_uses_environment_python(tmp_path: Path) -> None: + environment = tmp_path / "venv" + python = environment / "bin/python" + python.parent.mkdir(parents=True) + python.touch() + calls = [] + + def runner(command, **_kwargs): + calls.append(command) + return completed() + + PipProvider(environment, runner=runner).remove(("obsolete",)) + + assert calls == [ + [ + str(python), + "-m", + "pip", + "--disable-pip-version-check", + "uninstall", + "--yes", + "obsolete", + ] + ] + + +def test_command_failure_is_wrapped(tmp_path: Path) -> None: + environment = tmp_path / "venv" + python = environment / "bin/python" + python.parent.mkdir(parents=True) + python.touch() + + provider = PipProvider( + environment, + runner=lambda *_args, **_kwargs: completed( + stderr="network failed", + returncode=1, + ), + ) + + with pytest.raises(PipProviderError, match="network failed"): + provider.list_installed() + + +def test_timeout_is_wrapped(tmp_path: Path) -> None: + environment = tmp_path / "venv" + python = environment / "bin/python" + python.parent.mkdir(parents=True) + python.touch() + + def runner(*_args, **_kwargs): + raise subprocess.TimeoutExpired(["pip"], 30) + + with pytest.raises(PipProviderError, match="Unable to run"): + PipProvider(environment, runner=runner).list_installed() \ No newline at end of file diff --git a/tests/services/test_apply.py b/tests/services/test_apply.py index 706406a..c88ce15 100644 --- a/tests/services/test_apply.py +++ b/tests/services/test_apply.py @@ -6,7 +6,7 @@ from nirj_agent.config import DeviceType, create_config from nirj_agent.providers import AptProviderError -from nirj_agent.services.apply import ApplyError, apply_manifest +from nirj_agent.services.apply import ApplyError, PartialApplyError, apply_manifest from nirj_agent.state import AgentState, load_state, save_state from nirj_agent.storage.paths import AgentPaths @@ -50,6 +50,22 @@ def _fail_if_requested(self, operation: str) -> None: raise AptProviderError(f"{operation} failed") +class FakePythonProvider: + def __init__(self, installed: dict[str, str] | None = None) -> None: + self.installed = installed or {} + self.events: list[object] = [] + + def list_installed(self) -> dict[str, str]: + self.events.append("list") + return self.installed + + def install(self, requirements: tuple[str, ...]) -> None: + self.events.append(("install", requirements)) + + def remove(self, packages: tuple[str, ...]) -> None: + self.events.append(("remove", packages)) + + def prepare( tmp_path: Path, device_type: DeviceType = DeviceType.PI5, @@ -76,9 +92,15 @@ def test_apply_runs_operations_in_order_and_persists_state( paths.state, ) provider = FakeApplyProvider({"git"}) + python_provider = FakePythonProvider() applied_at = datetime(2026, 7, 1, 12, 30, tzinfo=timezone.utc) - result = apply_manifest(paths, provider, clock=lambda: applied_at) + result = apply_manifest( + paths, + provider, + python_provider, + clock=lambda: applied_at, + ) assert provider.events == [ "list", @@ -86,6 +108,7 @@ def test_apply_runs_operations_in_order_and_persists_state( ("install", ("thonny",)), ("remove", ("obsolete",)), ] + assert python_provider.events == ["list"] assert result.state.manifest_hash == hashlib.sha256(MANIFEST).hexdigest() assert result.state.last_apply == "2026-07-01T12:30:00Z" assert result.state.packages == ("git", "thonny") @@ -97,10 +120,12 @@ def test_apply_runs_operations_in_order_and_persists_state( def test_apply_skips_update_when_no_install_is_needed(tmp_path: Path) -> None: paths = prepare(tmp_path) provider = FakeApplyProvider({"git", "thonny"}) + python_provider = FakePythonProvider() - apply_manifest(paths, provider) + apply_manifest(paths, provider, python_provider) assert provider.events == ["list"] + assert python_provider.events == ["list"] def test_apply_reconciles_vscode_shortcut_after_install(tmp_path: Path) -> None: @@ -111,8 +136,9 @@ def test_apply_reconciles_vscode_shortcut_after_install(tmp_path: Path) -> None: ) + b"desktop:\n shortcuts: [vscode]\n" paths.manifest_cache.write_bytes(content) provider = FakeApplyProvider({"git"}) + python_provider = FakePythonProvider() - apply_manifest(paths, provider) + apply_manifest(paths, provider, python_provider) shortcut = paths.desktop_dir / "visual-studio-code.desktop" assert shortcut.exists() @@ -124,7 +150,7 @@ def test_apply_reconciles_vscode_shortcut_after_install(tmp_path: Path) -> None: @pytest.mark.parametrize("failure", ["update", "install", "remove"]) -def test_apply_failure_does_not_replace_previous_state( +def test_apply_failure_preserves_manifest_and_records_error( tmp_path: Path, failure: str, ) -> None: @@ -139,19 +165,112 @@ def test_apply_failure_does_not_replace_previous_state( save_state(previous, paths.state) installed = {"git"} if failure != "remove" else {"git", "thonny"} provider = FakeApplyProvider(installed, fail_operation=failure) + python_provider = FakePythonProvider() - with pytest.raises(AptProviderError, match=failure): - apply_manifest(paths, provider) + with pytest.raises(PartialApplyError, match=failure): + apply_manifest(paths, provider, python_provider) - assert load_state(paths.state) == previous + state = load_state(paths.state) + assert state.manifest_hash == previous.manifest_hash + assert state.last_apply == previous.last_apply + assert state.ready is False + assert state.errors == (f"{failure} failed",) def test_apply_rejects_windows_before_querying_packages(tmp_path: Path) -> None: paths = prepare(tmp_path, DeviceType.LAPTOP_WINDOWS) provider = FakeApplyProvider(set()) + python_provider = FakePythonProvider() with pytest.raises(ApplyError, match="not supported for Windows"): - apply_manifest(paths, provider) + apply_manifest(paths, provider, python_provider) assert provider.events == [] + assert python_provider.events == [] assert not paths.state.exists() + + +def test_apply_reconciles_python_packages_and_persists_state( + tmp_path: Path, +) -> None: + paths = prepare(tmp_path) + paths.manifest_cache.write_bytes( + MANIFEST + + b"python:\n" + + b" packages:\n" + + b" jamkit: '0.2.0'\n" + + b" requests: '2.32.5'\n" + ) + save_state( + AgentState( + manifest_hash="old", + last_apply=None, + packages=("git", "thonny"), + overlay_enabled=False, + ready=False, + python_packages=(("jamkit", "0.1.0"), ("obsolete", "1.0")), + ), + paths.state, + ) + provider = FakeApplyProvider({"git", "thonny"}) + python_provider = FakePythonProvider( + {"jamkit": "0.1.0", "requests": "2.32.5"} + ) + + result = apply_manifest(paths, provider, python_provider) + + assert python_provider.events == [ + "list", + ("install", ("jamkit==0.2.0", "requests==2.32.5")), + ("remove", ("obsolete",)), + ] + assert result.state.python_packages == ( + ("jamkit", "0.2.0"), + ("requests", "2.32.5"), + ) + assert load_state(paths.state) == result.state + + +def test_partial_apt_batch_tracks_only_observed_packages(tmp_path): + paths = prepare(tmp_path) + + class PartialApt(FakeApplyProvider): + def install(self, packages): + self.installed.add("git") + raise AptProviderError("thonny unavailable") + + with pytest.raises(PartialApplyError): + apply_manifest(paths, PartialApt(set()), FakePythonProvider()) + + assert load_state(paths.state).packages == ("git",) + + +def test_python_failure_preserves_failed_removal_and_allows_shortcuts(tmp_path): + from nirj_agent.providers import PipProviderError + + paths = prepare(tmp_path) + paths.manifest_cache.write_bytes( + b"schema: 1\napt:\n packages: [code]\n" + b"python:\n packages:\n pyfiglet: '1.0.2'\n" + b"desktop:\n shortcuts: [vscode]\n" + ) + save_state(AgentState("old", None, (), False, True, + (("obsolete", "1.0"),)), paths.state) + + class FailedPython(FakePythonProvider): + def install(self, requirements): + # Simulate a partially completed pip operation. + self.installed["pyfiglet"] = "1.0.2" + raise PipProviderError("installation failed") + + def remove(self, packages): + raise PipProviderError("removal failed") + + with pytest.raises(PartialApplyError, match="removal failed"): + apply_manifest(paths, FakeApplyProvider({"code"}), + FailedPython({"obsolete": "1.0"})) + + state = load_state(paths.state) + assert not state.ready + assert state.python_packages == (("obsolete", "1.0"), ("pyfiglet", "1.0.2")) + assert (paths.desktop_dir / "visual-studio-code.desktop").exists() diff --git a/tests/services/test_boot.py b/tests/services/test_boot.py index ac30068..6d79350 100644 --- a/tests/services/test_boot.py +++ b/tests/services/test_boot.py @@ -29,6 +29,15 @@ def remove(self, _packages): raise AssertionError("not needed") +class PythonPackages: + def list_installed(self): + return {} + def install(self, _requirements): + raise AssertionError("not needed") + def remove(self, _packages): + raise AssertionError("not needed") + + class Overlay: def __init__(self, active): self.active = active @@ -59,7 +68,7 @@ def test_boot_marks_pending_and_disables_active_overlay(tmp_path) -> None: paths = prepare(tmp_path) overlay = Overlay(active=True) - result = boot_prep(paths, Client(), Packages(), overlay) + result = boot_prep(paths, Client(), Packages(), PythonPackages(), overlay) assert result.reboot_requested is True assert overlay.events == ["disable", "reboot"] @@ -74,7 +83,7 @@ def test_writable_boot_applies_target_and_reenables_overlay(tmp_path) -> None: save_update_state(UpdateState(UpdatePhase.PENDING, "target"), paths.update_state) overlay = Overlay(active=False) - result = boot_prep(paths, Client(), Packages(), overlay) + result = boot_prep(paths, Client(), Packages(), PythonPackages(), overlay) assert result.action == "update_applied" assert result.reboot_requested is True @@ -90,14 +99,18 @@ def test_overlay_disable_flag_skips_manifest_for_one_boot(tmp_path) -> None: paths.overlay_disabled_once_flag.touch() overlay = Overlay(active=False) - first_result = boot_prep(paths, Client(), Packages(), overlay) + first_result = boot_prep( + paths, Client(), Packages(), PythonPackages(), overlay + ) assert first_result.action == "ready" assert first_result.reboot_requested is False assert overlay.events == [] assert not paths.overlay_disabled_once_flag.exists() - second_result = boot_prep(paths, Client(), Packages(), overlay) + second_result = boot_prep( + paths, Client(), Packages(), PythonPackages(), overlay + ) assert second_result.action == "enabling_overlay" assert second_result.reboot_requested is True @@ -112,7 +125,7 @@ def test_overlay_disable_flag_suppresses_restore_after_update(tmp_path) -> None: save_update_state(UpdateState(UpdatePhase.PENDING, "target"), paths.update_state) overlay = Overlay(active=False) - result = boot_prep(paths, Client(), Packages(), overlay) + result = boot_prep(paths, Client(), Packages(), PythonPackages(), overlay) assert result.action == "update_applied" assert result.reboot_requested is False @@ -128,7 +141,7 @@ def test_overlay_disable_flag_survives_intermediate_reboot(tmp_path) -> None: save_update_state(UpdateState(UpdatePhase.PENDING, "target"), paths.update_state) overlay = Overlay(active=True) - result = boot_prep(paths, Client(), Packages(), overlay) + result = boot_prep(paths, Client(), Packages(), PythonPackages(), overlay) assert result.action == "waiting_for_writable_boot" assert result.reboot_requested is True @@ -143,7 +156,7 @@ def test_desktop_setup_requests_writable_boot_when_overlay_is_active( paths.wallpaper_autostart.unlink() overlay = Overlay(active=True) - result = boot_prep(paths, Client(), Packages(), overlay) + result = boot_prep(paths, Client(), Packages(), PythonPackages(), overlay) assert result.action == "waiting_for_writable_desktop_setup" assert result.reboot_requested is True @@ -159,7 +172,7 @@ def test_desktop_setup_is_persisted_on_writable_boot(tmp_path) -> None: paths.current_manifest.write_bytes(MANIFEST) overlay = Overlay(active=False) - result = boot_prep(paths, Client(), Packages(), overlay) + result = boot_prep(paths, Client(), Packages(), PythonPackages(), overlay) assert result.action == "enabling_overlay" assert paths.wallpaper_autostart.read_bytes() == AUTOSTART_CONTENT @@ -167,3 +180,77 @@ def test_desktop_setup_is_persisted_on_writable_boot(tmp_path) -> None: paths.base_background.read_bytes() == paths.source_background.read_bytes() ) + + +def test_partial_package_failure_keeps_agent_startable_and_retries(tmp_path): + from nirj_agent.config.store import set_config_value + from nirj_agent.providers import AptProviderError + from nirj_agent.state import load_state + + paths = prepare(tmp_path) + set_config_value("overlay.enabled", False, paths.config) + paths.current_manifest.parent.mkdir(parents=True, exist_ok=True) + paths.current_manifest.write_bytes(MANIFEST) + target = (b"schema: 1\napt:\n packages: [code, git]\n" + b"python:\n packages:\n pyfiglet: '1.0.2'\n" + b"desktop:\n shortcuts: [vscode]\n") + + class TargetClient: + def fetch(self, source): + return "https://example.test/target.yaml", target + + class Apt: + installed = {"git"} + fail = True + + def list_installed(self): + return self.installed.copy() + + def update(self): + pass + + def install(self, packages): + if self.fail: + raise AptProviderError("Unable to locate package code") + self.installed.update(packages) + + class Python: + def __init__(self): + self.installed = {} + self.installs = 0 + + def list_installed(self): + return self.installed.copy() + + def install(self, requirements): + self.installs += 1 + self.installed.update(item.split("==") for item in requirements) + + apt = Apt() + python = Python() + overlay = Overlay(False) + first = boot_prep(paths, TargetClient(), apt, python, overlay) + + assert first.action == "update_failed" + assert not first.reboot_requested + assert overlay.events == [] + assert python.installed == {"pyfiglet": "1.0.2"} + assert not (paths.desktop_dir / "visual-studio-code.desktop").exists() + assert paths.current_manifest.read_bytes() == MANIFEST + state = load_state(paths.state) + assert not state.ready + assert state.packages == ("git",) + assert state.python_packages == (("pyfiglet", "1.0.2"),) + assert "Unable to locate package code" in state.errors + assert load_update_state(paths.update_state).state is UpdatePhase.FAILED + + apt.fail = False + second = boot_prep(paths, TargetClient(), apt, python, overlay) + + assert second.action == "update_applied" + assert paths.current_manifest.read_bytes() == target + assert (paths.desktop_dir / "visual-studio-code.desktop").exists() + assert python.installs == 1 + assert load_state(paths.state).ready + assert not load_state(paths.state).errors + assert load_update_state(paths.update_state).state is UpdatePhase.NORMAL diff --git a/tests/services/test_overlay.py b/tests/services/test_overlay.py index 844f7ba..f013127 100644 --- a/tests/services/test_overlay.py +++ b/tests/services/test_overlay.py @@ -21,6 +21,36 @@ def run(args, **kwargs): assert calls[1][0] == ["raspi-config", "nonint", "get_overlay_now"] +@pytest.mark.parametrize("filesystem, active", [("ext4", False), ("overlay", True)]) +def test_overlay_status_without_raspi_config(filesystem, active) -> None: + def run(args, **kwargs): + if args[0] == "raspi-config": + raise FileNotFoundError("raspi-config") + return SimpleNamespace(returncode=0, stdout=filesystem + "\n") + + status = OverlayManager(run).status() + + assert status.active is active + assert status.configured is None + + +@pytest.mark.parametrize( + "command, error", + [("findmnt", FileNotFoundError("findmnt")), + ("raspi-config", PermissionError("permission denied"))], +) +def test_overlay_status_preserves_other_errors(command, error) -> None: + def run(args, **kwargs): + if args[0] == command: + raise error + return SimpleNamespace(returncode=0, stdout="ext4\n") + + with pytest.raises(OverlayError) as caught: + OverlayManager(run).status() + + assert caught.value.__cause__ is error + + def test_overlay_transitions_and_reboot_commands(tmp_path) -> None: calls = [] cmdline = tmp_path / "cmdline.txt" diff --git a/tests/services/test_plan.py b/tests/services/test_plan.py index 73ba7e6..f232549 100644 --- a/tests/services/test_plan.py +++ b/tests/services/test_plan.py @@ -19,6 +19,16 @@ def list_installed(self) -> set[str]: return self.installed +class FakePythonProvider: + def __init__(self, installed: dict[str, str] | None = None) -> None: + self.installed = installed or {} + self.called = False + + def list_installed(self) -> dict[str, str]: + self.called = True + return self.installed + + def write_manifest(path: Path, enforce: bool = True) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( @@ -49,13 +59,15 @@ def test_create_plan_uses_config_manifest_state_and_provider( paths.state, ) provider = FakePackageProvider({"git"}) + python_provider = FakePythonProvider() - plan = create_plan(paths, provider) + plan = create_plan(paths, provider, python_provider) assert provider.called is True - assert plan.install == ("thonny",) - assert plan.remove == ("obsolete",) - assert plan.unchanged == ("git",) + assert python_provider.called is True + assert plan.apt.install == ("thonny",) + assert plan.apt.remove == ("obsolete",) + assert plan.apt.unchanged == ("git",) def test_create_plan_does_not_write_files(tmp_path: Path) -> None: @@ -65,7 +77,11 @@ def test_create_plan_does_not_write_files(tmp_path: Path) -> None: config_before = paths.config.read_bytes() manifest_before = paths.manifest_cache.read_bytes() - create_plan(paths, FakePackageProvider({"git", "thonny"})) + create_plan( + paths, + FakePackageProvider({"git", "thonny"}), + FakePythonProvider(), + ) assert paths.config.read_bytes() == config_before assert paths.manifest_cache.read_bytes() == manifest_before @@ -77,7 +93,7 @@ def test_create_plan_requires_cached_manifest(tmp_path: Path) -> None: create_config("PI5-001", DeviceType.PI5, paths.config) with pytest.raises(ManifestError, match="Unable to read manifest"): - create_plan(paths, FakePackageProvider(set())) + create_plan(paths, FakePackageProvider(set()), FakePythonProvider()) def test_create_plan_rejects_windows_before_querying_packages( @@ -86,8 +102,10 @@ def test_create_plan_rejects_windows_before_querying_packages( paths = AgentPaths.sandbox(tmp_path) create_config("LPT-001", DeviceType.LAPTOP_WINDOWS, paths.config) provider = FakePackageProvider(set()) + python_provider = FakePythonProvider() with pytest.raises(PlanError, match="not supported for Windows"): - create_plan(paths, provider) + create_plan(paths, provider, python_provider) assert provider.called is False + assert python_provider.called is False diff --git a/tests/services/test_python_setup.py b/tests/services/test_python_setup.py new file mode 100644 index 0000000..6915253 --- /dev/null +++ b/tests/services/test_python_setup.py @@ -0,0 +1,96 @@ +import json +import os +import subprocess + +import pytest + +from nirj_agent.services.python_setup import ( + START, + _settings, + python_setup_needs_reconcile, + reconcile_python_setup, +) +from nirj_agent.services.desktop_setup import ( + desktop_setup_needs_reconcile, + reconcile_desktop_setup, +) +from nirj_agent.storage.paths import AgentPaths + + +def prepare(tmp_path): + paths = AgentPaths.sandbox(tmp_path) + home = paths.desktop_dir.parent + home.mkdir(parents=True) + binary = paths.python_environment / "bin/python" + binary.parent.mkdir(parents=True) + binary.symlink_to("/usr/bin/python3") + return paths, home + + +def test_setup_preserves_content_permissions_and_is_idempotent(tmp_path): + paths, home = prepare(tmp_path) + profile = home / ".profile" + profile.write_text("export MY_SETTING=hello\n") + profile.chmod(0o600) + (home / ".bash_profile").write_text("# custom login\n") + assert desktop_setup_needs_reconcile(paths, False) + reconcile_desktop_setup(paths, False) + assert not desktop_setup_needs_reconcile(paths, False) + assert profile.read_text().startswith("export MY_SETTING=hello\n") + assert profile.stat().st_mode & 0o777 == 0o600 + assert profile.stat().st_uid == home.stat().st_uid + for name in (".profile", ".bashrc", ".xprofile", ".bash_profile"): + assert (home / name).read_text().count(START) == 1 + settings = home / ".config/Code/User/settings.json" + assert json.loads(settings.read_text())["python.defaultInterpreterPath"] == str(paths.python_environment / "bin/python") + before = profile.stat().st_mtime_ns + reconcile_python_setup(paths) + assert profile.stat().st_mtime_ns == before + + +def test_shell_resolves_managed_python_and_respects_active_venv(tmp_path): + paths, home = prepare(tmp_path) + reconcile_python_setup(paths) + command = '. "$HOME/.profile"; . "$HOME/.profile"; command -v python; printf "%s" "$PATH"' + env = {**os.environ, "HOME": str(home), "PATH": "/usr/bin:/bin", "VIRTUAL_ENV": ""} + result = subprocess.run(["/bin/sh", "-c", command], env=env, capture_output=True, text=True, check=True) + binary, path = result.stdout.split("\n", 1) + assert binary == str(paths.python_environment / "bin/python") + assert path.split(":").count(str(paths.python_environment / "bin")) == 1 + env["VIRTUAL_ENV"] = "/another/venv" + result = subprocess.run(["/bin/sh", "-c", '. "$HOME/.profile"; printf "%s" "$PATH"'], env=env, capture_output=True, text=True, check=True) + assert result.stdout == "/usr/bin:/bin" + + +def test_jsonc_preserves_comments_nested_settings_and_trailing_commas(): + content = '''{ +// keep this +"editor.fontSize": 16, +"nested": {"python.defaultInterpreterPath": "untouched",}, +"python.defaultInterpreterPath": "/old/python", /* keep too */ +}''' + result = _settings(content, "/new/python") + assert result == content.replace('"/old/python"', '"/new/python"') + assert _settings(result, "/new/python") == result + added = _settings('{/* comment */ "url": "https://example.test",}', "/new/python") + assert '"url": "https://example.test"' in added + assert _settings(added, "/new/python") == added + + +def test_missing_environment_or_user_does_not_create_configuration(tmp_path): + paths = AgentPaths.sandbox(tmp_path) + reconcile_python_setup(paths) + assert not paths.desktop_dir.parent.exists() + paths.desktop_dir.parent.mkdir(parents=True) + assert not python_setup_needs_reconcile(paths) + + +def test_invalid_settings_are_not_overwritten(tmp_path): + paths, home = prepare(tmp_path) + settings = home / ".config/Code/User/settings.json" + settings.parent.mkdir(parents=True) + settings.write_text("{ broken") + with pytest.raises(ValueError): + reconcile_python_setup(paths) + assert settings.read_text() == "{ broken" + assert not (home / ".profile").exists() diff --git a/tests/services/test_reconciliation.py b/tests/services/test_reconciliation.py index 0ca125b..b24bf5c 100644 --- a/tests/services/test_reconciliation.py +++ b/tests/services/test_reconciliation.py @@ -1,11 +1,24 @@ -from nirj_agent.manifests import AptManifest, DesktopManifest, Manifest -from nirj_agent.services.reconciliation import build_package_plan - - -def manifest(*packages: str, enforce: bool = True) -> Manifest: +from nirj_agent.manifests import ( + AptManifest, + DesktopManifest, + Manifest, + PythonManifest, +) +from nirj_agent.services.reconciliation import ( + build_package_plan, + build_python_package_plan, +) + + +def manifest( + *packages: str, + enforce: bool = True, + python_packages: tuple[tuple[str, str], ...] = (), +) -> Manifest: return Manifest( schema=1, apt=AptManifest(enforce=enforce, packages=packages), + python=PythonManifest(packages=python_packages), desktop=DesktopManifest(shortcuts=()), overlay_enabled=False, background_enabled=False, @@ -68,3 +81,48 @@ def test_package_plan_reports_no_changes() -> None: assert plan.install == () assert plan.remove == () assert plan.changes_required is False + +def test_python_plan_installs_missing_and_wrong_versions() -> None: + plan = build_python_package_plan( + manifest=manifest( + python_packages=( + ("jamkit", "0.2.0"), + ("requests", "2.32.5"), + ) + ), + installed_packages={ + "jamkit": "0.1.0", + "unmanaged": "1.0", + }, + previously_managed_packages={"jamkit", "obsolete"}, + ) + + assert plan.desired == ( + ("jamkit", "0.2.0"), + ("requests", "2.32.5"), + ) + assert plan.install == ( + "jamkit==0.2.0", + "requests==2.32.5", + ) + assert plan.remove == ("obsolete",) + assert plan.unchanged == () + + +def test_python_plan_preserves_unmanaged_packages() -> None: + plan = build_python_package_plan( + manifest=manifest( + python_packages=(("jamkit", "0.1.0"),) + ), + installed_packages={ + "jamkit": "0.1.0", + "pip": "25.2", + "setuptools": "80.0", + }, + previously_managed_packages={"jamkit"}, + ) + + assert plan.install == () + assert plan.remove == () + assert plan.unchanged == (("jamkit", "0.1.0"),) + assert plan.changes_required is False diff --git a/tests/test_cli_apply.py b/tests/test_cli_apply.py index 1153c54..861661d 100644 --- a/tests/test_cli_apply.py +++ b/tests/test_cli_apply.py @@ -3,7 +3,11 @@ from types import SimpleNamespace from nirj_agent.providers import AptProviderError -from nirj_agent.services.reconciliation import PackagePlan +from nirj_agent.services.reconciliation import ( + PackagePlan, + PythonPackagePlan, + ReconciliationPlan, +) from nirj_agent.state import AgentState @@ -31,24 +35,39 @@ def test_apply_requires_root(monkeypatch, capsys) -> None: def test_apply_prints_result(monkeypatch, capsys) -> None: monkeypatch.setattr(cli.os, "geteuid", lambda: 0) provider = object() + expected_python_provider = object() monkeypatch.setattr(cli, "AptProvider", lambda: provider) - plan = PackagePlan( - desired=("git", "thonny"), - install=("thonny",), - remove=("obsolete",), - unchanged=("git",), + monkeypatch.setattr( + cli, + "PipProvider", + lambda _path: expected_python_provider, + ) + plan = ReconciliationPlan( + apt=PackagePlan( + desired=("git", "thonny"), + install=("thonny",), + remove=("obsolete",), + unchanged=("git",), + ), + python=PythonPackagePlan( + desired=(("jamkit", "0.1.0"),), + install=("jamkit==0.1.0",), + remove=(), + unchanged=(), + ), ) state = AgentState( manifest_hash="abc123", last_apply="2026-07-01T12:30:00Z", - packages=plan.desired, + packages=plan.apt.desired, overlay_enabled=False, ready=False, ) - def apply_test_manifest(paths, package_provider): + def apply_test_manifest(paths, package_provider, python_provider): assert paths.config == Path("/data/nirj/config/config.yaml") assert package_provider is provider + assert python_provider is expected_python_provider return SimpleNamespace(plan=plan, state=state) monkeypatch.setattr(cli, "apply_manifest", apply_test_manifest) diff --git a/tests/test_cli_manifest.py b/tests/test_cli_manifest.py index 198c532..b615960 100644 --- a/tests/test_cli_manifest.py +++ b/tests/test_cli_manifest.py @@ -18,6 +18,7 @@ def test_manifest_refresh_prints_summary(tmp_path: Path, monkeypatch, capsys) -> manifest=SimpleNamespace( schema=1, apt=SimpleNamespace(packages=("git", "thonny")), + python=SimpleNamespace(packages=()), ), sha256="abc123", source_url="https://example.test/manifest.yaml", @@ -29,7 +30,8 @@ def test_manifest_refresh_prints_summary(tmp_path: Path, monkeypatch, capsys) -> output = capsys.readouterr() assert result == 0 assert '"sha256": "abc123"' in output.out - assert '"packages": 2' in output.out + assert '"apt_packages": 2' in output.out + assert '"python_packages": 0' in output.out assert str(tmp_path / "data/nirj/state/target-manifest.json") in output.out assert output.err == "" diff --git a/tests/test_cli_partial_update.py b/tests/test_cli_partial_update.py new file mode 100644 index 0000000..399c0e6 --- /dev/null +++ b/tests/test_cli_partial_update.py @@ -0,0 +1,17 @@ +from importlib import import_module + +import pytest + +from nirj_agent.services.boot import BootPrepResult + + +@pytest.mark.parametrize("arguments, expected", [(["boot-prep"], 0), (["update", "apply"], 1)]) +def test_partial_update_allows_startup_but_reports_explicit_failure( + monkeypatch, capsys, arguments, expected, +): + cli = import_module("nirj_agent.cli.main") + monkeypatch.setattr(cli, "_require_root", lambda *args: True) + monkeypatch.setattr(cli, "boot_prep", lambda **kwargs: BootPrepResult("update_failed", False)) + + assert cli.main(arguments) == expected + assert '"action": "update_failed"' in capsys.readouterr().out diff --git a/tests/test_cli_plan.py b/tests/test_cli_plan.py index 1e934aa..419394e 100644 --- a/tests/test_cli_plan.py +++ b/tests/test_cli_plan.py @@ -2,7 +2,11 @@ from pathlib import Path from nirj_agent.providers import AptProviderError -from nirj_agent.services.reconciliation import PackagePlan +from nirj_agent.services.reconciliation import ( + PackagePlan, + PythonPackagePlan, + ReconciliationPlan, +) cli = import_module("nirj_agent.cli.main") @@ -10,18 +14,33 @@ def test_plan_prints_package_changes(tmp_path: Path, monkeypatch, capsys) -> None: provider = object() + expected_python_provider = object() monkeypatch.setattr(cli, "AptProvider", lambda: provider) + monkeypatch.setattr( + cli, + "PipProvider", + lambda _path: expected_python_provider, + ) - def create_test_plan(paths, package_provider): + def create_test_plan(paths, package_provider, python_provider): assert paths.manifest_cache == ( tmp_path / "data/nirj/state/target-manifest.json" ) assert package_provider is provider - return PackagePlan( - desired=("git", "thonny"), - install=("thonny",), - remove=("obsolete",), - unchanged=("git",), + assert python_provider is expected_python_provider + return ReconciliationPlan( + apt=PackagePlan( + desired=("git", "thonny"), + install=("thonny",), + remove=("obsolete",), + unchanged=("git",), + ), + python=PythonPackagePlan( + desired=(), + install=(), + remove=(), + unchanged=(), + ), ) monkeypatch.setattr(cli, "create_plan", create_test_plan) diff --git a/tests/test_state.py b/tests/test_state.py index aa06074..af3d90e 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -22,6 +22,7 @@ def test_save_and_load_state(tmp_path: Path) -> None: packages=("thonny", "scratch"), overlay_enabled=True, ready=True, + python_packages=(("jamkit", "0.1.0"),), ) save_state(expected, path) assert load_state(path) == expected