Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/agent_comms/installer/probes/packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 13 additions & 1 deletion src/agent_comms/installer/runtime_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand All @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion src/agent_comms/installer/runtime_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions src/agent_comms/runtime_preparation.py
Original file line number Diff line number Diff line change
@@ -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())
60 changes: 60 additions & 0 deletions tests/test_runtime_preparation.py
Original file line number Diff line number Diff line change
@@ -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")
Loading