-
Notifications
You must be signed in to change notification settings - Fork 8
Plugin Stuff Round 2 #650
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+278
−48
Merged
Plugin Stuff Round 2 #650
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.