DocTreen for Python
Code-first API docs, OpenAPI 3.1 export, runtime validation and schema drift detection — for Flask, FastAPI and Django.
Docs → · Node package · PyPI · License: MIT
Status: alpha. Flask works end to end — docs UI, OpenAPI 3.1 export, runtime validation and schema drift detection, from the routes you already have. FastAPI is supported for drift alongside its own docs, and Django/DRF reads your URLconf and serializers. Integration flows run under pytest. The exporter's output is verified byte-for-byte against the Node reference by a shared conformance suite.
Why this exists
FastAPI already gives you Pydantic-native docs and validation for free, so DocTreen for Python is not another "generate Swagger from your models" library. It targets the parts of the problem that no Python framework solves:
Flask and Django never got FastAPI's DX. The existing options ask you to rewrite your
router, move to an Api/Blueprint abstraction, or bind tightly to DRF serializers.
DocTreen mounts on top of the app you already have and reads your existing routes.
The layer above docs doesn't exist anywhere. Schema drift detection, runnable
integration flows, a spec-driven mock server and contract observability aren't in
FastAPI, drf-spectacular or flask-smorest. They're framework-agnostic, and they're
where DocTreen's value sits once docs are handled.
Nothing else is bilingual. If your stack is Node and Python, DocTreen is the only way to get one contract surface across both — same schema model, same OpenAPI output, same drift format.
If you use FastAPI, keep its docs and use DocTreen for drift and flows. If you use Flask or Django, DocTreen is for both.
Open source, and staying that way
DocTreen is MIT-licensed and free to use, in production, commercially, forever. There is no paid tier of this package, no feature behind a licence key, no usage cap and no telemetry — the library never phones home.
A hosted service for contract observability is a separate, optional product. It does not change what this package does: everything it consumes is a documented, public extension point that you can point anywhere or implement yourself.
| Extension point | What it does | Self-hosted? |
|---|---|---|
drift.webhook |
POSTs drift events to any HTTPS URL | yes — it's your endpoint |
drift.store |
Pluggable storage (Redis, Postgres, anything) | yes — you write it |
announce_routes |
Optional store hook receiving the route inventory at startup | yes |
| Release attribution | Reads a git sha from env vars or a build stamp | yes |
If the hosted service ever disappears, nothing in this package stops working. That is the deal, and it is the same deal the Node package makes.
Roadmap
| Phase | Ships | Status |
|---|---|---|
| 0 | Spec (SPEC.md), shared conformance fixtures, scaffold |
done |
| 1 | Schema builder, route registry, OpenAPI 3.1 export, Pydantic adapter, docs UI, Flask adapter | done |
| 2 | Runtime validation (422), response assertion, schema drift + stores | done |
| 3 | FastAPI / Starlette adapter — drift over the app's own spec | done |
| 4 | Django / DRF adapter — URLconf introspection, serializer schemas | done |
| 5 | Integration flows + a pytest plugin that runs them as tests |
done |
| 6 | Mock server, Python client codegen | planned |
Quick start (Flask)
from flask import Flask
from pydantic import BaseModel
from doctreen.adapters.flask import define_route, flask_adapter
app = Flask(__name__)
class User(BaseModel):
id: int
name: str
@app.get("/users/<int:user_id>")
@define_route(description="Get a user", response=User, errors={404: "Not found"})
def get_user(user_id: int):
return {"id": user_id, "name": "Ada"}
# Mount anywhere — routes registered later are still picked up.
app.register_blueprint(flask_adapter(app, {"meta": {"title": "My API", "version": "1.0.0"}}))
That gives you GET /docs (the interactive UI) and GET /docs/openapi.json (the spec).
The path parameter's type comes from Flask's <int:> converter — nothing to declare.
define_route attaches metadata and returns your function unchanged; it is not a wrapper
and cannot change how the route behaves.
A runnable version lives in examples/flask_app.py.
On Python 3.9, write Pydantic fields as
Optional[str]rather thanstr | None. Pydantic evaluates annotations at runtime, and|needs 3.10 —from __future__ import annotationsdefers the evaluation without changing what can be parsed when it happens.
Runtime validation
Opt in once, on the adapter:
flask_adapter(app, {"validate": {"writeback": True, "response": "warn"}})
Requests that do not match a route's declared model are rejected before the handler runs:
{
"error": "validation_failed",
"issues": [
{ "path": "body.email", "message": "Field required", "code": "missing" }
]
}
Every part is checked in one pass, so a request with a bad body and a bad query reports both rather than making you fix one and retry.
writeback pushes the parsed payload — coercions applied, defaults filled in — at your
handler. Query strings and path segments arrive as text, so ?limit=5 reaches you as the
integer 5. Path parameters are replaced in the view's arguments directly; body and query
land on g.doctreen_validated, because Flask's request.args is immutable and its JSON is
cached.
response is a development-time check that never touches the body: "warn" logs a
mismatch, "throw" surfaces it, "off" (default) does nothing. It is status-aware —
a 409 body is checked against the schema declared for 409, not against the success
schema — so error envelopes stop producing phantom failures.
Validation needs a real parser, so it applies to routes declared with Pydantic models.
A route declared with the s.* builders is documented but not validated: a SchemaNode
describes a shape, and checking against a description would either reject valid payloads
or wave invalid ones through.
FastAPI
FastAPI is the one framework DocTreen does not try to document. It already gives you
Pydantic-native docs, an OpenAPI 3.1 spec and 422-on-invalid-request validation. So the
adapter reads rather than derives — it consumes app.openapi() and adds the part FastAPI
does not have:
from doctreen.adapters.fastapi import mount_doctreen
mount_doctreen(app, {"drift": {"enabled": True}})
FastAPI's Swagger UI stays exactly where it is at /docs; DocTreen defaults to
/doctreen so the two never collide. You get /doctreen/drift.json — what your clients
are actually sending, measured against what your routes declare.
Request validation is deliberately not installed: FastAPI already rejects invalid bodies, and a second validator would either duplicate that work or disagree with it.
A runnable version lives in examples/fastapi_app.py.
Django and DRF
Splice the URLs into the URLconf you already have. There is no router to replace and no base class to inherit — DocTreen walks the patterns Django has been keeping all along, and reads your DRF serializers, because that is what a Django API already uses to describe its payloads.
# urls.py
from doctreen.adapters.django import doctreen_urls
urlpatterns = [
path("api/", include(router.urls)),
*doctreen_urls({"meta": {"title": "My API"}, "drift": {"enabled": True}}),
]
# settings.py — for drift sampling
MIDDLEWARE = [..., "doctreen.adapters.django.DocTreenMiddleware"]
A DRF ViewSet expands into one documented route per action, each carrying its own
docstring: list becomes GET /users, create becomes POST /users, and so on. Router
regexes and path() converters are both understood, so <int:pk> arrives typed.
Scope is deliberately DRF. A plain Django view returning HttpResponse declares no
contract, so it is listed but carries no schema — inferring one would produce confident
documentation of something nobody promised. Request validation is not installed either:
a serializer already validates in the view, with an error format your clients depend on.
Schema drift detection
Validation rejects a payload that does not match. Drift measures the gap instead — the only option for an API whose clients you do not control and cannot break.
flask_adapter(app, {"drift": {"enabled": True, "sampleRate": 0.01}})
Real traffic is compared against the declared shape and the mismatches are aggregated at
GET /docs/drift.json: totals by kind (missing-required, unexpected-field,
type-mismatch), by part and by field, plus rolling hourly and daily buckets. At the
default 1% sample rate a busy endpoint costs almost nothing to watch, and the aggregate
still tells you that email has been arriving as null for two days.
Drift is on outside production by default. Query strings and path segments are compared
leniently, because "5" and "true" are the only spellings a URL has — flagging
?limit=5 would fill the report with noise about HTTP rather than about clients. A JSON
body is held to the declared types exactly: {"age": "30"} genuinely is a string where a
number was promised, and a client that starts sending it is precisely what this exists to
catch.
On multi-process deployments. Under gunicorn, uWSGI or any pre-forking server, each
worker holds its own in-memory store and /docs/drift.json reports whichever worker
answered. That is what "in memory" means when there are several memories. The default
store is for development and single-worker deployments; anything else wants a shared one:
class RedisDriftStore:
def record(self, event): ... # required
def report(self): ... # required
def reset(self): ... # required
def announce_routes(self, routes, meta): ... # optional
flask_adapter(app, {"drift": {"store": RedisDriftStore()}})
drift.webhook POSTs each event to any URL you control, drift.onDrift calls back into
your own code, and announce_routes receives the route inventory at startup so endpoints
no traffic has reached yet are still visible. None of these know anything about a hosted
service — see Open source, and staying that way.
Integration flows, as pytest tests
A flow is a named sequence of HTTP steps with values threaded between them — a test you
can also read. Name the document *.flow.json and it becomes a pytest test:
{
"version": 1,
"name": "User onboarding",
"inputs": { "email": { "type": "string", "required": true } },
"steps": [
{
"id": "create",
"request": { "method": "POST", "path": "/users",
"body": { "email": "{{input.email}}" } },
"extract": { "userId": { "from": "body", "path": "$.id" } },
"assert": { "status": 201, "exists": ["$.id"] }
},
{
"id": "fetch",
"request": { "method": "GET", "path": "/users/{{vars.userId}}" },
"assert": { "status": 200, "body": { "$.email": "{{input.email}}" } }
}
]
}
pytest --doctreen-base-url http://localhost:5000 --doctreen-input email=a@b.c
The plugin registers itself on install — no conftest.py line. A failing flow reports the
step that broke and what it expected, not a diff of two large JSON blobs. There is also a
doctreen-flow CLI for the flows that are not part of a test suite: a smoke check after a
deploy, a step in a release script.
This is the one place the Python package deliberately does more than Node. Node runs flows through a CLI, which is right for that ecosystem. In Python an integration test belongs in the test suite: collected by pytest, reported alongside everything else, failing the build the same way, with no separate command to remember in CI.
The flow document is identical across both, though — a flow written for one runner runs
unchanged on the other. Point flowsPath at a directory and the same files also appear in
the docs UI's Flows tab.
Schemas without Pydantic
from doctreen import s, define_schema
User = define_schema(
"User",
s.object(
{
"id": s.number(),
"name": s.string(),
"role": s.default(s.enum(["admin", "user"]), "user"),
"deletedAt": s.nullable(s.optional(s.string())),
}
),
)
…and turn a route list into an OpenAPI 3.1 document:
from doctreen import RouteRegistry, normalize_config
from doctreen.exporters.openapi import build_openapi_document
registry = RouteRegistry()
entry = registry.add({"method": "GET", "path": "/users/:id", "params": ["id"]})
entry["responseSchema"] = User
doc = build_openapi_document(
registry.get_visible(),
normalize_config({"meta": {"title": "My API", "version": "1.0.0"}}),
)
The builder mirrors the Node s helper method-for-method, and the exporter produces
JSON byte-identical to Node's — same values, same key order. That is not an aspiration:
the conformance suite in
the Node repo runs the same fixtures through both implementations on every push.
The docs UI shares its entire visual surface with the Node package: the stylesheet and
browser script are extracted verbatim from it by scripts/sync_ui_assets.py, so the two
cannot drift apart by hand-copying.
Install
pip install doctreen
Zero runtime dependencies. Everything else is opt-in:
pip install "doctreen[pydantic]" # Pydantic v2 schemas
pip install "doctreen[flask]" # Flask adapter
pip install "doctreen[django]" # Django / DRF adapter
pip install "doctreen[fastapi]" # FastAPI / Starlette adapter
Requires Python 3.9+.
Development
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
ruff check . && ruff format --check .
mypy
License
MIT
Metadata
Release files for doctreen 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| doctreen-0.1.0.tar.gz | 121.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| doctreen-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 231.9 kB
Release files / doctreen-0.1.0.tar.gz
| Download URL | doctreen-0.1.0.tar.gz |
|---|---|
| Size | 121.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0037a96b83e6c3b8a1a0d43bfaa993532c42399ec17d0263f04f22c4cf819112
|
|
BLAKE2b-256 checksum How to use checksums |
dd6f6558f09dda09bc797db60b3244d0e2c26f1695bb7e2579d70921eb689ee4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 1, 2026.
Transparency logRelease files / doctreen-0.1.0-py3-none-any.whl
| Download URL | doctreen-0.1.0-py3-none-any.whl |
|---|---|
| Size | 110.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1fa6057a9f46e82a3d050b960c323af956f9db4833f20306fabccc0169fdac8b
|
|
BLAKE2b-256 checksum How to use checksums |
9596484d751232b43df300f51bc7a3d9ca31e90ba9b737f112c5527070f4f979
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 1, 2026.
Transparency log