prodkit 0.2.1__tar.gz → 0.3.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.
- {prodkit-0.2.1 → prodkit-0.3.0}/CHANGELOG.md +25 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/PKG-INFO +46 -12
- {prodkit-0.2.1 → prodkit-0.3.0}/README.md +20 -9
- {prodkit-0.2.1 → prodkit-0.3.0}/pyproject.toml +18 -1
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/__init__.py +7 -1
- prodkit-0.3.0/src/prodkit/contracts/__init__.py +5 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/contracts/plugin.py +8 -2
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/config.py +34 -1
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/production.py +3 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/__init__.py +12 -0
- prodkit-0.3.0/src/prodkit/plugins/_redis.py +34 -0
- prodkit-0.3.0/src/prodkit/plugins/cache/__init__.py +150 -0
- prodkit-0.3.0/src/prodkit/plugins/metrics/__init__.py +173 -0
- prodkit-0.3.0/src/prodkit/plugins/rate_limit/__init__.py +169 -0
- prodkit-0.3.0/src/prodkit/plugins/rate_limit/backends.py +117 -0
- prodkit-0.3.0/src/prodkit/plugins/tracing/__init__.py +231 -0
- prodkit-0.3.0/tests/unit/test_cache.py +151 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/tests/unit/test_config.py +44 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/tests/unit/test_doctor.py +5 -2
- prodkit-0.3.0/tests/unit/test_metrics.py +184 -0
- prodkit-0.3.0/tests/unit/test_rate_limit.py +216 -0
- prodkit-0.3.0/tests/unit/test_tracing.py +179 -0
- prodkit-0.2.1/src/prodkit/plugins/rate_limit/__init__.py +0 -147
- prodkit-0.2.1/tests/unit/__init__.py +0 -0
- prodkit-0.2.1/tests/unit/test_rate_limit.py +0 -97
- {prodkit-0.2.1 → prodkit-0.3.0}/.gitignore +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/LICENSE +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/cli/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/cli/app.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/cli/loader.py +0 -0
- {prodkit-0.2.1/src/prodkit/contracts → prodkit-0.3.0/src/prodkit/core}/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/context.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/doctor.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/event_bus.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/exceptions.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/lifecycle.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/plugin_manager.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/core/registry.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/compression/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/cors/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/errors/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/health/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/logging/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/request_id/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/plugins/security/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/src/prodkit/py.typed +0 -0
- {prodkit-0.2.1/src/prodkit/core → prodkit-0.3.0/tests}/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/tests/conftest.py +0 -0
- {prodkit-0.2.1/tests → prodkit-0.3.0/tests/integration}/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/tests/integration/test_cli.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/tests/integration/test_logging.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/tests/integration/test_production.py +0 -0
- {prodkit-0.2.1/tests/integration → prodkit-0.3.0/tests/unit}/__init__.py +0 -0
- {prodkit-0.2.1 → prodkit-0.3.0}/tests/unit/test_kernel.py +0 -0
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-08-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Prometheus metrics plugin** (`pip install prodkit[metrics]`): request
|
|
14
|
+
count, latency histogram, in-flight gauge. Exposed at `/metrics` (or a
|
|
15
|
+
custom path). Metrics use route templates as labels (bounded cardinality).
|
|
16
|
+
Dedicated `CollectorRegistry` per instance — no global-state collisions.
|
|
17
|
+
- **Cache plugin** (`cache={...}`): a named cache service published in the
|
|
18
|
+
registry under `"cache"`. Memory (per-process LRU with TTL) and Redis
|
|
19
|
+
(shared across workers) backends. Values must be JSON-serializable.
|
|
20
|
+
- **OpenTelemetry tracing plugin** (`pip install prodkit[otel]`): automatic
|
|
21
|
+
request spans with W3C `traceparent` propagation, configurable sampler
|
|
22
|
+
(`sample_rate`), and OTLP/console/none exporters. The `TracerProvider` is
|
|
23
|
+
published in the registry as `"tracer"`.
|
|
24
|
+
- **Redis backend for rate limiting** (`rate_limit.backend="redis"`): shared
|
|
25
|
+
aligned fixed-window counters across all workers and hosts. Fails open on
|
|
26
|
+
Redis errors (availability > strict limiting). Readiness check via `/ready`.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- CI installs `otel` and `metrics` extras for full test coverage.
|
|
30
|
+
- `TracingMiddleware` now uses `context.attach()`/`detach()` for correct W3C
|
|
31
|
+
trace context propagation (previously passed unsupported `context` kwarg).
|
|
32
|
+
- Dev dependencies now include `anyio` and `pytest-anyio` for async tests.
|
|
33
|
+
|
|
34
|
+
|
|
10
35
|
## [0.2.1] - 2026-07-23
|
|
11
36
|
|
|
12
37
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: prodkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: The production framework for FastAPI. One line. Production ready.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Pushkarpant/PRODKIT
|
|
6
6
|
Project-URL: Documentation, https://github.com/Pushkarpant/PRODKIT#readme
|
|
@@ -10,7 +10,7 @@ Project-URL: Issues, https://github.com/Pushkarpant/PRODKIT/issues
|
|
|
10
10
|
Author-email: Pushkar Pant <pantpushkar4@gmail.com>
|
|
11
11
|
License-Expression: MIT
|
|
12
12
|
License-File: LICENSE
|
|
13
|
-
Keywords: fastapi,health-check,logging,middleware,observability,production,security
|
|
13
|
+
Keywords: fastapi,health-check,logging,metrics,middleware,observability,opentelemetry,production,security,tracing
|
|
14
14
|
Classifier: Development Status :: 3 - Alpha
|
|
15
15
|
Classifier: Framework :: FastAPI
|
|
16
16
|
Classifier: Intended Audience :: Developers
|
|
@@ -28,21 +28,44 @@ Requires-Dist: fastapi>=0.110
|
|
|
28
28
|
Requires-Dist: pydantic-settings>=2.1
|
|
29
29
|
Requires-Dist: pydantic>=2.5
|
|
30
30
|
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
31
|
+
Provides-Extra: all
|
|
32
|
+
Requires-Dist: brotli-asgi>=1.4; extra == 'all'
|
|
33
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == 'all'
|
|
34
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20; extra == 'all'
|
|
35
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == 'all'
|
|
36
|
+
Requires-Dist: prometheus-client>=0.20; extra == 'all'
|
|
37
|
+
Requires-Dist: redis>=5.0; extra == 'all'
|
|
38
|
+
Requires-Dist: rich>=13; extra == 'all'
|
|
39
|
+
Requires-Dist: typer>=0.12; extra == 'all'
|
|
31
40
|
Provides-Extra: brotli
|
|
32
41
|
Requires-Dist: brotli-asgi>=1.4; extra == 'brotli'
|
|
33
42
|
Provides-Extra: cli
|
|
34
43
|
Requires-Dist: rich>=13; extra == 'cli'
|
|
35
44
|
Requires-Dist: typer>=0.12; extra == 'cli'
|
|
36
45
|
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: anyio[trio]>=4.0; extra == 'dev'
|
|
47
|
+
Requires-Dist: fakeredis[lua]>=2.21; extra == 'dev'
|
|
37
48
|
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
38
49
|
Requires-Dist: import-linter>=2.0; extra == 'dev'
|
|
39
50
|
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
51
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == 'dev'
|
|
52
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == 'dev'
|
|
53
|
+
Requires-Dist: prometheus-client>=0.20; extra == 'dev'
|
|
54
|
+
Requires-Dist: pytest-anyio>=0.0.0; extra == 'dev'
|
|
40
55
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
41
56
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
42
57
|
Requires-Dist: rich>=13; extra == 'dev'
|
|
43
58
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
44
59
|
Requires-Dist: tomli>=2.0; extra == 'dev'
|
|
45
60
|
Requires-Dist: typer>=0.12; extra == 'dev'
|
|
61
|
+
Provides-Extra: metrics
|
|
62
|
+
Requires-Dist: prometheus-client>=0.20; extra == 'metrics'
|
|
63
|
+
Provides-Extra: otel
|
|
64
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
|
|
65
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20; extra == 'otel'
|
|
66
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == 'otel'
|
|
67
|
+
Provides-Extra: redis
|
|
68
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
46
69
|
Description-Content-Type: text/markdown
|
|
47
70
|
|
|
48
71
|
# ProdKit
|
|
@@ -99,8 +122,16 @@ pip install "prodkit[cli]" # + the `prodkit doctor` CLI (typer + rich)
|
|
|
99
122
|
```
|
|
100
123
|
|
|
101
124
|
Requires Python 3.10+ and FastAPI 0.110+. The base install depends only on
|
|
102
|
-
FastAPI and Pydantic — nothing else. Optional extras:
|
|
103
|
-
|
|
125
|
+
FastAPI and Pydantic — nothing else. Optional extras:
|
|
126
|
+
|
|
127
|
+
| Extra | Adds |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `cli` | `prodkit doctor` CLI (typer + rich) |
|
|
130
|
+
| `metrics` | Prometheus `/metrics` endpoint |
|
|
131
|
+
| `otel` | OpenTelemetry tracing (OTLP export) |
|
|
132
|
+
| `redis` | Shared Redis backends (rate-limit, cache) |
|
|
133
|
+
| `brotli` | Brotli compression |
|
|
134
|
+
| `all` | Everything above |
|
|
104
135
|
|
|
105
136
|
## Quick Start
|
|
106
137
|
|
|
@@ -151,7 +182,10 @@ Production(app, environment="development")
|
|
|
151
182
|
| ❤️ **Health endpoints** | `/health`, `/live` (liveness) and `/ready` (readiness — aggregates checks from every plugin, 503 until all pass). Kubernetes-native. |
|
|
152
183
|
| 🌐 **CORS** | Explicit origins only; the wildcard-with-credentials footgun is refused at boot. |
|
|
153
184
|
| 📦 **Compression** | Gzip for responses over 500 bytes. |
|
|
154
|
-
| 🚦 **Rate limiting** | Opt-in per-IP limiting (`100/minute`), `429 problem+json` with `Retry-After`.
|
|
185
|
+
| 🚦 **Rate limiting** | Opt-in per-IP limiting (`100/minute`), `429 problem+json` with `Retry-After`. Memory or Redis backend (shared across workers). |
|
|
186
|
+
| 📊 **Prometheus metrics** | Request count, latency histogram, in-flight gauge at `/metrics`. Route-template labels (bounded cardinality). `pip install prodkit[metrics]` |
|
|
187
|
+
| 🗄️ **Cache service** | Memory (LRU + TTL) or Redis backend, published in the registry. `await cache.get(key)` / `.set(key, value, ttl=60)`. |
|
|
188
|
+
| 🔭 **OpenTelemetry tracing** | Automatic request spans with W3C `traceparent` propagation. OTLP, console, or none exporter. `pip install prodkit[otel]` |
|
|
155
189
|
| 🩺 **`prodkit doctor`** | CLI production-readiness audit with a 0–100 score. `--strict` gates CI. |
|
|
156
190
|
| 🔌 **Plugin system** | Every feature above is a plugin. Write your own with optional hooks incl. `doctor()`. |
|
|
157
191
|
|
|
@@ -301,13 +335,13 @@ plugins score too.
|
|
|
301
335
|
|
|
302
336
|
## Project Status
|
|
303
337
|
|
|
304
|
-
**v0.
|
|
305
|
-
the `prodkit doctor` CLI
|
|
306
|
-
|
|
338
|
+
**v0.3.0 — alpha.** Core kernel, eleven built-in plugins (metrics, cache, tracing,
|
|
339
|
+
rate-limiting with Redis), the `prodkit doctor` CLI, strict mypy, CI across
|
|
340
|
+
Python 3.10–3.13.
|
|
307
341
|
|
|
308
|
-
Roadmap: ✅ `prodkit doctor` CLI + rate limiting (v0.2), Prometheus metrics +
|
|
309
|
-
Redis backends (v0.3), Dockerfile/nginx/CI generators
|
|
310
|
-
(v0.5), auth helpers (v0.6), stable API (v1.0).
|
|
342
|
+
Roadmap: ✅ `prodkit doctor` CLI + rate limiting (v0.2), ✅ Prometheus metrics +
|
|
343
|
+
Redis backends + OpenTelemetry tracing (v0.3), Dockerfile/nginx/CI generators
|
|
344
|
+
(v0.4), public plugin SDK (v0.5), auth helpers (v0.6), stable API (v1.0).
|
|
311
345
|
Full details in [docs/ARCHITECTURE.md](https://github.com/Pushkarpant/PRODKIT/blob/main/docs/ARCHITECTURE.md).
|
|
312
346
|
|
|
313
347
|
## Contributing
|
|
@@ -52,8 +52,16 @@ pip install "prodkit[cli]" # + the `prodkit doctor` CLI (typer + rich)
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
Requires Python 3.10+ and FastAPI 0.110+. The base install depends only on
|
|
55
|
-
FastAPI and Pydantic — nothing else. Optional extras:
|
|
56
|
-
|
|
55
|
+
FastAPI and Pydantic — nothing else. Optional extras:
|
|
56
|
+
|
|
57
|
+
| Extra | Adds |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `cli` | `prodkit doctor` CLI (typer + rich) |
|
|
60
|
+
| `metrics` | Prometheus `/metrics` endpoint |
|
|
61
|
+
| `otel` | OpenTelemetry tracing (OTLP export) |
|
|
62
|
+
| `redis` | Shared Redis backends (rate-limit, cache) |
|
|
63
|
+
| `brotli` | Brotli compression |
|
|
64
|
+
| `all` | Everything above |
|
|
57
65
|
|
|
58
66
|
## Quick Start
|
|
59
67
|
|
|
@@ -104,7 +112,10 @@ Production(app, environment="development")
|
|
|
104
112
|
| ❤️ **Health endpoints** | `/health`, `/live` (liveness) and `/ready` (readiness — aggregates checks from every plugin, 503 until all pass). Kubernetes-native. |
|
|
105
113
|
| 🌐 **CORS** | Explicit origins only; the wildcard-with-credentials footgun is refused at boot. |
|
|
106
114
|
| 📦 **Compression** | Gzip for responses over 500 bytes. |
|
|
107
|
-
| 🚦 **Rate limiting** | Opt-in per-IP limiting (`100/minute`), `429 problem+json` with `Retry-After`.
|
|
115
|
+
| 🚦 **Rate limiting** | Opt-in per-IP limiting (`100/minute`), `429 problem+json` with `Retry-After`. Memory or Redis backend (shared across workers). |
|
|
116
|
+
| 📊 **Prometheus metrics** | Request count, latency histogram, in-flight gauge at `/metrics`. Route-template labels (bounded cardinality). `pip install prodkit[metrics]` |
|
|
117
|
+
| 🗄️ **Cache service** | Memory (LRU + TTL) or Redis backend, published in the registry. `await cache.get(key)` / `.set(key, value, ttl=60)`. |
|
|
118
|
+
| 🔭 **OpenTelemetry tracing** | Automatic request spans with W3C `traceparent` propagation. OTLP, console, or none exporter. `pip install prodkit[otel]` |
|
|
108
119
|
| 🩺 **`prodkit doctor`** | CLI production-readiness audit with a 0–100 score. `--strict` gates CI. |
|
|
109
120
|
| 🔌 **Plugin system** | Every feature above is a plugin. Write your own with optional hooks incl. `doctor()`. |
|
|
110
121
|
|
|
@@ -254,13 +265,13 @@ plugins score too.
|
|
|
254
265
|
|
|
255
266
|
## Project Status
|
|
256
267
|
|
|
257
|
-
**v0.
|
|
258
|
-
the `prodkit doctor` CLI
|
|
259
|
-
|
|
268
|
+
**v0.3.0 — alpha.** Core kernel, eleven built-in plugins (metrics, cache, tracing,
|
|
269
|
+
rate-limiting with Redis), the `prodkit doctor` CLI, strict mypy, CI across
|
|
270
|
+
Python 3.10–3.13.
|
|
260
271
|
|
|
261
|
-
Roadmap: ✅ `prodkit doctor` CLI + rate limiting (v0.2), Prometheus metrics +
|
|
262
|
-
Redis backends (v0.3), Dockerfile/nginx/CI generators
|
|
263
|
-
(v0.5), auth helpers (v0.6), stable API (v1.0).
|
|
272
|
+
Roadmap: ✅ `prodkit doctor` CLI + rate limiting (v0.2), ✅ Prometheus metrics +
|
|
273
|
+
Redis backends + OpenTelemetry tracing (v0.3), Dockerfile/nginx/CI generators
|
|
274
|
+
(v0.4), public plugin SDK (v0.5), auth helpers (v0.6), stable API (v1.0).
|
|
264
275
|
Full details in [docs/ARCHITECTURE.md](https://github.com/Pushkarpant/PRODKIT/blob/main/docs/ARCHITECTURE.md).
|
|
265
276
|
|
|
266
277
|
## Contributing
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "prodkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "The production framework for FastAPI. One line. Production ready."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -19,6 +19,9 @@ keywords = [
|
|
|
19
19
|
"observability",
|
|
20
20
|
"logging",
|
|
21
21
|
"health-check",
|
|
22
|
+
"metrics",
|
|
23
|
+
"tracing",
|
|
24
|
+
"opentelemetry",
|
|
22
25
|
]
|
|
23
26
|
classifiers = [
|
|
24
27
|
"Development Status :: 3 - Alpha",
|
|
@@ -44,9 +47,19 @@ dependencies = [
|
|
|
44
47
|
[project.optional-dependencies]
|
|
45
48
|
brotli = ["brotli-asgi>=1.4"]
|
|
46
49
|
cli = ["typer>=0.12", "rich>=13"]
|
|
50
|
+
metrics = ["prometheus-client>=0.20"]
|
|
51
|
+
redis = ["redis>=5.0"]
|
|
52
|
+
otel = [
|
|
53
|
+
"opentelemetry-api>=1.20",
|
|
54
|
+
"opentelemetry-sdk>=1.20",
|
|
55
|
+
"opentelemetry-exporter-otlp-proto-grpc>=1.20",
|
|
56
|
+
]
|
|
57
|
+
all = ["prodkit[metrics,redis,otel,brotli,cli]"]
|
|
47
58
|
dev = [
|
|
48
59
|
"pytest>=8.0",
|
|
49
60
|
"pytest-cov>=5.0",
|
|
61
|
+
"anyio[trio]>=4.0",
|
|
62
|
+
"pytest-anyio>=0.0.0",
|
|
50
63
|
"httpx>=0.27",
|
|
51
64
|
"ruff>=0.6",
|
|
52
65
|
"mypy>=1.11",
|
|
@@ -54,6 +67,10 @@ dev = [
|
|
|
54
67
|
"tomli>=2.0", # so mypy (python_version=3.10) can type-check the fallback import
|
|
55
68
|
"typer>=0.12", # CLI is type-checked and tested in CI
|
|
56
69
|
"rich>=13",
|
|
70
|
+
"prometheus-client>=0.20",
|
|
71
|
+
"fakeredis[lua]>=2.21",
|
|
72
|
+
"opentelemetry-api>=1.20",
|
|
73
|
+
"opentelemetry-sdk>=1.20",
|
|
57
74
|
]
|
|
58
75
|
|
|
59
76
|
[project.scripts]
|
|
@@ -9,15 +9,18 @@ Production(app)
|
|
|
9
9
|
|
|
10
10
|
from prodkit.contracts.plugin import Audit, Check, Plugin
|
|
11
11
|
from prodkit.core.config import (
|
|
12
|
+
CacheConfig,
|
|
12
13
|
CompressionConfig,
|
|
13
14
|
CORSConfig,
|
|
14
15
|
ErrorsConfig,
|
|
15
16
|
HealthConfig,
|
|
16
17
|
LoggingConfig,
|
|
18
|
+
MetricsConfig,
|
|
17
19
|
ProdKitConfig,
|
|
18
20
|
RateLimitConfig,
|
|
19
21
|
RequestIDConfig,
|
|
20
22
|
SecurityConfig,
|
|
23
|
+
TracingConfig,
|
|
21
24
|
)
|
|
22
25
|
from prodkit.core.context import Context
|
|
23
26
|
from prodkit.core.exceptions import (
|
|
@@ -34,17 +37,19 @@ from prodkit.plugins import builtin_plugins
|
|
|
34
37
|
# root — the kernel itself never imports from prodkit.plugins.
|
|
35
38
|
set_builtin_factory(builtin_plugins)
|
|
36
39
|
|
|
37
|
-
__version__ = "0.
|
|
40
|
+
__version__ = "0.3.0"
|
|
38
41
|
|
|
39
42
|
__all__ = [
|
|
40
43
|
"Audit",
|
|
41
44
|
"CORSConfig",
|
|
45
|
+
"CacheConfig",
|
|
42
46
|
"Check",
|
|
43
47
|
"CompressionConfig",
|
|
44
48
|
"Context",
|
|
45
49
|
"ErrorsConfig",
|
|
46
50
|
"HealthConfig",
|
|
47
51
|
"LoggingConfig",
|
|
52
|
+
"MetricsConfig",
|
|
48
53
|
"Plugin",
|
|
49
54
|
"PluginDependencyError",
|
|
50
55
|
"PluginError",
|
|
@@ -56,5 +61,6 @@ __all__ = [
|
|
|
56
61
|
"RequestIDConfig",
|
|
57
62
|
"SecurityConfig",
|
|
58
63
|
"ServiceNotFoundError",
|
|
64
|
+
"TracingConfig",
|
|
59
65
|
"__version__",
|
|
60
66
|
]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Awaitable
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from typing import TYPE_CHECKING, ClassVar, Literal
|
|
7
8
|
|
|
@@ -72,8 +73,12 @@ class Plugin:
|
|
|
72
73
|
async def shutdown(self, ctx: Context) -> None:
|
|
73
74
|
"""Release resources gracefully."""
|
|
74
75
|
|
|
75
|
-
def checks(self, ctx: Context) -> list[Check]:
|
|
76
|
-
"""Readiness checks, aggregated by the health plugin's /ready.
|
|
76
|
+
def checks(self, ctx: Context) -> list[Check] | Awaitable[list[Check]]:
|
|
77
|
+
"""Readiness checks, aggregated by the health plugin's /ready.
|
|
78
|
+
|
|
79
|
+
May be overridden as ``async def`` (e.g. to ping a backend); the
|
|
80
|
+
health plugin awaits awaitable results.
|
|
81
|
+
"""
|
|
77
82
|
return []
|
|
78
83
|
|
|
79
84
|
def doctor(self, ctx: Context) -> list[Audit]:
|
|
@@ -89,6 +94,7 @@ class Plugin:
|
|
|
89
94
|
PRIORITY_REQUEST_ID = 100
|
|
90
95
|
PRIORITY_LOGGING = 200
|
|
91
96
|
PRIORITY_ERRORS = 250
|
|
97
|
+
PRIORITY_TRACING = 290
|
|
92
98
|
PRIORITY_METRICS = 300
|
|
93
99
|
PRIORITY_SECURITY = 400
|
|
94
100
|
PRIORITY_CORS = 500
|
|
@@ -93,7 +93,37 @@ class RateLimitConfig(_Section):
|
|
|
93
93
|
enabled: bool = False # opt-in: an unexpected 429 is worse than no limit
|
|
94
94
|
# "<count>/<second|minute|hour>", parsed and validated by the plugin.
|
|
95
95
|
default: str = "100/minute"
|
|
96
|
-
by: Literal["ip"] = "ip" #
|
|
96
|
+
by: Literal["ip"] = "ip" # keys on client IP; per-user/route land later
|
|
97
|
+
# "memory" is per-process; "redis" shares the limit across workers/hosts.
|
|
98
|
+
backend: Literal["memory", "redis"] = "memory"
|
|
99
|
+
redis_url: str = "redis://localhost:6379/0"
|
|
100
|
+
key_prefix: str = "prodkit:ratelimit:"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MetricsConfig(_Section):
|
|
104
|
+
enabled: bool = False # opt-in: needs prodkit[metrics]
|
|
105
|
+
path: str = "/metrics"
|
|
106
|
+
buckets: list[float] | None = None # None -> prometheus-client defaults
|
|
107
|
+
# Paths never measured (the metrics path itself is always excluded).
|
|
108
|
+
exclude_paths: list[str] = Field(default_factory=list)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CacheConfig(_Section):
|
|
112
|
+
enabled: bool = False # opt-in: a service other code asks for, not middleware
|
|
113
|
+
backend: Literal["memory", "redis"] = "memory"
|
|
114
|
+
redis_url: str = "redis://localhost:6379/0"
|
|
115
|
+
default_ttl: int = 300 # seconds; 0 = no expiry
|
|
116
|
+
key_prefix: str = "prodkit:cache:"
|
|
117
|
+
max_entries: int = 1024 # memory backend LRU bound
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TracingConfig(_Section):
|
|
121
|
+
enabled: bool = False # opt-in: needs prodkit[otel]
|
|
122
|
+
service_name: str | None = None # None -> the FastAPI app's title
|
|
123
|
+
exporter: Literal["otlp", "console", "none"] = "otlp"
|
|
124
|
+
# None defers to the standard OTEL_EXPORTER_OTLP_* environment variables.
|
|
125
|
+
endpoint: str | None = None
|
|
126
|
+
sample_rate: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
97
127
|
|
|
98
128
|
|
|
99
129
|
class ProdKitConfig(_Section):
|
|
@@ -109,6 +139,9 @@ class ProdKitConfig(_Section):
|
|
|
109
139
|
cors: CORSConfig = Field(default_factory=CORSConfig)
|
|
110
140
|
compression: CompressionConfig = Field(default_factory=CompressionConfig)
|
|
111
141
|
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
|
|
142
|
+
metrics: MetricsConfig = Field(default_factory=MetricsConfig)
|
|
143
|
+
cache: CacheConfig = Field(default_factory=CacheConfig)
|
|
144
|
+
tracing: TracingConfig = Field(default_factory=TracingConfig)
|
|
112
145
|
|
|
113
146
|
|
|
114
147
|
# Profile defaults: applied beneath toml/env/args. The one-liner must be
|
|
@@ -10,14 +10,17 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
from typing import TYPE_CHECKING
|
|
12
12
|
|
|
13
|
+
from prodkit.plugins.cache import CachePlugin
|
|
13
14
|
from prodkit.plugins.compression import CompressionPlugin
|
|
14
15
|
from prodkit.plugins.cors import CORSPlugin
|
|
15
16
|
from prodkit.plugins.errors import ErrorsPlugin
|
|
16
17
|
from prodkit.plugins.health import HealthPlugin
|
|
17
18
|
from prodkit.plugins.logging import LoggingPlugin
|
|
19
|
+
from prodkit.plugins.metrics import MetricsPlugin
|
|
18
20
|
from prodkit.plugins.rate_limit import RateLimitPlugin
|
|
19
21
|
from prodkit.plugins.request_id import RequestIDPlugin
|
|
20
22
|
from prodkit.plugins.security import SecurityPlugin
|
|
23
|
+
from prodkit.plugins.tracing import TracingPlugin
|
|
21
24
|
|
|
22
25
|
if TYPE_CHECKING:
|
|
23
26
|
from prodkit.contracts.plugin import Plugin
|
|
@@ -25,13 +28,16 @@ if TYPE_CHECKING:
|
|
|
25
28
|
|
|
26
29
|
__all__ = [
|
|
27
30
|
"CORSPlugin",
|
|
31
|
+
"CachePlugin",
|
|
28
32
|
"CompressionPlugin",
|
|
29
33
|
"ErrorsPlugin",
|
|
30
34
|
"HealthPlugin",
|
|
31
35
|
"LoggingPlugin",
|
|
36
|
+
"MetricsPlugin",
|
|
32
37
|
"RateLimitPlugin",
|
|
33
38
|
"RequestIDPlugin",
|
|
34
39
|
"SecurityPlugin",
|
|
40
|
+
"TracingPlugin",
|
|
35
41
|
"builtin_plugins",
|
|
36
42
|
]
|
|
37
43
|
|
|
@@ -55,4 +61,10 @@ def builtin_plugins(config: ProdKitConfig) -> list[Plugin]:
|
|
|
55
61
|
plugins.append(RateLimitPlugin())
|
|
56
62
|
if config.compression.enabled:
|
|
57
63
|
plugins.append(CompressionPlugin())
|
|
64
|
+
if config.metrics.enabled:
|
|
65
|
+
plugins.append(MetricsPlugin())
|
|
66
|
+
if config.cache.enabled:
|
|
67
|
+
plugins.append(CachePlugin())
|
|
68
|
+
if config.tracing.enabled:
|
|
69
|
+
plugins.append(TracingPlugin())
|
|
58
70
|
return plugins
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared Redis client construction for plugins with a Redis backend.
|
|
2
|
+
|
|
3
|
+
The optional ``redis`` dependency is imported lazily here — plugin modules are
|
|
4
|
+
imported unconditionally by ``prodkit.plugins``, so a missing extra must not
|
|
5
|
+
break ``import prodkit``. It fails at plugin ``configure()`` time instead, with
|
|
6
|
+
an actionable message. This function is also the single seam tests monkeypatch
|
|
7
|
+
to inject a fake client (fakeredis).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from prodkit.core.exceptions import ProdKitConfigError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from redis.asyncio import Redis
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_client(url: str, *, section: str = "redis") -> Redis:
|
|
21
|
+
"""Create an async Redis client, or fail with a named-section pip hint.
|
|
22
|
+
|
|
23
|
+
The client connects lazily (on first command), so calling this at boot is
|
|
24
|
+
cheap; reachability is verified by the owning plugin's ``startup()`` ping.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
from redis.asyncio import Redis
|
|
28
|
+
except ImportError:
|
|
29
|
+
raise ProdKitConfigError(
|
|
30
|
+
f"{section}: backend='redis' requires the redis package. "
|
|
31
|
+
"Install it with: pip install 'prodkit[redis]'"
|
|
32
|
+
) from None
|
|
33
|
+
client: Any = Redis.from_url(url)
|
|
34
|
+
return client # type: ignore[no-any-return]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Cache plugin: a named cache service other plugins and user code can share.
|
|
2
|
+
|
|
3
|
+
The service is published in the registry under ``"cache"``::
|
|
4
|
+
|
|
5
|
+
cache = ctx.registry.get("cache")
|
|
6
|
+
await cache.set("user:42", {"name": "Ada"}, ttl=60)
|
|
7
|
+
user = await cache.get("user:42")
|
|
8
|
+
|
|
9
|
+
Two backends: ``memory`` (per-process LRU with TTL, the default) and ``redis``
|
|
10
|
+
(shared across workers/hosts). Values must be JSON-serializable so behavior is
|
|
11
|
+
identical across backends.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from collections import OrderedDict
|
|
19
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Protocol
|
|
20
|
+
|
|
21
|
+
from prodkit.contracts.plugin import Audit, Check, Plugin
|
|
22
|
+
from prodkit.core.context import Context
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from redis.asyncio import Redis
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CacheService(Protocol):
|
|
29
|
+
"""The contract of the registry's ``"cache"`` service.
|
|
30
|
+
|
|
31
|
+
``ttl=None`` means the configured default TTL; ``ttl=0`` means no expiry.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
async def get(self, key: str) -> Any | None: ...
|
|
35
|
+
|
|
36
|
+
async def set(self, key: str, value: Any, ttl: int | None = None) -> None: ...
|
|
37
|
+
|
|
38
|
+
async def delete(self, key: str) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MemoryCache:
|
|
42
|
+
"""Per-process LRU cache with monotonic-deadline TTLs (lazy expiry)."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, default_ttl: int, max_entries: int) -> None:
|
|
45
|
+
self.default_ttl = default_ttl
|
|
46
|
+
self.max_entries = max_entries
|
|
47
|
+
# key -> (deadline_monotonic | None, value); insertion order = LRU order
|
|
48
|
+
self._data: OrderedDict[str, tuple[float | None, Any]] = OrderedDict()
|
|
49
|
+
|
|
50
|
+
def _deadline(self, ttl: int | None) -> float | None:
|
|
51
|
+
effective = self.default_ttl if ttl is None else ttl
|
|
52
|
+
return None if effective == 0 else time.monotonic() + effective
|
|
53
|
+
|
|
54
|
+
async def get(self, key: str) -> Any | None:
|
|
55
|
+
item = self._data.get(key)
|
|
56
|
+
if item is None:
|
|
57
|
+
return None
|
|
58
|
+
deadline, value = item
|
|
59
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
60
|
+
del self._data[key]
|
|
61
|
+
return None
|
|
62
|
+
self._data.move_to_end(key)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
|
66
|
+
self._data[key] = (self._deadline(ttl), value)
|
|
67
|
+
self._data.move_to_end(key)
|
|
68
|
+
while len(self._data) > self.max_entries:
|
|
69
|
+
self._data.popitem(last=False) # evict least recently used
|
|
70
|
+
|
|
71
|
+
async def delete(self, key: str) -> None:
|
|
72
|
+
self._data.pop(key, None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RedisCache:
|
|
76
|
+
"""Redis-backed cache; values JSON-encoded, keys prefixed."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, url: str, default_ttl: int, prefix: str) -> None:
|
|
79
|
+
self.default_ttl = default_ttl
|
|
80
|
+
self.prefix = prefix
|
|
81
|
+
from prodkit.plugins._redis import create_client
|
|
82
|
+
|
|
83
|
+
self._client: Redis = create_client(url, section="cache")
|
|
84
|
+
|
|
85
|
+
async def get(self, key: str) -> Any | None:
|
|
86
|
+
raw = await self._client.get(self.prefix + key)
|
|
87
|
+
return None if raw is None else json.loads(raw)
|
|
88
|
+
|
|
89
|
+
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
|
90
|
+
effective = self.default_ttl if ttl is None else ttl
|
|
91
|
+
await self._client.set(
|
|
92
|
+
self.prefix + key,
|
|
93
|
+
json.dumps(value),
|
|
94
|
+
ex=effective if effective > 0 else None,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def delete(self, key: str) -> None:
|
|
98
|
+
await self._client.delete(self.prefix + key)
|
|
99
|
+
|
|
100
|
+
async def ping(self) -> None:
|
|
101
|
+
await self._client.ping()
|
|
102
|
+
|
|
103
|
+
async def aclose(self) -> None:
|
|
104
|
+
await self._client.aclose()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class CachePlugin(Plugin):
|
|
108
|
+
name: ClassVar[str] = "cache"
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self._cache: CacheService | None = None
|
|
112
|
+
|
|
113
|
+
def configure(self, ctx: Context) -> None:
|
|
114
|
+
cfg = ctx.config.cache
|
|
115
|
+
if cfg.backend == "redis":
|
|
116
|
+
# create_client raises a named ProdKitConfigError if the redis
|
|
117
|
+
# extra is missing; connection itself is verified in startup().
|
|
118
|
+
self._cache = RedisCache(cfg.redis_url, cfg.default_ttl, cfg.key_prefix)
|
|
119
|
+
else:
|
|
120
|
+
self._cache = MemoryCache(cfg.default_ttl, cfg.max_entries)
|
|
121
|
+
ctx.registry.provide("cache", self._cache)
|
|
122
|
+
|
|
123
|
+
async def startup(self, ctx: Context) -> None:
|
|
124
|
+
if isinstance(self._cache, RedisCache):
|
|
125
|
+
# Fail fast at boot if Redis is unreachable.
|
|
126
|
+
await self._cache.ping()
|
|
127
|
+
|
|
128
|
+
async def shutdown(self, ctx: Context) -> None:
|
|
129
|
+
if isinstance(self._cache, RedisCache):
|
|
130
|
+
await self._cache.aclose()
|
|
131
|
+
|
|
132
|
+
async def checks(self, ctx: Context) -> list[Check]:
|
|
133
|
+
if not isinstance(self._cache, RedisCache):
|
|
134
|
+
return []
|
|
135
|
+
try:
|
|
136
|
+
await self._cache.ping()
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
return [Check(name="cache-redis", passed=False, detail=str(exc))]
|
|
139
|
+
return [Check(name="cache-redis", passed=True, detail="reachable")]
|
|
140
|
+
|
|
141
|
+
def doctor(self, ctx: Context) -> list[Audit]:
|
|
142
|
+
cfg = ctx.config.cache
|
|
143
|
+
return [
|
|
144
|
+
Audit(
|
|
145
|
+
name="Cache",
|
|
146
|
+
status="ok",
|
|
147
|
+
detail=f"{cfg.backend} backend, default_ttl={cfg.default_ttl}s",
|
|
148
|
+
weight=5,
|
|
149
|
+
)
|
|
150
|
+
]
|