diff --git a/src/agent_comms/installer/probes/packages.py b/src/agent_comms/installer/probes/packages.py index 8b9eec0dca..37f16c7c36 100644 --- a/src/agent_comms/installer/probes/packages.py +++ b/src/agent_comms/installer/probes/packages.py @@ -16,6 +16,17 @@ from agent_comms.field_codec import WireValue from agent_comms.installer.interpreter_operation import RuntimeProbe, TargetProbe from agent_comms.package_stamp import STAMP +from agent_comms.runtime_preparation import RuntimePreparation + + +@dataclass(frozen=True) +class PreparePackageProbe(TargetProbe, RuntimeProbe[None]): + """Only the built target loads its distribution's preparation implementation.""" + + distribution: str + + def run(self) -> None: + RuntimePreparation.load(self.distribution).prepare() @dataclass(frozen=True) diff --git a/src/agent_comms/installer/runtime_resolution.py b/src/agent_comms/installer/runtime_resolution.py index 177356a0aa..0230571a40 100644 --- a/src/agent_comms/installer/runtime_resolution.py +++ b/src/agent_comms/installer/runtime_resolution.py @@ -16,7 +16,10 @@ from agent_comms.declared_family import DeclaredFamily from agent_comms.installer.probes.packages import ArchivePin, GitPin, PackagePin, PackageRecord -from agent_comms.installer.probes.packages import DistributionVersionsProbe, InstalledPackagesProbe +from agent_comms.installer.interpreter_operation import RuntimeProbe +from agent_comms.installer.probes.packages import ( + DistributionVersionsProbe, InstalledPackagesProbe, PreparePackageProbe, +) from agent_comms.installer.refusals import UpgradeRefused from agent_comms.launch_runtime import LaunchRuntime from agent_comms.repository_name import RepositoryName @@ -33,6 +36,11 @@ class RuntimePackage(DeclaredFamily, affix="Package"): def commit(cls, resolution: RuntimeResolution) -> str: return resolution.packages[cls.distribution].source.revision() + @classmethod + def preparations(cls) -> tuple[RuntimeProbe[None], ...]: + """Only packages declaring requirements add target-runtime operations.""" + return () + class ConsoleScriptPackage: """A runtime package whose console scripts the installer publishes.""" @@ -51,6 +59,10 @@ class ToadPackage(RuntimePackage, ConsoleScriptPackage): distribution = "batrachian-toad" repository = RepositoryName("OpenHCSDev", "toad") + @classmethod + def preparations(cls) -> tuple[RuntimeProbe[None], ...]: + return (PreparePackageProbe(cls.distribution),) + class TextualPackage(RuntimePackage): distribution = "textual" diff --git a/src/agent_comms/installer/runtime_stack.py b/src/agent_comms/installer/runtime_stack.py index 12ed68ac68..7e08333839 100644 --- a/src/agent_comms/installer/runtime_stack.py +++ b/src/agent_comms/installer/runtime_stack.py @@ -26,7 +26,7 @@ from agent_comms.installer.probes.imports import ClientImportsProbe from agent_comms.installer.probes.native import PrepareNativeProbe, PrepareProducerDigestsProbe from agent_comms.installer.refusals import UpgradeRefused -from agent_comms.installer.runtime_resolution import ResolvedLock, RuntimeResolution +from agent_comms.installer.runtime_resolution import ResolvedLock, RuntimePackage, RuntimeResolution from agent_comms.launch_runtime import LaunchRuntime, RestartEnvironment from agent_comms.native_build_inputs import NativeBuildInputs from agent_comms.native_package import NativePackageStore, pinned_package_for @@ -187,6 +187,9 @@ def install_native(self) -> None: def prepare_runtime(self) -> None: self.probe(PrepareProducerDigestsProbe()) self.probe(PrepareNativeProbe()) + for package in RuntimePackage.members_with(RuntimePackage): + for preparation in package.preparations(): + self.probe(preparation) def environment(self, *paths: Path) -> dict: """Use the artifact's packages; ``paths`` are searched first, in order (the target diff --git a/src/agent_comms/runtime_preparation.py b/src/agent_comms/runtime_preparation.py new file mode 100644 index 0000000000..af3fc56b99 --- /dev/null +++ b/src/agent_comms/runtime_preparation.py @@ -0,0 +1,35 @@ +"""The nominal preparation contract loaded from a target distribution's metadata.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from importlib.metadata import distribution +from inspect import isabstract +from typing import ClassVar + + +class RuntimePreparation(ABC): + """A distribution prepares its own requirements before runtime publication.""" + + entry_point_group: ClassVar[str] = "agent_comms.runtime_preparation" + + @abstractmethod + def prepare(self) -> None: ... + + @classmethod + def decode(cls, value: object) -> RuntimePreparation: + """The entry point is foreign until its concrete nominal class is checked.""" + if not isinstance(value, type): + raise TypeError("Runtime preparation entry point must load a class") + if not issubclass(value, cls) or isabstract(value): + raise TypeError(f"Runtime preparation must implement {cls.__qualname__}") + return value() + + @classmethod + def load(cls, package: str) -> RuntimePreparation: + entries = tuple(distribution(package).entry_points.select(group=cls.entry_point_group)) + if len(entries) != 1: + raise ValueError( + f"{package} declares {len(entries)} {cls.entry_point_group} entry points; expected one" + ) + return cls.decode(entries[0].load()) diff --git a/tests/test_runtime_preparation.py b/tests/test_runtime_preparation.py new file mode 100644 index 0000000000..8c7eebf3b9 --- /dev/null +++ b/tests/test_runtime_preparation.py @@ -0,0 +1,60 @@ +"""The target probe loads actual distribution metadata, not a patched loader.""" + +from pathlib import Path + +import pytest + +from agent_comms.installer.probes.packages import PreparePackageProbe +from agent_comms.runtime_preparation import RuntimePreparation + + +class PreparationEntered(RuntimeError): + pass + + +class DeclaredPreparation(RuntimePreparation): + def prepare(self) -> None: + raise PreparationEntered("distribution-owned preparation ran") + + +@pytest.fixture +def preparation_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + metadata = tmp_path / "p928_preparation-1.0.dist-info" + metadata.mkdir() + (metadata / "METADATA").write_text( + "Metadata-Version: 2.1\nName: p928-preparation\nVersion: 1.0\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + return metadata / "entry_points.txt" + + +def test_target_probe_runs_the_distribution_declaration(preparation_metadata: Path): + implementation = f"{DeclaredPreparation.__module__}:{DeclaredPreparation.__qualname__}" + preparation_metadata.write_text( + f"[{RuntimePreparation.entry_point_group}]\nrequirement = {implementation}\n" + ) + with pytest.raises(PreparationEntered, match="distribution-owned preparation ran"): + PreparePackageProbe("p928-preparation").run() + + +@pytest.mark.parametrize("value", [object(), object, RuntimePreparation]) +def test_foreign_preparation_must_be_a_concrete_nominal_class(value: object): + with pytest.raises(TypeError): + RuntimePreparation.decode(value) + + +@pytest.mark.parametrize("entries", ["", "first = builtins:object\nsecond = builtins:object\n"]) +def test_declared_preparation_requires_exactly_one_entry_point( + preparation_metadata: Path, entries: str, +): + preparation_metadata.write_text(f"[{RuntimePreparation.entry_point_group}]\n{entries}") + with pytest.raises(ValueError, match="expected one"): + RuntimePreparation.load("p928-preparation") + + +def test_entry_point_shape_does_not_admit_an_unrelated_class(preparation_metadata: Path): + preparation_metadata.write_text( + f"[{RuntimePreparation.entry_point_group}]\nrequirement = builtins:object\n" + ) + with pytest.raises(TypeError, match="must implement"): + RuntimePreparation.load("p928-preparation")