Metadata-Version: 2.4
Name: doctreen
Version: 0.1.0
Summary: Code-first API docs, OpenAPI 3.1 export, runtime validation, schema drift detection and integration flows for Flask, FastAPI and Django.
Project-URL: Homepage, https://doctreen.dev
Project-URL: Documentation, https://doctreen.dev/docs
Project-URL: Repository, https://github.com/CanDgrmc/doctreen-py
Project-URL: Issues, https://github.com/CanDgrmc/doctreen-py/issues
Project-URL: Node package, https://github.com/CanDgrmc/doctreen
Author-email: Can Doğramacı <candgrmc@gmail.com>
License: MIT License
        
        Copyright (c) 2026 Can Doğramacı
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: api,api-docs,code-first,django,documentation,fastapi,flask,integration-tests,mock-server,openapi,openapi-3-1,pydantic,request-validation,schema-drift,starlette,validation
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Framework :: FastAPI
Classifier: Framework :: Flask
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Documentation
Classifier: Topic :: Software Development :: Testing
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: django>=4.2; extra == 'dev'
Requires-Dist: djangorestframework>=3.14; extra == 'dev'
Requires-Dist: fastapi>=0.100; extra == 'dev'
Requires-Dist: flask>=2.2; extra == 'dev'
Requires-Dist: httpx>=0.24; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pydantic>=2.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: django
Requires-Dist: django>=4.2; extra == 'django'
Provides-Extra: drf
Requires-Dist: django>=4.2; extra == 'drf'
Requires-Dist: djangorestframework>=3.14; extra == 'drf'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
Provides-Extra: flask
Requires-Dist: flask>=2.2; extra == 'flask'
Provides-Extra: mock
Requires-Dist: faker>=20; extra == 'mock'
Provides-Extra: pydantic
Requires-Dist: pydantic>=2.0; extra == 'pydantic'
Description-Content-Type: text/markdown

# DocTreen for Python

**Code-first API docs, OpenAPI 3.1 export, runtime validation and schema drift detection — for Flask, FastAPI and Django.**

[Docs →](https://doctreen.dev) · [Node package](https://github.com/CanDgrmc/doctreen) · [PyPI](https://pypi.org/project/doctreen/) · 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](https://github.com/CanDgrmc/doctreen)
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)

```python
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`](./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:

```python
flask_adapter(app, {"validate": {"writeback": True, "response": "warn"}})
```

Requests that do not match a route's declared model are rejected before the handler runs:

```json
{
  "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:

```python
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`](./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.

```python
# 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.

```python
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:

```python
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](#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:

```json
{
  "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}}" } }
    }
  ]
}
```

```bash
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

```python
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:

```python
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](https://github.com/CanDgrmc/doctreen/tree/main/conformance) 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

```bash
pip install doctreen
```

Zero runtime dependencies. Everything else is opt-in:

```bash
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

```bash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

pytest
ruff check . && ruff format --check .
mypy
```

---

## License

MIT
