gaard-plugin-api 0.1.0__py3-none-any.whl
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.
- gaard_plugin_api/__init__.py +29 -0
- gaard_plugin_api/discovery.py +195 -0
- gaard_plugin_api/models.py +99 -0
- gaard_plugin_api-0.1.0.dist-info/METADATA +20 -0
- gaard_plugin_api-0.1.0.dist-info/RECORD +7 -0
- gaard_plugin_api-0.1.0.dist-info/WHEEL +5 -0
- gaard_plugin_api-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,7 @@
|
|
|
1
|
+
gaard_plugin_api/__init__.py,sha256=nx8rv6UBQmkucHTwGWhq8E46i3qyjZ3ueplX6c8_lUY,690
|
|
2
|
+
gaard_plugin_api/discovery.py,sha256=aSl6uHW6bF0L5keeVlzw2GvY5Agy-mhU5OkmcFhTi5Y,6800
|
|
3
|
+
gaard_plugin_api/models.py,sha256=QwFlOIdEJZTvozzh5i_CI32dSz96QCmyLPeijBhhVBA,3367
|
|
4
|
+
gaard_plugin_api-0.1.0.dist-info/METADATA,sha256=OuZdx9GuC0FD8go5gR89pX866B6JS3j555Q2V3B0Wf8,772
|
|
5
|
+
gaard_plugin_api-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
gaard_plugin_api-0.1.0.dist-info/top_level.txt,sha256=Ta4rk_wVsVyWJ1AeClMcgCu6ZzoZUYO597tfRBAe_pY,17
|
|
7
|
+
gaard_plugin_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gaard_plugin_api
|