Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ version: 2

updates:
# No Cargo block, and no pip one either. This distribution has exactly one
# runtime requirement — `dynamic-config-py` — and seven framework extras,
# runtime requirement — `dynamic-config-py` — and nine framework extras,
# none of which is locked: a library that pinned its dependencies would pin
# its users'. `scripts/resolve-web-audit.py` is what resolves them, and the
# OSV job in security.yml is what watches them.
Expand Down
31 changes: 30 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,35 @@ jobs:
python "$example" > /dev/null
done

coverage:
needs: [changes]
if: needs.changes.outputs.python == 'true' || github.event_name != 'pull_request'
name: coverage report
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
# Every framework, so the number covers the adapters too — the same
# environment the `types` job builds.
- run: pip install -e ".[dev,all,robyn,django-bolt]" pytest-cov
- run: |
python -m pytest tests -q \
--cov=dynamic_config_web --cov-report=term \
--cov-report=json:coverage.json
- name: the number, where reviews can see it
run: |
python - <<'REPORT' >> "$GITHUB_STEP_SUMMARY"
import json

covered = json.load(open("coverage.json"))["totals"]["percent_covered"]
print(f"line coverage: {covered:.1f}%")
REPORT

book:
needs: [changes]
if: needs.changes.outputs.docs == 'true' || github.event_name != 'pull_request'
Expand Down Expand Up @@ -343,7 +372,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
if: always()
needs: [changes, lint, core, adapters, django-app, types, examples, book, docs-links, actionlint]
needs: [changes, lint, core, adapters, django-app, types, examples, coverage, book, docs-links, actionlint]
steps:
- name: every job succeeded
run: |
Expand Down
34 changes: 32 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name: Security
#
# That matters more here than anywhere else in the organisation: this is the
# one distribution whose dependency graph is somebody else's web framework —
# seven of them — and a web framework is where advisories actually land.
# nine of them — and a web framework is where advisories actually land.
#
# No push trigger for `dev` — same reasoning as ci.yml: dev travels through
# pull requests, and a push twin under required checks poisons the gate.
Expand Down Expand Up @@ -89,14 +89,44 @@ jobs:
fail-on-severity: low
comment-summary-in-pr: on-failure

published:
name: the published wheel is still pure
# Schedule and dispatch only: this asks PyPI, not the tree, so a pull
# request cannot change its answer — but a compromised upload can, and
# a weekly read is how that is noticed here rather than in an issue.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: what PyPI serves imports no framework
run: |
pip install --quiet dynamic-config-py-web
python - <<'CHECK'
import sys

import dynamic_config_web

frameworks = {
"django", "django_bolt", "fastapi", "flask",
"litestar", "quart", "rest_framework", "robyn", "starlette",
}
arrived = sorted(n for n in sys.modules if n.split(".")[0] in frameworks)

assert not arrived, f"the published wheel pulled in {arrived}"
print(f"pure, at {dynamic_config_web.__version__}")
CHECK

# The one status branch protection requires from this workflow — same
# reasoning as CI's gate. `supply-chain` IS in the needs even though it only
# exists on pull requests: the gate's own check tolerates `skipped`, so a
# plain push passes, while a PR with a failing dependency review is blocked
# — which is the whole point of running it.
security-ok:
name: Security is green
needs: [osv, supply-chain]
needs: [osv, supply-chain, published]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,45 @@ for an adapter and an adapter fix should not drag the wheels behind it.

## [Unreleased]

### Changed

- **The route table is written once.** Six adapters — FastAPI, Litestar,
Flask, Quart, Robyn and django-bolt — had each re-declared `/healthz`,
`/readyz`, `/metrics` and the two guarded diagnostics routes with the
same bodies; they now loop over one shared table and translate only
what is genuinely theirs: the path syntax, the response type, and the
refusal convention (the WSGI-shaped adapters keep 404, DRF keeps 403,
Django Ninja keeps 401). The raw-ASGI scope middleware FastAPI and
Litestar carried twice is one module now. No public signature moved;
the conformance suite is the proof, thirteen cases against every
adapter, unchanged before and after.

The Django family stays off the table on purpose: its views late-bind
the installation per request and are individually routable public API.
They share the same `_health`/`_metrics`/`_diagnostics` bodies one
level down.

### Fixed

- **A scope over several configurations can no longer tear across a
reload.** Each configuration has its own atomic cell and the engine
keeps no epoch across them, so opening a scope was N independent reads —
and a reload landing between two of them put two generations in one
request. `enter()` now reads every install counter before and after,
and starts over when anything moved, with the same retry budget as the
Rust web core's `Sections::take`. One configuration pays nothing.

The conformance suite gained the case that would have caught it: a
wiring over two configurations, read, moved underneath the request, and
read again — thirteen cases now, asked of all nine adapters.

### Added

- **A standalone DRF example.** `examples/09_django_drf.py` — the health
surface as APIViews and the diagnostics behind
`ConfigDiagnosticsPermission`, beside the django-ninja example instead
of folded into the Django one.

## 0.1.0 — 2026-08-18

### Added
Expand Down
1 change: 1 addition & 0 deletions book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

# Guide

- [Quick Start](quick-start.md)
- [The Rules](rules.md)
- [Wiring & Lifetime](wiring.md)
- [The Request Scope](scope.md)
Expand Down
4 changes: 4 additions & 0 deletions book/src/introduction.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Web Integrations

> **Python.** This book covers the *Python* web adapters
> (`dynamic-config-py-web`). The Rust web crates — axum, Actix, Loco,
> tower — have [their own book](https://dynamic-config-rs.github.io/rust-web/).

`dynamic-config-py` resolves configuration and hands a program a validated
model that a file edit can replace while the process serves. Everything a
*web* application needs around that — where the watcher starts, how a
Expand Down
57 changes: 57 additions & 0 deletions book/src/quick-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Quick Start

```sh
pip install "dynamic-config-py[fastapi]"
```

```python
from dataclasses import dataclass

from fastapi import Depends, FastAPI

from dynamic_config import DynamicConfig
from dynamic_config_web.fastapi import config_dependency, setup


@dataclass
class Database:
host: str = "localhost"
port: int = 5432


config = DynamicConfig(Database, key="db").file("config.toml").env("APP_")

app = FastAPI()
setup(app, config) # lifecycle + request scope + routes
database = config_dependency(config)


@app.get("/")
def index(db: Database = Depends(database)) -> dict[str, str]:
return {"host": db.host}
```

Run it — `uvicorn main:app` — and you have:

| | |
|---|---|
| `GET /` | your handler, reading one pinned snapshot per request |
| `GET /healthz` | 200 while the process lives |
| `GET /readyz` | 200 serving, 503 when nothing loaded or reloads are failing |
| `GET /metrics` | the engine's series, Prometheus text |

`setup` did four things: loaded before the first request (a broken
document fails startup, not traffic), started the watcher and stops it on
shutdown, opened a request scope around every request, and mounted the
routes above. Every adapter here is those same four things
through its own framework's seams — the [Introduction](introduction.md)
has the table of nine.

Edit `config.toml` while it serves: the *next* request answers with the
new document, and no request ever straddles the change — that is the
request scope, and [The Rules](rules.md) is the page that spells out
what it promises and what it refuses to.

Diagnostics (`/_config/explain`, `/_config/check`) exist only when you
pass a guard: `setup(app, config, guard=token_guard("s3cret"))` — see
[Diagnostics](diagnostics.md).
144 changes: 144 additions & 0 deletions examples/09_django_drf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Django REST Framework: the health surface inside DRF's own auth.

pip install django djangorestframework
python examples/09_django_drf.py

The Django adapter supplies everything that is not routing — `AppConfig.ready`
loads and watches, the middleware opens the request scope — and the plain
Django views would already work in a DRF project. What this module adds is
the *permission seam*: the diagnostics sit behind
`ConfigDiagnosticsPermission`, which defers to the installation's guard and
can be swapped for `IsAdminUser`, a scope check, or anything else DRF
offers.

A real project writes:

INSTALLED_APPS = [..., "dynamic_config_web.django", "rest_framework"]
MIDDLEWARE = ["dynamic_config_web.django.middleware.DynamicConfigMiddleware", ...]
DYNAMIC_CONFIG = {"target": "myproject.config:database", "guard": "..."}

from dynamic_config_web.django.drf import urls as config_urls
urlpatterns = [path("internal/", include(config_urls())), ...]

One convention to know: a request DRF refuses gets **403**, where the plain
views answer 404 and django-ninja answers 401. Each adapter keeps its
framework's own convention.
"""

from __future__ import annotations

from typing import Any

from _shared import Database, show, workspace
from dynamic_config import DynamicConfig
from dynamic_config_web import token_guard

try:
import django
from django.conf import settings
except ImportError: # pragma: no cover - the example says how to fix it
raise SystemExit("this example needs Django: pip install django") from None

try:
import rest_framework # noqa: F401
except ImportError: # pragma: no cover
raise SystemExit(
"this example needs DRF: pip install djangorestframework"
) from None

#: Rewritten in `main()`, once the routes exist.
urlpatterns: list[Any] = []

database: DynamicConfig[Database]
guard = token_guard("s3cret")


def configure() -> None:
"""What a `settings.py` would say, said in memory."""
if settings.configured:
return

settings.configure(
DEBUG=False,
SECRET_KEY="example",
ALLOWED_HOSTS=["*"],
ROOT_URLCONF=__name__,
INSTALLED_APPS=["dynamic_config_web.django", "rest_framework"],
MIDDLEWARE=["dynamic_config_web.django.middleware.DynamicConfigMiddleware"],
DATABASES={},
USE_TZ=True,
REST_FRAMEWORK={"UNAUTHENTICATED_USER": None},
DYNAMIC_CONFIG={
"target": f"{__name__}:database",
"debounce": 0.05,
"guard": f"{__name__}:guard",
},
)


def main() -> None:
"""Runs the DRF example end to end."""
global database

with workspace() as path:
database = DynamicConfig(Database, key="db").file(str(path)).env("APP_")

configure()
django.setup()

from django.test import Client
from rest_framework.decorators import api_view
from rest_framework.response import Response

from dynamic_config_web.django import snapshot
from dynamic_config_web.django.drf import urls as config_urls

@api_view(["GET"])
def index(request: Any) -> Response:
"""`snapshot()` needs no argument — the middleware scoped it."""
del request

db: Database = snapshot()

return Response(
{"host": db.host, "port": db.port, "pool": db.pool.max_size}
)

from django.urls import path as route

urlpatterns[:] = [route("", index), *config_urls()]

client = Client()

show("serving")
print(f" GET / → {client.get('/').json()}")

show("the health surface, as APIViews")
print(f" GET /healthz → {client.get('/healthz').status_code}")

ready = client.get("/readyz")
print(f" GET /readyz → {ready.status_code} {ready.json()['status']}")
print(f" GET /metrics → {client.get('/metrics').status_code}")

show("a deployment edits the file")
path.write_text('[db]\nhost = "db.replica"\nport = 6543\n')
database.reload()

print(f" GET / → {client.get('/').json()}")

show("diagnostics, behind DRF's permission")
# 403 rather than ninja's 401 or the plain views' 404: DRF's
# convention, kept on purpose.
print(f" no token → {client.get('/_config/check').status_code}")

answer = client.get(
"/_config/explain/port", headers={"x-config-token": "s3cret"}
)
print(f" with one → {answer.status_code}")

for line in answer.content.decode().splitlines()[:4]:
print(f" {line}")


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion src/dynamic_config_web/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Native web-framework integrations for `dynamic-config-py`.

Seven frameworks, one shape. Whatever the framework calls its startup
Nine adapters, one shape. Whatever the framework calls its startup
hook, its dependency injection and its router, an integration here does
the same five things:

Expand Down
Loading
Loading