prodkit 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.
- prodkit/__init__.py +57 -0
- prodkit/contracts/__init__.py +0 -0
- prodkit/contracts/plugin.py +61 -0
- prodkit/core/__init__.py +0 -0
- prodkit/core/config.py +245 -0
- prodkit/core/context.py +53 -0
- prodkit/core/event_bus.py +42 -0
- prodkit/core/exceptions.py +23 -0
- prodkit/core/lifecycle.py +54 -0
- prodkit/core/plugin_manager.py +52 -0
- prodkit/core/production.py +123 -0
- prodkit/core/registry.py +38 -0
- prodkit/plugins/__init__.py +54 -0
- prodkit/plugins/compression/__init__.py +22 -0
- prodkit/plugins/cors/__init__.py +38 -0
- prodkit/plugins/errors/__init__.py +98 -0
- prodkit/plugins/health/__init__.py +72 -0
- prodkit/plugins/logging/__init__.py +116 -0
- prodkit/plugins/request_id/__init__.py +61 -0
- prodkit/plugins/security/__init__.py +78 -0
- prodkit/py.typed +0 -0
- prodkit-0.1.0.dist-info/METADATA +264 -0
- prodkit-0.1.0.dist-info/RECORD +25 -0
- prodkit-0.1.0.dist-info/WHEEL +4 -0
- prodkit-0.1.0.dist-info/licenses/LICENSE +21 -0
prodkit/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""ProdKit — the production framework for FastAPI.
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from prodkit import Production
|
|
5
|
+
|
|
6
|
+
app = FastAPI()
|
|
7
|
+
Production(app)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from prodkit.contracts.plugin import Check, Plugin
|
|
11
|
+
from prodkit.core.config import (
|
|
12
|
+
CompressionConfig,
|
|
13
|
+
CORSConfig,
|
|
14
|
+
ErrorsConfig,
|
|
15
|
+
HealthConfig,
|
|
16
|
+
LoggingConfig,
|
|
17
|
+
ProdKitConfig,
|
|
18
|
+
RequestIDConfig,
|
|
19
|
+
SecurityConfig,
|
|
20
|
+
)
|
|
21
|
+
from prodkit.core.context import Context
|
|
22
|
+
from prodkit.core.exceptions import (
|
|
23
|
+
PluginDependencyError,
|
|
24
|
+
PluginError,
|
|
25
|
+
ProdKitConfigError,
|
|
26
|
+
ProdKitError,
|
|
27
|
+
ServiceNotFoundError,
|
|
28
|
+
)
|
|
29
|
+
from prodkit.core.production import Production, set_builtin_factory
|
|
30
|
+
from prodkit.plugins import builtin_plugins
|
|
31
|
+
|
|
32
|
+
# Wire the built-in plugins into the kernel here, at the package composition
|
|
33
|
+
# root — the kernel itself never imports from prodkit.plugins.
|
|
34
|
+
set_builtin_factory(builtin_plugins)
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"CORSConfig",
|
|
40
|
+
"Check",
|
|
41
|
+
"CompressionConfig",
|
|
42
|
+
"Context",
|
|
43
|
+
"ErrorsConfig",
|
|
44
|
+
"HealthConfig",
|
|
45
|
+
"LoggingConfig",
|
|
46
|
+
"Plugin",
|
|
47
|
+
"PluginDependencyError",
|
|
48
|
+
"PluginError",
|
|
49
|
+
"ProdKitConfig",
|
|
50
|
+
"ProdKitConfigError",
|
|
51
|
+
"ProdKitError",
|
|
52
|
+
"Production",
|
|
53
|
+
"RequestIDConfig",
|
|
54
|
+
"SecurityConfig",
|
|
55
|
+
"ServiceNotFoundError",
|
|
56
|
+
"__version__",
|
|
57
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The Plugin contract every ProdKit plugin implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from prodkit.core.context import Context
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Check:
|
|
14
|
+
"""A readiness/doctor check result."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
passed: bool
|
|
18
|
+
detail: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Plugin:
|
|
22
|
+
"""Base class for all ProdKit plugins. All hooks are optional overrides.
|
|
23
|
+
|
|
24
|
+
Hooks run in dependency order (see ``requires``); ``shutdown`` runs in
|
|
25
|
+
reverse activation order (LIFO).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
name: ClassVar[str] = ""
|
|
29
|
+
requires: ClassVar[tuple[str, ...]] = ()
|
|
30
|
+
|
|
31
|
+
def configure(self, ctx: Context) -> None:
|
|
32
|
+
"""Validate and resolve configuration. Raise ProdKitConfigError to
|
|
33
|
+
abort boot with a clear message."""
|
|
34
|
+
|
|
35
|
+
def register_middleware(self, ctx: Context) -> None:
|
|
36
|
+
"""Register middleware via ``ctx.add_middleware(cls, priority=N, ...)``."""
|
|
37
|
+
|
|
38
|
+
def register_routes(self, ctx: Context) -> None:
|
|
39
|
+
"""Add routes to ``ctx.app`` (e.g. /health)."""
|
|
40
|
+
|
|
41
|
+
async def startup(self, ctx: Context) -> None:
|
|
42
|
+
"""Acquire async resources (connection pools, clients)."""
|
|
43
|
+
|
|
44
|
+
async def shutdown(self, ctx: Context) -> None:
|
|
45
|
+
"""Release resources gracefully."""
|
|
46
|
+
|
|
47
|
+
def checks(self, ctx: Context) -> list[Check]:
|
|
48
|
+
"""Readiness checks, aggregated by the health plugin's /ready."""
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Documented middleware priorities for the built-ins. Lower = outermost.
|
|
53
|
+
PRIORITY_REQUEST_ID = 100
|
|
54
|
+
PRIORITY_LOGGING = 200
|
|
55
|
+
PRIORITY_ERRORS = 250
|
|
56
|
+
PRIORITY_METRICS = 300
|
|
57
|
+
PRIORITY_SECURITY = 400
|
|
58
|
+
PRIORITY_CORS = 500
|
|
59
|
+
PRIORITY_RATE_LIMIT = 600
|
|
60
|
+
PRIORITY_COMPRESSION = 700
|
|
61
|
+
PRIORITY_AUTH = 800
|
prodkit/core/__init__.py
ADDED
|
File without changes
|
prodkit/core/config.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Layered configuration.
|
|
2
|
+
|
|
3
|
+
Resolution priority (highest wins):
|
|
4
|
+
1. Python arguments to ``Production(...)``
|
|
5
|
+
2. Environment variables (``PRODKIT_*``, ``__`` as section delimiter)
|
|
6
|
+
3. ``prodkit.toml``
|
|
7
|
+
4. Environment-profile defaults (development / staging / production)
|
|
8
|
+
5. Library defaults declared on the models below
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Literal
|
|
17
|
+
|
|
18
|
+
if sys.version_info >= (3, 11):
|
|
19
|
+
import tomllib
|
|
20
|
+
else: # pragma: no cover - exercised on Python 3.10 in CI
|
|
21
|
+
import tomli as tomllib
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
24
|
+
|
|
25
|
+
from prodkit.core.exceptions import ProdKitConfigError
|
|
26
|
+
|
|
27
|
+
Environment = Literal["development", "staging", "production"]
|
|
28
|
+
|
|
29
|
+
_ENV_PREFIX = "PRODKIT_"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Section(BaseModel):
|
|
33
|
+
"""Base for config sections: unknown keys are a hard error (fail fast)."""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LoggingConfig(_Section):
|
|
39
|
+
enabled: bool = True
|
|
40
|
+
level: str = "INFO"
|
|
41
|
+
format: Literal["json", "console"] = "json"
|
|
42
|
+
include_request_body: bool = False # off by default: bodies may contain secrets/PII
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RequestIDConfig(_Section):
|
|
46
|
+
enabled: bool = True
|
|
47
|
+
header: str = "X-Request-ID"
|
|
48
|
+
# Trusting inbound IDs lets clients forge/poison log correlation, so
|
|
49
|
+
# only honor them from a proxy you control.
|
|
50
|
+
trust_incoming: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ErrorsConfig(_Section):
|
|
54
|
+
enabled: bool = True
|
|
55
|
+
# Debug tracebacks in responses are opt-in and refused in production (see checks below).
|
|
56
|
+
include_debug_details: bool = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HealthConfig(_Section):
|
|
60
|
+
enabled: bool = True
|
|
61
|
+
health_path: str = "/health"
|
|
62
|
+
ready_path: str = "/ready"
|
|
63
|
+
live_path: str = "/live"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SecurityConfig(_Section):
|
|
67
|
+
enabled: bool = True
|
|
68
|
+
hsts: bool = True
|
|
69
|
+
hsts_max_age: int = 63072000 # 2 years, preload-eligible
|
|
70
|
+
frame_options: Literal["DENY", "SAMEORIGIN"] = "DENY"
|
|
71
|
+
referrer_policy: str = "strict-origin-when-cross-origin"
|
|
72
|
+
content_security_policy: str | None = None # opt-in: app-specific
|
|
73
|
+
permissions_policy: str = "camera=(), microphone=(), geolocation=()"
|
|
74
|
+
trusted_hosts: list[str] = Field(default_factory=list)
|
|
75
|
+
https_redirect: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CORSConfig(_Section):
|
|
79
|
+
enabled: bool = False
|
|
80
|
+
origins: list[str] = Field(default_factory=list)
|
|
81
|
+
allow_credentials: bool = False
|
|
82
|
+
allow_methods: list[str] = Field(default_factory=lambda: ["GET", "POST", "PUT", "DELETE"])
|
|
83
|
+
allow_headers: list[str] = Field(default_factory=lambda: ["Authorization", "Content-Type"])
|
|
84
|
+
max_age: int = 600
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CompressionConfig(_Section):
|
|
88
|
+
enabled: bool = True
|
|
89
|
+
minimum_size: int = 500 # bytes; don't waste CPU on tiny responses
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ProdKitConfig(_Section):
|
|
93
|
+
"""Fully resolved, validated ProdKit configuration."""
|
|
94
|
+
|
|
95
|
+
environment: Environment = "production"
|
|
96
|
+
debug: bool = False
|
|
97
|
+
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
98
|
+
request_id: RequestIDConfig = Field(default_factory=RequestIDConfig)
|
|
99
|
+
errors: ErrorsConfig = Field(default_factory=ErrorsConfig)
|
|
100
|
+
health: HealthConfig = Field(default_factory=HealthConfig)
|
|
101
|
+
security: SecurityConfig = Field(default_factory=SecurityConfig)
|
|
102
|
+
cors: CORSConfig = Field(default_factory=CORSConfig)
|
|
103
|
+
compression: CompressionConfig = Field(default_factory=CompressionConfig)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Profile defaults: applied beneath toml/env/args. The one-liner must be
|
|
107
|
+
# pleasant in development and hardened in production.
|
|
108
|
+
_PROFILE_DEFAULTS: dict[Environment, dict[str, Any]] = {
|
|
109
|
+
"development": {
|
|
110
|
+
"debug": True,
|
|
111
|
+
"logging": {"level": "DEBUG", "format": "console"},
|
|
112
|
+
"security": {"hsts": False, "https_redirect": False},
|
|
113
|
+
"errors": {"include_debug_details": True},
|
|
114
|
+
},
|
|
115
|
+
"staging": {
|
|
116
|
+
"logging": {"format": "json"},
|
|
117
|
+
},
|
|
118
|
+
"production": {
|
|
119
|
+
"debug": False,
|
|
120
|
+
"logging": {"format": "json"},
|
|
121
|
+
"security": {"hsts": True},
|
|
122
|
+
"errors": {"include_debug_details": False},
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
128
|
+
merged = dict(base)
|
|
129
|
+
for key, value in override.items():
|
|
130
|
+
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
|
131
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
132
|
+
else:
|
|
133
|
+
merged[key] = value
|
|
134
|
+
return merged
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
138
|
+
if not path.is_file():
|
|
139
|
+
return {}
|
|
140
|
+
try:
|
|
141
|
+
with path.open("rb") as fh:
|
|
142
|
+
data = tomllib.load(fh)
|
|
143
|
+
except tomllib.TOMLDecodeError as exc:
|
|
144
|
+
raise ProdKitConfigError(f"Invalid TOML in {path}: {exc}") from exc
|
|
145
|
+
# Accept both flat sections and a [prodkit] table for top-level keys.
|
|
146
|
+
prodkit_table = data.pop("prodkit", {})
|
|
147
|
+
if not isinstance(prodkit_table, dict):
|
|
148
|
+
raise ProdKitConfigError(f"[prodkit] in {path} must be a table")
|
|
149
|
+
return _deep_merge(data, prodkit_table)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_env_value(raw: str) -> Any:
|
|
153
|
+
lowered = raw.strip().lower()
|
|
154
|
+
if lowered in {"true", "1", "yes", "on"}:
|
|
155
|
+
return True
|
|
156
|
+
if lowered in {"false", "0", "no", "off"}:
|
|
157
|
+
return False
|
|
158
|
+
if "," in raw:
|
|
159
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
160
|
+
return raw
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _load_env(environ: dict[str, str]) -> dict[str, Any]:
|
|
164
|
+
"""PRODKIT_DEBUG=false → {"debug": False};
|
|
165
|
+
PRODKIT_LOGGING__LEVEL=DEBUG → {"logging": {"level": "DEBUG"}}."""
|
|
166
|
+
result: dict[str, Any] = {}
|
|
167
|
+
for key, raw in environ.items():
|
|
168
|
+
if not key.startswith(_ENV_PREFIX):
|
|
169
|
+
continue
|
|
170
|
+
path = key[len(_ENV_PREFIX) :].lower().split("__")
|
|
171
|
+
cursor = result
|
|
172
|
+
for part in path[:-1]:
|
|
173
|
+
cursor = cursor.setdefault(part, {})
|
|
174
|
+
if not isinstance(cursor, dict):
|
|
175
|
+
raise ProdKitConfigError(f"Conflicting environment variable: {key}")
|
|
176
|
+
cursor[path[-1]] = _parse_env_value(raw)
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _format_validation_error(exc: ValidationError) -> str:
|
|
181
|
+
lines = ["Invalid ProdKit configuration:"]
|
|
182
|
+
for err in exc.errors():
|
|
183
|
+
location = ".".join(str(part) for part in err["loc"]) or "<root>"
|
|
184
|
+
lines.append(f" - {location}: {err['msg']}")
|
|
185
|
+
return "\n".join(lines)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def resolve_config(
|
|
189
|
+
overrides: dict[str, Any] | None = None,
|
|
190
|
+
*,
|
|
191
|
+
toml_path: Path | str = "prodkit.toml",
|
|
192
|
+
environ: dict[str, str] | None = None,
|
|
193
|
+
) -> ProdKitConfig:
|
|
194
|
+
"""Resolve configuration from all layers and validate it."""
|
|
195
|
+
environ = dict(os.environ) if environ is None else environ
|
|
196
|
+
overrides = {k: v for k, v in (overrides or {}).items() if v is not None}
|
|
197
|
+
|
|
198
|
+
toml_layer = _load_toml(Path(toml_path))
|
|
199
|
+
env_layer = _load_env(environ)
|
|
200
|
+
|
|
201
|
+
# Environment must be decided first — it selects the profile defaults
|
|
202
|
+
# that sit *under* every other layer.
|
|
203
|
+
env_name = (
|
|
204
|
+
overrides.get("environment")
|
|
205
|
+
or env_layer.get("environment")
|
|
206
|
+
or toml_layer.get("environment", "production")
|
|
207
|
+
)
|
|
208
|
+
if env_name not in _PROFILE_DEFAULTS:
|
|
209
|
+
raise ProdKitConfigError(
|
|
210
|
+
f"Unknown environment {env_name!r}; expected one of: "
|
|
211
|
+
+ ", ".join(sorted(_PROFILE_DEFAULTS))
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
merged = _PROFILE_DEFAULTS[env_name]
|
|
215
|
+
for layer in (toml_layer, env_layer, overrides):
|
|
216
|
+
merged = _deep_merge(merged, layer)
|
|
217
|
+
merged["environment"] = env_name
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
config = ProdKitConfig(**merged)
|
|
221
|
+
except ValidationError as exc:
|
|
222
|
+
raise ProdKitConfigError(_format_validation_error(exc)) from exc
|
|
223
|
+
|
|
224
|
+
_check_production_safety(config)
|
|
225
|
+
return config
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _check_production_safety(config: ProdKitConfig) -> None:
|
|
229
|
+
"""Refuse configurations that would silently weaken a production deployment."""
|
|
230
|
+
if config.environment != "production":
|
|
231
|
+
return
|
|
232
|
+
problems: list[str] = []
|
|
233
|
+
if config.debug:
|
|
234
|
+
problems.append("debug=True is not allowed in production")
|
|
235
|
+
if config.errors.include_debug_details:
|
|
236
|
+
problems.append("errors.include_debug_details=True would leak tracebacks in production")
|
|
237
|
+
if config.cors.enabled and config.cors.allow_credentials and "*" in config.cors.origins:
|
|
238
|
+
problems.append(
|
|
239
|
+
"cors: origins=['*'] with allow_credentials=True allows any site to make "
|
|
240
|
+
"authenticated requests; list explicit origins instead"
|
|
241
|
+
)
|
|
242
|
+
if problems:
|
|
243
|
+
raise ProdKitConfigError(
|
|
244
|
+
"Unsafe production configuration:\n" + "\n".join(f" - {p}" for p in problems)
|
|
245
|
+
)
|
prodkit/core/context.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The Context object handed to every plugin hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from prodkit.core.event_bus import EventBus
|
|
9
|
+
from prodkit.core.registry import Registry
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
|
|
14
|
+
from prodkit.core.config import ProdKitConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MiddlewareSpec:
|
|
19
|
+
"""A deferred middleware registration; the kernel sorts these by priority
|
|
20
|
+
(ascending = outermost first) before applying them to the app."""
|
|
21
|
+
|
|
22
|
+
cls: type
|
|
23
|
+
priority: int
|
|
24
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
plugin: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Context:
|
|
29
|
+
"""Everything a plugin may touch: the app, config, registry, and events."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, app: FastAPI, config: ProdKitConfig) -> None:
|
|
32
|
+
self.app = app
|
|
33
|
+
self.config = config
|
|
34
|
+
self.registry = Registry()
|
|
35
|
+
self.events = EventBus()
|
|
36
|
+
self._middleware: list[MiddlewareSpec] = []
|
|
37
|
+
self._current_plugin: str = ""
|
|
38
|
+
|
|
39
|
+
def add_middleware(self, cls: type, *, priority: int, **options: Any) -> None:
|
|
40
|
+
"""Register middleware with an explicit priority.
|
|
41
|
+
|
|
42
|
+
Lower priority = outermost (runs first on requests, last on responses).
|
|
43
|
+
Built-in priorities: request-id=100, logging=200, security=400,
|
|
44
|
+
cors=500, compression=700.
|
|
45
|
+
"""
|
|
46
|
+
self._middleware.append(
|
|
47
|
+
MiddlewareSpec(
|
|
48
|
+
cls=cls, priority=priority, options=options, plugin=self._current_plugin
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def middleware_specs(self) -> list[MiddlewareSpec]:
|
|
53
|
+
return list(self._middleware)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Minimal in-process pub/sub for cross-plugin signals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("prodkit")
|
|
11
|
+
|
|
12
|
+
Handler = Callable[..., Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventBus:
|
|
16
|
+
"""Synchronous-by-default event bus; async handlers are awaited by
|
|
17
|
+
:meth:`emit_async`. A failing handler is logged and skipped — one plugin's
|
|
18
|
+
bug must not take down another's event handling."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._handlers: dict[str, list[Handler]] = {}
|
|
22
|
+
|
|
23
|
+
def subscribe(self, event: str, handler: Handler) -> None:
|
|
24
|
+
self._handlers.setdefault(event, []).append(handler)
|
|
25
|
+
|
|
26
|
+
def emit(self, event: str, **payload: Any) -> None:
|
|
27
|
+
for handler in self._handlers.get(event, []):
|
|
28
|
+
if inspect.iscoroutinefunction(handler):
|
|
29
|
+
raise TypeError(f"Handler {handler!r} for {event!r} is async; use emit_async()")
|
|
30
|
+
try:
|
|
31
|
+
handler(**payload)
|
|
32
|
+
except Exception:
|
|
33
|
+
logger.exception("Event handler failed for event %r", event)
|
|
34
|
+
|
|
35
|
+
async def emit_async(self, event: str, **payload: Any) -> None:
|
|
36
|
+
for handler in self._handlers.get(event, []):
|
|
37
|
+
try:
|
|
38
|
+
result = handler(**payload)
|
|
39
|
+
if inspect.isawaitable(result):
|
|
40
|
+
await result
|
|
41
|
+
except Exception:
|
|
42
|
+
logger.exception("Event handler failed for event %r", event)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""ProdKit exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProdKitError(Exception):
|
|
7
|
+
"""Base class for all ProdKit errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProdKitConfigError(ProdKitError):
|
|
11
|
+
"""Raised when configuration is invalid. Aborts boot with a clear message."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PluginError(ProdKitError):
|
|
15
|
+
"""Raised when a plugin misbehaves (bad contract, failed hook)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PluginDependencyError(PluginError):
|
|
19
|
+
"""Raised when plugin dependencies are missing or cyclic."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ServiceNotFoundError(ProdKitError):
|
|
23
|
+
"""Raised when a requested service is not in the registry."""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Lifespan composition: ProdKit startup wraps the user's existing lifespan.
|
|
2
|
+
|
|
3
|
+
Order guarantee:
|
|
4
|
+
plugin startup (dependency order)
|
|
5
|
+
→ user lifespan enter
|
|
6
|
+
→ requests
|
|
7
|
+
→ user lifespan exit
|
|
8
|
+
plugin shutdown (reverse order, LIFO)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import AsyncIterator, Callable
|
|
15
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from fastapi import FastAPI
|
|
20
|
+
|
|
21
|
+
from prodkit.contracts.plugin import Plugin
|
|
22
|
+
from prodkit.core.context import Context
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("prodkit")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compose_lifespan(
|
|
28
|
+
app: FastAPI, ctx: Context, plugins: list[Plugin]
|
|
29
|
+
) -> Callable[[FastAPI], Any]:
|
|
30
|
+
existing_lifespan = app.router.lifespan_context
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def lifespan(app_: FastAPI) -> AsyncIterator[Any]:
|
|
34
|
+
async with AsyncExitStack() as stack:
|
|
35
|
+
started: list[Plugin] = []
|
|
36
|
+
|
|
37
|
+
async def _shutdown_all() -> None:
|
|
38
|
+
for plugin in reversed(started):
|
|
39
|
+
try:
|
|
40
|
+
await plugin.shutdown(ctx)
|
|
41
|
+
except Exception:
|
|
42
|
+
# One plugin's failing shutdown must not prevent the
|
|
43
|
+
# rest from releasing their resources.
|
|
44
|
+
logger.exception("Shutdown failed for plugin %r", plugin.name)
|
|
45
|
+
|
|
46
|
+
stack.push_async_callback(_shutdown_all)
|
|
47
|
+
for plugin in plugins:
|
|
48
|
+
await plugin.startup(ctx)
|
|
49
|
+
started.append(plugin)
|
|
50
|
+
|
|
51
|
+
state = await stack.enter_async_context(existing_lifespan(app_))
|
|
52
|
+
yield state
|
|
53
|
+
|
|
54
|
+
return lifespan
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Plugin collection, validation, and dependency-ordered activation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from graphlib import CycleError, TopologicalSorter
|
|
6
|
+
|
|
7
|
+
from prodkit.contracts.plugin import Plugin
|
|
8
|
+
from prodkit.core.exceptions import PluginDependencyError, PluginError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PluginManager:
|
|
12
|
+
"""Validates plugins and produces a deterministic activation order via
|
|
13
|
+
topological sort of their ``requires`` declarations."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, plugins: list[Plugin]) -> None:
|
|
16
|
+
self._validate(plugins)
|
|
17
|
+
self.plugins = self._sort(plugins)
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def _validate(plugins: list[Plugin]) -> None:
|
|
21
|
+
seen: set[str] = set()
|
|
22
|
+
for plugin in plugins:
|
|
23
|
+
if not isinstance(plugin, Plugin):
|
|
24
|
+
raise PluginError(f"{plugin!r} does not implement the ProdKit Plugin contract")
|
|
25
|
+
if not plugin.name:
|
|
26
|
+
raise PluginError(f"{type(plugin).__name__} has no 'name' set")
|
|
27
|
+
if plugin.name in seen:
|
|
28
|
+
raise PluginError(f"Duplicate plugin name: {plugin.name!r}")
|
|
29
|
+
seen.add(plugin.name)
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _sort(plugins: list[Plugin]) -> list[Plugin]:
|
|
33
|
+
by_name = {plugin.name: plugin for plugin in plugins}
|
|
34
|
+
graph: dict[str, set[str]] = {}
|
|
35
|
+
for plugin in plugins:
|
|
36
|
+
missing = [dep for dep in plugin.requires if dep not in by_name]
|
|
37
|
+
if missing:
|
|
38
|
+
raise PluginDependencyError(
|
|
39
|
+
f"Plugin {plugin.name!r} requires missing plugin(s): "
|
|
40
|
+
+ ", ".join(repr(m) for m in missing)
|
|
41
|
+
)
|
|
42
|
+
graph[plugin.name] = set(plugin.requires)
|
|
43
|
+
|
|
44
|
+
# static_order is deterministic for a fixed insertion order, which we
|
|
45
|
+
# have (built-ins first, then user plugins in the order given).
|
|
46
|
+
try:
|
|
47
|
+
order = list(TopologicalSorter(graph).static_order())
|
|
48
|
+
except CycleError as exc:
|
|
49
|
+
raise PluginDependencyError(
|
|
50
|
+
f"Plugin dependency cycle detected: {exc.args[1]}"
|
|
51
|
+
) from exc
|
|
52
|
+
return [by_name[name] for name in order]
|