Skip to main content

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 than str | None. Pydantic evaluates annotations at runtime, and | needs 3.10 — from __future__ import annotations defers 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

doctreen-0.1.0.tar.gz (121.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

doctreen-0.1.0-py3-none-any.whl (110.7 kB view details)

Uploaded Python 3

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

Hashes for doctreen-0.1.0.tar.gz
Algorithm Hash digest
SHA256 0037a96b83e6c3b8a1a0d43bfaa993532c42399ec17d0263f04f22c4cf819112
MD5 ae09096d88ab9fa6830c5a3812e65cd1
BLAKE2b-256 dd6f6558f09dda09bc797db60b3244d0e2c26f1695bb7e2579d70921eb689ee4

See more details on using hashes here.

Provenance

The following attestation bundles were made for doctreen-0.1.0.tar.gz:

Publisher: workflow.yml on CanDgrmc/doctreen-py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for doctreen-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1fa6057a9f46e82a3d050b960c323af956f9db4833f20306fabccc0169fdac8b
MD5 e0524872d1ba528193a87cb82e799d0d
BLAKE2b-256 9596484d751232b43df300f51bc7a3d9ca31e90ba9b737f112c5527070f4f979

See more details on using hashes here.

Provenance

The following attestation bundles were made for doctreen-0.1.0-py3-none-any.whl:

Publisher: workflow.yml on CanDgrmc/doctreen-py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page