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 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
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
+ )
@@ -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]