diff --git a/aegis/core/components.py b/aegis/core/components.py index 1edfc5d2..569f0eb1 100644 --- a/aegis/core/components.py +++ b/aegis/core/components.py @@ -5,10 +5,11 @@ used for project generation and validation. """ -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum from .file_manifest import FileManifest +from .plugin_spec import PluginKind, PluginSpec class ComponentType(Enum): @@ -30,24 +31,18 @@ class SchedulerBackend(str, Enum): CORE_COMPONENTS = ["backend", "frontend"] -@dataclass -class ComponentSpec: - """Specification for a single component.""" +@dataclass(kw_only=True) +class ComponentSpec(PluginSpec): + """Component-flavoured PluginSpec — back-compat alias for pre-R2 callers. - name: str - type: ComponentType - description: str - requires: list[str] = field(default_factory=list) # Hard dependencies - recommends: list[str] = field(default_factory=list) # Soft dependencies - conflicts: list[str] = field(default_factory=list) # Mutual exclusions - docker_services: list[str] = field(default_factory=list) - pyproject_deps: list[str] = field(default_factory=list) - template_files: list[str] = field(default_factory=list) - # R1 file manifest used by cleanup_components(). The legacy - # post_gen_tasks.get_component_file_mapping() dict is still maintained - # separately, so this manifest must be kept aligned with it by hand - # until R2 derives the mapping from manifests. See file_manifest.py. - files: FileManifest = field(default_factory=FileManifest) + Subclasses ``PluginSpec`` and pins ``kind`` to ``COMPONENT`` by default. + Legacy field names ``requires`` / ``recommends`` continue to work for + *read* access via the property aliases on ``PluginSpec``; constructions + in this file use the canonical ``required_components`` / + ``recommended_components`` names. R2 of the plugin system refactor. + """ + + kind: PluginKind = PluginKind.COMPONENT # Component registry - single source of truth @@ -85,7 +80,7 @@ class ComponentSpec: name="worker", type=ComponentType.INFRASTRUCTURE, description="Background task processing (arq, Dramatiq, or TaskIQ)", - requires=["redis"], # Hard dependency + required_components=["redis"], # Hard dependency pyproject_deps=["arq==0.25.0"], docker_services=["worker-system", "worker-load-test"], template_files=["app/components/worker/"], @@ -154,7 +149,7 @@ class ComponentSpec: type=ComponentType.INFRASTRUCTURE, description="Traefik reverse proxy and load balancer", docker_services=["traefik"], - recommends=["backend"], + recommended_components=["backend"], files=FileManifest( primary=[ "traefik", diff --git a/aegis/core/file_manifest.py b/aegis/core/file_manifest.py index ff65110c..8bcb357e 100644 --- a/aegis/core/file_manifest.py +++ b/aegis/core/file_manifest.py @@ -1,11 +1,14 @@ """ -Declarative file manifest per service / component spec. +Declarative file manifest per ``PluginSpec``. R1 of the plugin system refactor. Centralises the per-spec file ownership needed for declarative cleanup, replacing the **Pattern A** imperative blocks ("if X is not selected, remove these files") that were scattered through ``aegis/core/post_gen_tasks.py``. +Consumed by every ``PluginSpec`` (in-tree services and components today; +third-party plugins under R2+). See ``aegis/core/plugin_spec.py``. + What R1 does NOT do (deliberately deferred to R2): * ``post_gen_tasks.get_component_file_mapping()`` is still a hard-coded diff --git a/aegis/core/plugin_spec.py b/aegis/core/plugin_spec.py new file mode 100644 index 00000000..dbd29210 --- /dev/null +++ b/aegis/core/plugin_spec.py @@ -0,0 +1,100 @@ +""" +Unified plugin specification — R2 of the plugin system refactor. + +Replaces the parallel ``ServiceSpec`` / ``ComponentSpec`` types with a +single ``PluginSpec`` that covers in-tree services, in-tree components, +and (eventually) third-party plugins. + +In-tree services and components are first-party plugins (``verified=True``); +external plugins use the same dataclass with ``verified=False``. The +``ServiceSpec`` and ``ComponentSpec`` aliases in +``aegis/core/services.py`` and ``aegis/core/components.py`` preserve +back-compat with pre-R2 call sites — they are subclasses of ``PluginSpec`` +that pin ``kind`` to ``SERVICE`` / ``COMPONENT`` by default. + +R2 keeps PluginKind narrow (``SERVICE`` / ``COMPONENT``). Additional +kinds (``INTEGRATION``, ``ENHANCEMENT``, ...) are added when a real +plugin needs them, not speculatively. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from .file_manifest import FileManifest + + +class PluginKind(Enum): + """Top-level role a ``PluginSpec`` plays in an Aegis project.""" + + SERVICE = "service" + """Business-logic plugin (auth, payment, AI, scraping, ...).""" + + COMPONENT = "component" + """Infrastructure plugin (database, redis, worker, frontend, ...).""" + + +@dataclass(kw_only=True) +class PluginSpec: + """Unified specification for components, services, and third-party plugins. + + Field naming notes: + + * ``required_components`` / ``recommended_components`` / ``required_services`` + are the canonical dependency-list fields (matches the pre-R2 + ``ServiceSpec`` convention, which had the higher caller volume). + * ``requires`` and ``recommends`` exist as **read-only** property aliases + so legacy ``ComponentSpec``-style attribute access keeps working + (``component.requires``, ``component.recommends``). Construction via + those names is intentionally not supported — pass the canonical names. + + The ``type`` field is a sub-classification facet that is meaningful per + ``kind``: when ``kind=SERVICE`` it carries a ``ServiceType`` (AUTH, + PAYMENT, ...); when ``kind=COMPONENT`` it carries a ``ComponentType`` + (CORE, INFRASTRUCTURE). Typed as ``Any`` here to avoid a circular + import; consumers narrow as needed. + """ + + # Identity + name: str + kind: PluginKind + description: str + + # Sub-classification — ServiceType when kind=SERVICE, + # ComponentType when kind=COMPONENT. + type: Any = None + + # Dependencies + required_components: list[str] = field(default_factory=list) + recommended_components: list[str] = field(default_factory=list) + required_services: list[str] = field(default_factory=list) + required_plugins: list[str] = field(default_factory=list) + + # Mutual exclusion + conflicts: list[str] = field(default_factory=list) + + # Packaging / template + pyproject_deps: list[str] = field(default_factory=list) + docker_services: list[str] = field(default_factory=list) + template_files: list[str] = field(default_factory=list) + + # File ownership for cleanup (R1) + files: FileManifest = field(default_factory=FileManifest) + + # Plugin metadata + version: str = "0.0.0" + verified: bool = True + + # ----- Read-only legacy aliases ------------------------------------- + + @property + def requires(self) -> list[str]: + """Component-style alias for ``required_components``.""" + return self.required_components + + @property + def recommends(self) -> list[str]: + """Component-style alias for ``recommended_components``.""" + return self.recommended_components diff --git a/aegis/core/services.py b/aegis/core/services.py index 5ce0188a..f82a119e 100644 --- a/aegis/core/services.py +++ b/aegis/core/services.py @@ -5,12 +5,13 @@ and metadata used for project generation and validation. """ -from dataclasses import dataclass, field +from dataclasses import dataclass from enum import Enum from ..constants import ComponentNames from ..i18n import t from .file_manifest import FileManifest +from .plugin_spec import PluginKind, PluginSpec class ServiceType(Enum): @@ -24,32 +25,17 @@ class ServiceType(Enum): STORAGE = "storage" # File storage and CDN -@dataclass -class ServiceSpec: - """Specification for a single service.""" - - name: str - type: ServiceType - description: str - required_components: list[str] = field( - default_factory=list - ) # Components this service needs - recommended_components: list[str] = field( - default_factory=list - ) # Soft component dependencies - required_services: list[str] = field( - default_factory=list - ) # Other services this service needs - conflicts: list[str] = field(default_factory=list) # Mutual exclusions - pyproject_deps: list[str] = field( - default_factory=list - ) # Python packages for this service - template_files: list[str] = field(default_factory=list) # Template files to include - # R1 file manifest used by cleanup_components(). The legacy - # post_gen_tasks.get_component_file_mapping() dict is still maintained - # separately, so this manifest must be kept aligned with it by hand - # until R2 derives the mapping from manifests. See file_manifest.py. - files: FileManifest = field(default_factory=FileManifest) +@dataclass(kw_only=True) +class ServiceSpec(PluginSpec): + """Service-flavoured PluginSpec — back-compat alias for pre-R2 callers. + + Subclasses ``PluginSpec`` and pins ``kind`` to ``SERVICE`` by default, so + ``ServiceSpec(name=..., type=..., description=...)`` still works without + naming the kind. R2 of the plugin system refactor; see + ``aegis/core/plugin_spec.py`` for the unified type. + """ + + kind: PluginKind = PluginKind.SERVICE # Service registry - single source of truth for all available services diff --git a/tests/core/test_plugin_spec.py b/tests/core/test_plugin_spec.py new file mode 100644 index 00000000..913100d3 --- /dev/null +++ b/tests/core/test_plugin_spec.py @@ -0,0 +1,146 @@ +""" +Tests for the unified PluginSpec contract (R2 of plugin refactor). + +These tests lock in: + * the dataclass shape (required vs default fields, sensible defaults) + * back-compat aliases (ServiceSpec / ComponentSpec subclass behaviour, + requires / recommends read-only properties) + * registry-shape guarantees (every in-tree spec is a PluginSpec with + a sane kind and verified=True). +""" + +import pytest + +from aegis.core.components import COMPONENTS, ComponentSpec, ComponentType +from aegis.core.file_manifest import FileManifest +from aegis.core.plugin_spec import PluginKind, PluginSpec +from aegis.core.services import SERVICES, ServiceSpec, ServiceType + + +class TestPluginSpec: + """The unified dataclass itself.""" + + def test_minimal_construction(self) -> None: + spec = PluginSpec(name="x", kind=PluginKind.SERVICE, description="d") + assert spec.name == "x" + assert spec.kind is PluginKind.SERVICE + assert spec.description == "d" + assert spec.type is None + assert spec.required_components == [] + assert spec.recommended_components == [] + assert spec.required_services == [] + assert spec.required_plugins == [] + assert spec.conflicts == [] + assert spec.pyproject_deps == [] + assert spec.docker_services == [] + assert spec.template_files == [] + assert isinstance(spec.files, FileManifest) + assert spec.version == "0.0.0" + assert spec.verified is True + + def test_kind_is_required(self) -> None: + with pytest.raises(TypeError): + PluginSpec(name="x", description="d") # type: ignore[call-arg] + + def test_independent_default_lists(self) -> None: + a = PluginSpec(name="a", kind=PluginKind.SERVICE, description="") + b = PluginSpec(name="b", kind=PluginKind.SERVICE, description="") + a.required_components.append("redis") + assert b.required_components == [] + + def test_requires_alias_reads_required_components(self) -> None: + spec = PluginSpec( + name="x", + kind=PluginKind.COMPONENT, + description="d", + required_components=["redis", "database"], + ) + assert spec.requires == ["redis", "database"] + assert spec.requires is spec.required_components # same list + + def test_recommends_alias_reads_recommended_components(self) -> None: + spec = PluginSpec( + name="x", + kind=PluginKind.COMPONENT, + description="d", + recommended_components=["backend"], + ) + assert spec.recommends == ["backend"] + assert spec.recommends is spec.recommended_components + + def test_unverified_third_party_default_pattern(self) -> None: + """A third-party plugin would set ``verified=False`` explicitly.""" + spec = PluginSpec( + name="aegis-plugin-scraper", + kind=PluginKind.SERVICE, + description="Web scraping", + verified=False, + version="0.1.0", + required_plugins=["auth>=1.0"], + ) + assert spec.verified is False + assert spec.version == "0.1.0" + assert spec.required_plugins == ["auth>=1.0"] + + +class TestSubclassAliases: + """ServiceSpec and ComponentSpec are PluginSpec subclasses with a + pre-baked ``kind`` default. Pre-R2 call sites rely on this. + """ + + def test_servicespec_defaults_kind_service(self) -> None: + spec = ServiceSpec(name="x", type=ServiceType.AUTH, description="d") + assert spec.kind is PluginKind.SERVICE + assert isinstance(spec, PluginSpec) + assert isinstance(spec, ServiceSpec) + + def test_componentspec_defaults_kind_component(self) -> None: + spec = ComponentSpec( + name="x", type=ComponentType.INFRASTRUCTURE, description="d" + ) + assert spec.kind is PluginKind.COMPONENT + assert isinstance(spec, PluginSpec) + assert isinstance(spec, ComponentSpec) + + def test_servicespec_can_override_kind(self) -> None: + """Just because — ensures the default is overridable, not pinned.""" + spec = ServiceSpec( + name="x", + kind=PluginKind.COMPONENT, + type=ServiceType.AUTH, + description="d", + ) + assert spec.kind is PluginKind.COMPONENT + + +class TestRegistryShape: + """In-tree services and components are first-party plugins.""" + + def test_every_service_is_first_party(self) -> None: + for name, spec in SERVICES.items(): + assert isinstance(spec, ServiceSpec), f"{name} not a ServiceSpec" + assert isinstance(spec, PluginSpec) + assert spec.kind is PluginKind.SERVICE, name + assert spec.verified is True, f"{name} should be verified=True" + assert isinstance(spec.type, ServiceType), f"{name} type wrong" + + def test_every_component_is_first_party(self) -> None: + for name, spec in COMPONENTS.items(): + assert isinstance(spec, ComponentSpec), f"{name} not a ComponentSpec" + assert isinstance(spec, PluginSpec) + assert spec.kind is PluginKind.COMPONENT, name + assert spec.verified is True, f"{name} should be verified=True" + assert isinstance(spec.type, ComponentType), f"{name} type wrong" + + def test_no_servicespec_in_components(self) -> None: + """ComponentSpec instances are NOT ServiceSpec instances (sibling subclasses).""" + for name, spec in COMPONENTS.items(): + assert not isinstance(spec, ServiceSpec), ( + f"component {name} leaked as ServiceSpec" + ) + + def test_no_componentspec_in_services(self) -> None: + for name, spec in SERVICES.items(): + assert not isinstance(spec, ComponentSpec), ( + f"service {name} leaked as ComponentSpec" + )