gaard-plugin-api 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: gaard-plugin-api
3
+ Version: 0.1.0
4
+ Summary: Versioned extension contracts and discovery for GAARD
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: packaging>=24.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
10
+ Requires-Dist: ruff>=0.5.0; extra == "dev"
11
+ Requires-Dist: mypy>=1.10.0; extra == "dev"
12
+
13
+ # gaard-plugin-api
14
+
15
+ `gaard-plugin-api` provides the stable, dependency-light contracts used by
16
+ GAARD extension packages. It defines extension manifests, discovery through
17
+ Python entry points, compatibility validation, and contribution activation.
18
+
19
+ An extension is trusted, installed Python code. GAARD does not load executable
20
+ extensions from database records, configuration files, or arbitrary URLs.
@@ -0,0 +1,8 @@
1
+ # gaard-plugin-api
2
+
3
+ `gaard-plugin-api` provides the stable, dependency-light contracts used by
4
+ GAARD extension packages. It defines extension manifests, discovery through
5
+ Python entry points, compatibility validation, and contribution activation.
6
+
7
+ An extension is trusted, installed Python code. GAARD does not load executable
8
+ extensions from database records, configuration files, or arbitrary URLs.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gaard-plugin-api"
7
+ version = "0.1.0"
8
+ description = "Versioned extension contracts and discovery for GAARD"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "packaging>=24.0",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest>=8.0.0",
18
+ "ruff>=0.5.0",
19
+ "mypy>=1.10.0",
20
+ ]
21
+
22
+ [tool.ruff]
23
+ line-length = 100
24
+ target-version = "py311"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,29 @@
1
+ from gaard_plugin_api.discovery import (
2
+ EXTENSION_ENTRY_POINT_GROUP,
3
+ ExtensionManager,
4
+ discover_extensions,
5
+ )
6
+ from gaard_plugin_api.models import (
7
+ EXTENSION_API_VERSION,
8
+ ExtensionActivationError,
9
+ ExtensionCompatibilityError,
10
+ ExtensionContext,
11
+ ExtensionManifest,
12
+ ExtensionManifestError,
13
+ ExtensionRecord,
14
+ ExtensionStatus,
15
+ )
16
+
17
+ __all__ = [
18
+ "EXTENSION_API_VERSION",
19
+ "EXTENSION_ENTRY_POINT_GROUP",
20
+ "ExtensionActivationError",
21
+ "ExtensionCompatibilityError",
22
+ "ExtensionContext",
23
+ "ExtensionManager",
24
+ "ExtensionManifest",
25
+ "ExtensionManifestError",
26
+ "ExtensionRecord",
27
+ "ExtensionStatus",
28
+ "discover_extensions",
29
+ ]
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterable, Mapping
4
+ from importlib import import_module
5
+ from importlib.metadata import PackageNotFoundError, entry_points, version
6
+ from typing import Any, Protocol, cast
7
+
8
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
9
+
10
+ from gaard_plugin_api.models import (
11
+ EXTENSION_API_VERSION,
12
+ ExtensionActivationError,
13
+ ExtensionCompatibilityError,
14
+ ExtensionContext,
15
+ ExtensionManifest,
16
+ ExtensionManifestError,
17
+ ExtensionRecord,
18
+ ExtensionStatus,
19
+ )
20
+
21
+
22
+ EXTENSION_ENTRY_POINT_GROUP = "gaard.extensions"
23
+
24
+
25
+ class EntryPointLike(Protocol):
26
+ name: str
27
+
28
+ def load(self) -> Any: ...
29
+
30
+
31
+ ContributionLoader = Callable[[str], Callable[[ExtensionContext], None]]
32
+ PackageVersionResolver = Callable[[str], str]
33
+
34
+
35
+ def discover_extensions(
36
+ entry_point_items: Iterable[EntryPointLike] | None = None,
37
+ package_version: PackageVersionResolver = version,
38
+ ) -> list[ExtensionRecord]:
39
+ """Discover installed manifests without activating their contributions."""
40
+
41
+ selected_entry_points = (
42
+ list(entry_point_items)
43
+ if entry_point_items is not None
44
+ else list(entry_points(group=EXTENSION_ENTRY_POINT_GROUP))
45
+ )
46
+ records: list[ExtensionRecord] = []
47
+
48
+ for entry_point in sorted(selected_entry_points, key=lambda item: item.name):
49
+ record = ExtensionRecord(entry_point_name=entry_point.name)
50
+ records.append(record)
51
+
52
+ try:
53
+ manifest = _load_manifest(entry_point)
54
+ _validate_compatibility(manifest, package_version)
55
+ except (
56
+ ExtensionCompatibilityError,
57
+ ExtensionManifestError,
58
+ PackageNotFoundError,
59
+ TypeError,
60
+ ValueError,
61
+ ) as exc:
62
+ record.status = ExtensionStatus.FAILED
63
+ record.error = str(exc)
64
+ continue
65
+ except Exception as exc: # pragma: no cover - defensive boundary for third-party code
66
+ record.status = ExtensionStatus.FAILED
67
+ record.error = f"Unable to load extension manifest: {exc}"
68
+ continue
69
+
70
+ record.manifest = manifest
71
+ record.status = ExtensionStatus.VALIDATED
72
+
73
+ return records
74
+
75
+
76
+ class ExtensionManager:
77
+ """Discovers and activates typed extension contributions for one host process."""
78
+
79
+ def __init__(
80
+ self,
81
+ entry_point_items: Iterable[EntryPointLike] | None = None,
82
+ package_version: PackageVersionResolver = version,
83
+ contribution_loader: ContributionLoader | None = None,
84
+ ) -> None:
85
+ self._entry_point_items = entry_point_items
86
+ self._package_version = package_version
87
+ self._contribution_loader = contribution_loader or load_contribution
88
+ self.records: list[ExtensionRecord] = []
89
+
90
+ def discover(self) -> list[ExtensionRecord]:
91
+ self.records = discover_extensions(
92
+ entry_point_items=self._entry_point_items,
93
+ package_version=self._package_version,
94
+ )
95
+ return self.records
96
+
97
+ def activate(
98
+ self,
99
+ capability: str,
100
+ registry: Any,
101
+ services: Mapping[str, Any] | None = None,
102
+ ) -> list[ExtensionRecord]:
103
+ """Activate one capability for every validated extension that declares it."""
104
+
105
+ if not self.records:
106
+ self.discover()
107
+
108
+ activated: list[ExtensionRecord] = []
109
+
110
+ for record in self.records:
111
+ if record.status not in {ExtensionStatus.VALIDATED, ExtensionStatus.ACTIVE}:
112
+ continue
113
+ if record.manifest is None or capability not in record.manifest.contributions:
114
+ continue
115
+ if capability in record.active_capabilities:
116
+ continue
117
+
118
+ target = record.manifest.contributions[capability]
119
+ context = ExtensionContext(
120
+ extension_id=record.manifest.id,
121
+ capability=capability,
122
+ registry=registry,
123
+ services=services or {},
124
+ )
125
+
126
+ try:
127
+ contribution = self._contribution_loader(target)
128
+ contribution(context)
129
+ except Exception as exc: # pragma: no cover - defensive boundary for third-party code
130
+ record.status = ExtensionStatus.FAILED
131
+ record.error = f"Unable to activate {capability!r} contribution: {exc}"
132
+ continue
133
+
134
+ record.active_capabilities.add(capability)
135
+ record.status = ExtensionStatus.ACTIVE
136
+ activated.append(record)
137
+
138
+ return activated
139
+
140
+
141
+ def load_contribution(target: str) -> Callable[[ExtensionContext], None]:
142
+ """Import one declared contribution factory from a `module:attribute` target."""
143
+
144
+ module_name, separator, attribute_name = target.partition(":")
145
+ if not separator or not module_name or not attribute_name:
146
+ raise ExtensionActivationError(
147
+ "Extension contribution targets must use the 'module:attribute' format."
148
+ )
149
+
150
+ module = import_module(module_name)
151
+ contribution = getattr(module, attribute_name)
152
+
153
+ if not callable(contribution):
154
+ raise ExtensionActivationError(f"Extension contribution {target!r} is not callable.")
155
+
156
+ return cast(Callable[[ExtensionContext], None], contribution)
157
+
158
+
159
+ def _load_manifest(entry_point: EntryPointLike) -> ExtensionManifest:
160
+ candidate = entry_point.load()
161
+ manifest = candidate() if callable(candidate) else candidate
162
+
163
+ if not isinstance(manifest, ExtensionManifest):
164
+ raise ExtensionManifestError(
165
+ f"Entry point {entry_point.name!r} must resolve to an ExtensionManifest or a factory."
166
+ )
167
+
168
+ return manifest
169
+
170
+
171
+ def _validate_compatibility(
172
+ manifest: ExtensionManifest,
173
+ package_version: PackageVersionResolver,
174
+ ) -> None:
175
+ if manifest.extension_api_version != EXTENSION_API_VERSION:
176
+ raise ExtensionCompatibilityError(
177
+ f"Extension {manifest.id!r} requires extension API "
178
+ f"{manifest.extension_api_version!r}, but host supports {EXTENSION_API_VERSION!r}."
179
+ )
180
+
181
+ for package_name, version_specifier in manifest.requires.items():
182
+ try:
183
+ requirement = SpecifierSet(version_specifier)
184
+ except InvalidSpecifier as exc:
185
+ raise ExtensionCompatibilityError(
186
+ f"Extension {manifest.id!r} has invalid version specifier "
187
+ f"{version_specifier!r} for {package_name!r}."
188
+ ) from exc
189
+
190
+ installed_version = package_version(package_name)
191
+ if installed_version not in requirement:
192
+ raise ExtensionCompatibilityError(
193
+ f"Extension {manifest.id!r} requires {package_name}{version_specifier}, "
194
+ f"but {installed_version} is installed."
195
+ )
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from types import MappingProxyType
8
+ from typing import Any
9
+
10
+
11
+ EXTENSION_API_VERSION = "1"
12
+ _EXTENSION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
13
+
14
+
15
+ class ExtensionManifestError(ValueError):
16
+ """Raised when an extension manifest does not satisfy the public contract."""
17
+
18
+
19
+ class ExtensionCompatibilityError(RuntimeError):
20
+ """Raised when an installed extension is incompatible with the host."""
21
+
22
+
23
+ class ExtensionActivationError(RuntimeError):
24
+ """Raised when an extension contribution cannot be activated."""
25
+
26
+
27
+ class ExtensionStatus(StrEnum):
28
+ DISCOVERED = "discovered"
29
+ VALIDATED = "validated"
30
+ ACTIVE = "active"
31
+ DISABLED = "disabled"
32
+ FAILED = "failed"
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class ExtensionManifest:
37
+ """Public metadata describing one installed GAARD extension."""
38
+
39
+ id: str
40
+ version: str
41
+ extension_api_version: str = EXTENSION_API_VERSION
42
+ requires: Mapping[str, str] = field(default_factory=dict)
43
+ contributions: Mapping[str, str] = field(default_factory=dict)
44
+
45
+ def __post_init__(self) -> None:
46
+ if not _EXTENSION_ID_PATTERN.fullmatch(self.id):
47
+ raise ExtensionManifestError(
48
+ "Extension id must use lowercase letters, digits, and hyphens, "
49
+ "and must start with a letter."
50
+ )
51
+
52
+ if not self.version.strip():
53
+ raise ExtensionManifestError("Extension version must not be empty.")
54
+
55
+ for package_name, version_specifier in self.requires.items():
56
+ if not package_name.strip() or not version_specifier.strip():
57
+ raise ExtensionManifestError(
58
+ "Extension package requirements must contain a package name and version specifier."
59
+ )
60
+
61
+ for capability, target in self.contributions.items():
62
+ if not capability.strip():
63
+ raise ExtensionManifestError("Extension contribution capability must not be empty.")
64
+ if not _is_import_target(target):
65
+ raise ExtensionManifestError(
66
+ "Extension contribution targets must use the 'module:attribute' format."
67
+ )
68
+
69
+ object.__setattr__(self, "requires", MappingProxyType(dict(self.requires)))
70
+ object.__setattr__(self, "contributions", MappingProxyType(dict(self.contributions)))
71
+
72
+
73
+ @dataclass(slots=True)
74
+ class ExtensionRecord:
75
+ """Runtime state for a discovered extension."""
76
+
77
+ entry_point_name: str
78
+ manifest: ExtensionManifest | None = None
79
+ status: ExtensionStatus = ExtensionStatus.DISCOVERED
80
+ error: str | None = None
81
+ active_capabilities: set[str] = field(default_factory=set)
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class ExtensionContext:
86
+ """Controlled context supplied to one extension contribution factory."""
87
+
88
+ extension_id: str
89
+ capability: str
90
+ registry: Any
91
+ services: Mapping[str, Any] = field(default_factory=dict)
92
+
93
+ def __post_init__(self) -> None:
94
+ object.__setattr__(self, "services", MappingProxyType(dict(self.services)))
95
+
96
+
97
+ def _is_import_target(value: str) -> bool:
98
+ module_name, separator, attribute_name = value.partition(":")
99
+ return bool(separator and module_name.strip() and attribute_name.strip())
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: gaard-plugin-api
3
+ Version: 0.1.0
4
+ Summary: Versioned extension contracts and discovery for GAARD
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: packaging>=24.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
10
+ Requires-Dist: ruff>=0.5.0; extra == "dev"
11
+ Requires-Dist: mypy>=1.10.0; extra == "dev"
12
+
13
+ # gaard-plugin-api
14
+
15
+ `gaard-plugin-api` provides the stable, dependency-light contracts used by
16
+ GAARD extension packages. It defines extension manifests, discovery through
17
+ Python entry points, compatibility validation, and contribution activation.
18
+
19
+ An extension is trusted, installed Python code. GAARD does not load executable
20
+ extensions from database records, configuration files, or arbitrary URLs.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/gaard_plugin_api/__init__.py
4
+ src/gaard_plugin_api/discovery.py
5
+ src/gaard_plugin_api/models.py
6
+ src/gaard_plugin_api.egg-info/PKG-INFO
7
+ src/gaard_plugin_api.egg-info/SOURCES.txt
8
+ src/gaard_plugin_api.egg-info/dependency_links.txt
9
+ src/gaard_plugin_api.egg-info/requires.txt
10
+ src/gaard_plugin_api.egg-info/top_level.txt
11
+ tests/test_discovery.py
@@ -0,0 +1,6 @@
1
+ packaging>=24.0
2
+
3
+ [dev]
4
+ pytest>=8.0.0
5
+ ruff>=0.5.0
6
+ mypy>=1.10.0
@@ -0,0 +1 @@
1
+ gaard_plugin_api
@@ -0,0 +1,79 @@
1
+ from dataclasses import dataclass
2
+
3
+ from gaard_plugin_api import (
4
+ EXTENSION_API_VERSION,
5
+ ExtensionContext,
6
+ ExtensionManager,
7
+ ExtensionManifest,
8
+ ExtensionStatus,
9
+ discover_extensions,
10
+ )
11
+
12
+
13
+ @dataclass
14
+ class FakeEntryPoint:
15
+ name: str
16
+ value: object
17
+
18
+ def load(self) -> object:
19
+ return self.value
20
+
21
+
22
+ def test_discovery_validates_manifests_and_package_compatibility() -> None:
23
+ manifest = ExtensionManifest(
24
+ id="acme-warehouse",
25
+ version="1.2.3",
26
+ requires={"gaard-connectors": ">=0.1,<0.2"},
27
+ )
28
+
29
+ records = discover_extensions(
30
+ [FakeEntryPoint("acme", manifest)],
31
+ package_version=lambda package_name: "0.1.0",
32
+ )
33
+
34
+ assert len(records) == 1
35
+ assert records[0].status == ExtensionStatus.VALIDATED
36
+ assert records[0].manifest == manifest
37
+
38
+
39
+ def test_discovery_reports_incompatible_extensions_without_raising() -> None:
40
+ manifest = ExtensionManifest(
41
+ id="future-extension",
42
+ version="1.0.0",
43
+ extension_api_version="999",
44
+ )
45
+
46
+ records = discover_extensions([FakeEntryPoint("future", manifest)])
47
+
48
+ assert records[0].status == ExtensionStatus.FAILED
49
+ assert records[0].error is not None
50
+ assert "requires extension API" in records[0].error
51
+
52
+
53
+ def test_manager_activates_only_declared_capability() -> None:
54
+ received_contexts: list[ExtensionContext] = []
55
+
56
+ def register(context: ExtensionContext) -> None:
57
+ received_contexts.append(context)
58
+
59
+ manifest = ExtensionManifest(
60
+ id="acme-connector",
61
+ version="1.0.0",
62
+ extension_api_version=EXTENSION_API_VERSION,
63
+ contributions={"connectors": "acme.module:register"},
64
+ )
65
+ manager = ExtensionManager(
66
+ entry_point_items=[FakeEntryPoint("acme", manifest)],
67
+ contribution_loader=lambda target: register,
68
+ )
69
+ registry = object()
70
+
71
+ activated = manager.activate("connectors", registry, services={"mode": "test"})
72
+
73
+ assert [record.manifest.id for record in activated if record.manifest] == ["acme-connector"]
74
+ assert received_contexts[0].registry is registry
75
+ assert received_contexts[0].services == {"mode": "test"}
76
+ assert manager.records[0].status == ExtensionStatus.ACTIVE
77
+ assert manager.records[0].active_capabilities == {"connectors"}
78
+
79
+ assert manager.activate("llm", registry) == []