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
35 changes: 15 additions & 20 deletions aegis/core/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -30,24 +31,18 @@ class SchedulerBackend(str, Enum):
CORE_COMPONENTS = ["backend", "frontend"]


@dataclass
class ComponentSpec:
"""Specification for a single component."""
@dataclass(kw_only=True)
Comment thread
lbedner marked this conversation as resolved.
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
Expand Down Expand Up @@ -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/"],
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion aegis/core/file_manifest.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
100 changes: 100 additions & 0 deletions aegis/core/plugin_spec.py
Original file line number Diff line number Diff line change
@@ -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
40 changes: 13 additions & 27 deletions aegis/core/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Comment thread
lbedner marked this conversation as resolved.
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
Expand Down
146 changes: 146 additions & 0 deletions tests/core/test_plugin_spec.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading