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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file doctreen-0.1.0.tar.gz.
File metadata
- Download URL: doctreen-0.1.0.tar.gz
- Upload date:
- Size: 121.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0037a96b83e6c3b8a1a0d43bfaa993532c42399ec17d0263f04f22c4cf819112
|
|
| MD5 |
ae09096d88ab9fa6830c5a3812e65cd1
|
|
| BLAKE2b-256 |
dd6f6558f09dda09bc797db60b3244d0e2c26f1695bb7e2579d70921eb689ee4
|
Provenance
The following attestation bundles were made for doctreen-0.1.0.tar.gz:
Publisher:
workflow.yml on CanDgrmc/doctreen-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
doctreen-0.1.0.tar.gz -
Subject digest:
0037a96b83e6c3b8a1a0d43bfaa993532c42399ec17d0263f04f22c4cf819112 - Sigstore transparency entry: 2313312999
- Sigstore integration time:
-
Permalink:
CanDgrmc/doctreen-py@be0994658708b372a6449faac417cbd7bd7ab6f8 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/CanDgrmc
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
workflow.yml@be0994658708b372a6449faac417cbd7bd7ab6f8 -
Trigger Event:
release
-
Statement type:
File details
Details for the file doctreen-0.1.0-py3-none-any.whl.
File metadata
- Download URL: doctreen-0.1.0-py3-none-any.whl
- Upload date:
- Size: 110.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1fa6057a9f46e82a3d050b960c323af956f9db4833f20306fabccc0169fdac8b
|
|
| MD5 |
e0524872d1ba528193a87cb82e799d0d
|
|
| BLAKE2b-256 |
9596484d751232b43df300f51bc7a3d9ca31e90ba9b737f112c5527070f4f979
|
Provenance
The following attestation bundles were made for doctreen-0.1.0-py3-none-any.whl:
Publisher:
workflow.yml on CanDgrmc/doctreen-py
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
doctreen-0.1.0-py3-none-any.whl -
Subject digest:
1fa6057a9f46e82a3d050b960c323af956f9db4833f20306fabccc0169fdac8b - Sigstore transparency entry: 2313313008
- Sigstore integration time:
-
Permalink:
CanDgrmc/doctreen-py@be0994658708b372a6449faac417cbd7bd7ab6f8 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/CanDgrmc
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
workflow.yml@be0994658708b372a6449faac417cbd7bd7ab6f8 -
Trigger Event:
release
-
Statement type: