Skip to content

Commit 3bde359

Browse files
author
Aegis Stack
committed
Plugin Stuff Round 2
1 parent ce81827 commit 3bde359

5 files changed

Lines changed: 278 additions & 48 deletions

File tree

aegis/core/components.py

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
used for project generation and validation.
66
"""
77

8-
from dataclasses import dataclass, field
8+
from dataclasses import dataclass
99
from enum import Enum
1010

1111
from .file_manifest import FileManifest
12+
from .plugin_spec import PluginKind, PluginSpec
1213

1314

1415
class ComponentType(Enum):
@@ -30,24 +31,18 @@ class SchedulerBackend(str, Enum):
3031
CORE_COMPONENTS = ["backend", "frontend"]
3132

3233

33-
@dataclass
34-
class ComponentSpec:
35-
"""Specification for a single component."""
34+
@dataclass(kw_only=True)
35+
class ComponentSpec(PluginSpec):
36+
"""Component-flavoured PluginSpec — back-compat alias for pre-R2 callers.
3637
37-
name: str
38-
type: ComponentType
39-
description: str
40-
requires: list[str] = field(default_factory=list) # Hard dependencies
41-
recommends: list[str] = field(default_factory=list) # Soft dependencies
42-
conflicts: list[str] = field(default_factory=list) # Mutual exclusions
43-
docker_services: list[str] = field(default_factory=list)
44-
pyproject_deps: list[str] = field(default_factory=list)
45-
template_files: list[str] = field(default_factory=list)
46-
# R1 file manifest used by cleanup_components(). The legacy
47-
# post_gen_tasks.get_component_file_mapping() dict is still maintained
48-
# separately, so this manifest must be kept aligned with it by hand
49-
# until R2 derives the mapping from manifests. See file_manifest.py.
50-
files: FileManifest = field(default_factory=FileManifest)
38+
Subclasses ``PluginSpec`` and pins ``kind`` to ``COMPONENT`` by default.
39+
Legacy field names ``requires`` / ``recommends`` continue to work for
40+
*read* access via the property aliases on ``PluginSpec``; constructions
41+
in this file use the canonical ``required_components`` /
42+
``recommended_components`` names. R2 of the plugin system refactor.
43+
"""
44+
45+
kind: PluginKind = PluginKind.COMPONENT
5146

5247

5348
# Component registry - single source of truth
@@ -85,7 +80,7 @@ class ComponentSpec:
8580
name="worker",
8681
type=ComponentType.INFRASTRUCTURE,
8782
description="Background task processing (arq, Dramatiq, or TaskIQ)",
88-
requires=["redis"], # Hard dependency
83+
required_components=["redis"], # Hard dependency
8984
pyproject_deps=["arq==0.25.0"],
9085
docker_services=["worker-system", "worker-load-test"],
9186
template_files=["app/components/worker/"],
@@ -154,7 +149,7 @@ class ComponentSpec:
154149
type=ComponentType.INFRASTRUCTURE,
155150
description="Traefik reverse proxy and load balancer",
156151
docker_services=["traefik"],
157-
recommends=["backend"],
152+
recommended_components=["backend"],
158153
files=FileManifest(
159154
primary=[
160155
"traefik",

aegis/core/file_manifest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""
2-
Declarative file manifest per service / component spec.
2+
Declarative file manifest per ``PluginSpec``.
33
44
R1 of the plugin system refactor. Centralises the per-spec file
55
ownership needed for declarative cleanup, replacing the **Pattern A**
66
imperative blocks ("if X is not selected, remove these files") that
77
were scattered through ``aegis/core/post_gen_tasks.py``.
88
9+
Consumed by every ``PluginSpec`` (in-tree services and components today;
10+
third-party plugins under R2+). See ``aegis/core/plugin_spec.py``.
11+
912
What R1 does NOT do (deliberately deferred to R2):
1013
1114
* ``post_gen_tasks.get_component_file_mapping()`` is still a hard-coded

aegis/core/plugin_spec.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""
2+
Unified plugin specification — R2 of the plugin system refactor.
3+
4+
Replaces the parallel ``ServiceSpec`` / ``ComponentSpec`` types with a
5+
single ``PluginSpec`` that covers in-tree services, in-tree components,
6+
and (eventually) third-party plugins.
7+
8+
In-tree services and components are first-party plugins (``verified=True``);
9+
external plugins use the same dataclass with ``verified=False``. The
10+
``ServiceSpec`` and ``ComponentSpec`` aliases in
11+
``aegis/core/services.py`` and ``aegis/core/components.py`` preserve
12+
back-compat with pre-R2 call sites — they are subclasses of ``PluginSpec``
13+
that pin ``kind`` to ``SERVICE`` / ``COMPONENT`` by default.
14+
15+
R2 keeps PluginKind narrow (``SERVICE`` / ``COMPONENT``). Additional
16+
kinds (``INTEGRATION``, ``ENHANCEMENT``, ...) are added when a real
17+
plugin needs them, not speculatively.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from dataclasses import dataclass, field
23+
from enum import Enum
24+
from typing import Any
25+
26+
from .file_manifest import FileManifest
27+
28+
29+
class PluginKind(Enum):
30+
"""Top-level role a ``PluginSpec`` plays in an Aegis project."""
31+
32+
SERVICE = "service"
33+
"""Business-logic plugin (auth, payment, AI, scraping, ...)."""
34+
35+
COMPONENT = "component"
36+
"""Infrastructure plugin (database, redis, worker, frontend, ...)."""
37+
38+
39+
@dataclass(kw_only=True)
40+
class PluginSpec:
41+
"""Unified specification for components, services, and third-party plugins.
42+
43+
Field naming notes:
44+
45+
* ``required_components`` / ``recommended_components`` / ``required_services``
46+
are the canonical dependency-list fields (matches the pre-R2
47+
``ServiceSpec`` convention, which had the higher caller volume).
48+
* ``requires`` and ``recommends`` exist as **read-only** property aliases
49+
so legacy ``ComponentSpec``-style attribute access keeps working
50+
(``component.requires``, ``component.recommends``). Construction via
51+
those names is intentionally not supported — pass the canonical names.
52+
53+
The ``type`` field is a sub-classification facet that is meaningful per
54+
``kind``: when ``kind=SERVICE`` it carries a ``ServiceType`` (AUTH,
55+
PAYMENT, ...); when ``kind=COMPONENT`` it carries a ``ComponentType``
56+
(CORE, INFRASTRUCTURE). Typed as ``Any`` here to avoid a circular
57+
import; consumers narrow as needed.
58+
"""
59+
60+
# Identity
61+
name: str
62+
kind: PluginKind
63+
description: str
64+
65+
# Sub-classification — ServiceType when kind=SERVICE,
66+
# ComponentType when kind=COMPONENT.
67+
type: Any = None
68+
69+
# Dependencies
70+
required_components: list[str] = field(default_factory=list)
71+
recommended_components: list[str] = field(default_factory=list)
72+
required_services: list[str] = field(default_factory=list)
73+
required_plugins: list[str] = field(default_factory=list)
74+
75+
# Mutual exclusion
76+
conflicts: list[str] = field(default_factory=list)
77+
78+
# Packaging / template
79+
pyproject_deps: list[str] = field(default_factory=list)
80+
docker_services: list[str] = field(default_factory=list)
81+
template_files: list[str] = field(default_factory=list)
82+
83+
# File ownership for cleanup (R1)
84+
files: FileManifest = field(default_factory=FileManifest)
85+
86+
# Plugin metadata
87+
version: str = "0.0.0"
88+
verified: bool = True
89+
90+
# ----- Read-only legacy aliases -------------------------------------
91+
92+
@property
93+
def requires(self) -> list[str]:
94+
"""Component-style alias for ``required_components``."""
95+
return self.required_components
96+
97+
@property
98+
def recommends(self) -> list[str]:
99+
"""Component-style alias for ``recommended_components``."""
100+
return self.recommended_components

aegis/core/services.py

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
and metadata used for project generation and validation.
66
"""
77

8-
from dataclasses import dataclass, field
8+
from dataclasses import dataclass
99
from enum import Enum
1010

1111
from ..constants import ComponentNames
1212
from ..i18n import t
1313
from .file_manifest import FileManifest
14+
from .plugin_spec import PluginKind, PluginSpec
1415

1516

1617
class ServiceType(Enum):
@@ -24,32 +25,17 @@ class ServiceType(Enum):
2425
STORAGE = "storage" # File storage and CDN
2526

2627

27-
@dataclass
28-
class ServiceSpec:
29-
"""Specification for a single service."""
30-
31-
name: str
32-
type: ServiceType
33-
description: str
34-
required_components: list[str] = field(
35-
default_factory=list
36-
) # Components this service needs
37-
recommended_components: list[str] = field(
38-
default_factory=list
39-
) # Soft component dependencies
40-
required_services: list[str] = field(
41-
default_factory=list
42-
) # Other services this service needs
43-
conflicts: list[str] = field(default_factory=list) # Mutual exclusions
44-
pyproject_deps: list[str] = field(
45-
default_factory=list
46-
) # Python packages for this service
47-
template_files: list[str] = field(default_factory=list) # Template files to include
48-
# R1 file manifest used by cleanup_components(). The legacy
49-
# post_gen_tasks.get_component_file_mapping() dict is still maintained
50-
# separately, so this manifest must be kept aligned with it by hand
51-
# until R2 derives the mapping from manifests. See file_manifest.py.
52-
files: FileManifest = field(default_factory=FileManifest)
28+
@dataclass(kw_only=True)
29+
class ServiceSpec(PluginSpec):
30+
"""Service-flavoured PluginSpec — back-compat alias for pre-R2 callers.
31+
32+
Subclasses ``PluginSpec`` and pins ``kind`` to ``SERVICE`` by default, so
33+
``ServiceSpec(name=..., type=..., description=...)`` still works without
34+
naming the kind. R2 of the plugin system refactor; see
35+
``aegis/core/plugin_spec.py`` for the unified type.
36+
"""
37+
38+
kind: PluginKind = PluginKind.SERVICE
5339

5440

5541
# Service registry - single source of truth for all available services

tests/core/test_plugin_spec.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""
2+
Tests for the unified PluginSpec contract (R2 of plugin refactor).
3+
4+
These tests lock in:
5+
* the dataclass shape (required vs default fields, sensible defaults)
6+
* back-compat aliases (ServiceSpec / ComponentSpec subclass behaviour,
7+
requires / recommends read-only properties)
8+
* registry-shape guarantees (every in-tree spec is a PluginSpec with
9+
a sane kind and verified=True).
10+
"""
11+
12+
import pytest
13+
14+
from aegis.core.components import COMPONENTS, ComponentSpec, ComponentType
15+
from aegis.core.file_manifest import FileManifest
16+
from aegis.core.plugin_spec import PluginKind, PluginSpec
17+
from aegis.core.services import SERVICES, ServiceSpec, ServiceType
18+
19+
20+
class TestPluginSpec:
21+
"""The unified dataclass itself."""
22+
23+
def test_minimal_construction(self) -> None:
24+
spec = PluginSpec(name="x", kind=PluginKind.SERVICE, description="d")
25+
assert spec.name == "x"
26+
assert spec.kind is PluginKind.SERVICE
27+
assert spec.description == "d"
28+
assert spec.type is None
29+
assert spec.required_components == []
30+
assert spec.recommended_components == []
31+
assert spec.required_services == []
32+
assert spec.required_plugins == []
33+
assert spec.conflicts == []
34+
assert spec.pyproject_deps == []
35+
assert spec.docker_services == []
36+
assert spec.template_files == []
37+
assert isinstance(spec.files, FileManifest)
38+
assert spec.version == "0.0.0"
39+
assert spec.verified is True
40+
41+
def test_kind_is_required(self) -> None:
42+
with pytest.raises(TypeError):
43+
PluginSpec(name="x", description="d") # type: ignore[call-arg]
44+
45+
def test_independent_default_lists(self) -> None:
46+
a = PluginSpec(name="a", kind=PluginKind.SERVICE, description="")
47+
b = PluginSpec(name="b", kind=PluginKind.SERVICE, description="")
48+
a.required_components.append("redis")
49+
assert b.required_components == []
50+
51+
def test_requires_alias_reads_required_components(self) -> None:
52+
spec = PluginSpec(
53+
name="x",
54+
kind=PluginKind.COMPONENT,
55+
description="d",
56+
required_components=["redis", "database"],
57+
)
58+
assert spec.requires == ["redis", "database"]
59+
assert spec.requires is spec.required_components # same list
60+
61+
def test_recommends_alias_reads_recommended_components(self) -> None:
62+
spec = PluginSpec(
63+
name="x",
64+
kind=PluginKind.COMPONENT,
65+
description="d",
66+
recommended_components=["backend"],
67+
)
68+
assert spec.recommends == ["backend"]
69+
assert spec.recommends is spec.recommended_components
70+
71+
def test_unverified_third_party_default_pattern(self) -> None:
72+
"""A third-party plugin would set ``verified=False`` explicitly."""
73+
spec = PluginSpec(
74+
name="aegis-plugin-scraper",
75+
kind=PluginKind.SERVICE,
76+
description="Web scraping",
77+
verified=False,
78+
version="0.1.0",
79+
required_plugins=["auth>=1.0"],
80+
)
81+
assert spec.verified is False
82+
assert spec.version == "0.1.0"
83+
assert spec.required_plugins == ["auth>=1.0"]
84+
85+
86+
class TestSubclassAliases:
87+
"""ServiceSpec and ComponentSpec are PluginSpec subclasses with a
88+
pre-baked ``kind`` default. Pre-R2 call sites rely on this.
89+
"""
90+
91+
def test_servicespec_defaults_kind_service(self) -> None:
92+
spec = ServiceSpec(name="x", type=ServiceType.AUTH, description="d")
93+
assert spec.kind is PluginKind.SERVICE
94+
assert isinstance(spec, PluginSpec)
95+
assert isinstance(spec, ServiceSpec)
96+
97+
def test_componentspec_defaults_kind_component(self) -> None:
98+
spec = ComponentSpec(
99+
name="x", type=ComponentType.INFRASTRUCTURE, description="d"
100+
)
101+
assert spec.kind is PluginKind.COMPONENT
102+
assert isinstance(spec, PluginSpec)
103+
assert isinstance(spec, ComponentSpec)
104+
105+
def test_servicespec_can_override_kind(self) -> None:
106+
"""Just because — ensures the default is overridable, not pinned."""
107+
spec = ServiceSpec(
108+
name="x",
109+
kind=PluginKind.COMPONENT,
110+
type=ServiceType.AUTH,
111+
description="d",
112+
)
113+
assert spec.kind is PluginKind.COMPONENT
114+
115+
116+
class TestRegistryShape:
117+
"""In-tree services and components are first-party plugins."""
118+
119+
def test_every_service_is_first_party(self) -> None:
120+
for name, spec in SERVICES.items():
121+
assert isinstance(spec, ServiceSpec), f"{name} not a ServiceSpec"
122+
assert isinstance(spec, PluginSpec)
123+
assert spec.kind is PluginKind.SERVICE, name
124+
assert spec.verified is True, f"{name} should be verified=True"
125+
assert isinstance(spec.type, ServiceType), f"{name} type wrong"
126+
127+
def test_every_component_is_first_party(self) -> None:
128+
for name, spec in COMPONENTS.items():
129+
assert isinstance(spec, ComponentSpec), f"{name} not a ComponentSpec"
130+
assert isinstance(spec, PluginSpec)
131+
assert spec.kind is PluginKind.COMPONENT, name
132+
assert spec.verified is True, f"{name} should be verified=True"
133+
assert isinstance(spec.type, ComponentType), f"{name} type wrong"
134+
135+
def test_no_servicespec_in_components(self) -> None:
136+
"""ComponentSpec instances are NOT ServiceSpec instances (sibling subclasses)."""
137+
for name, spec in COMPONENTS.items():
138+
assert not isinstance(spec, ServiceSpec), (
139+
f"component {name} leaked as ServiceSpec"
140+
)
141+
142+
def test_no_componentspec_in_services(self) -> None:
143+
for name, spec in SERVICES.items():
144+
assert not isinstance(spec, ComponentSpec), (
145+
f"service {name} leaked as ComponentSpec"
146+
)

0 commit comments

Comments
 (0)