Skip to main content

A contract-first, Python-native framework for building REST APIs around use cases, ports, and explicit wiring.

Project description

Tenchi

Tenchi is a small, contract-first Python framework for building typed HTTP APIs. Contracts define the boundary, plain async functions implement use cases, and frozen dataclasses carry explicitly wired dependencies.

Tenchi uses Pydantic for validation, Starlette for ASGI, and httpx for its typed client. It requires Python 3.12 or newer and is currently pre-1.0.

Read the documentation for the quickstart, mental model, complete contract and runtime guides, production handbook, comparisons, and module reference.

Quick start

Create and run a working application:

uvx tenchi new my_app
cd my_app
uv sync
uv run tenchi check
uv run tenchi dev

The generated app includes a todos feature, SQLite persistence, memory-backed unit tests, Swagger UI, health and OpenAPI routes, an agent guide, and a CI compatibility gate. It also includes .mcp.json, so MCP-aware coding agents can use Tenchi's structured inspection and validation tools after uv sync. With the server running, open http://127.0.0.1:8000/docs or call it directly:

curl -X POST http://127.0.0.1:8000/todos \
  -H 'content-type: application/json' \
  -d '{"title": "Buy milk"}'

To add Tenchi to an existing project instead:

uv add tenchi

Add MCP support with uv add --dev "tenchi[mcp]" and follow the MCP server guide to connect a coding agent.

Follow the existing-project guide to add the first contract, use case, ASGI application, test, and OpenAPI baseline.

How it works

A contract declares the HTTP boundary:

class CreatedTodoHeaders(BaseModel):
    location: str = Field(alias="Location")


create_todo_contract = contract(
    method="POST",
    path="/todos",
    request=CreateTodo,
    response=Todo,
    response_headers=CreatedTodoHeaders,
    status=201,
)

A use case is an ordinary async function whose dependencies come from the app context:

async def create_todo(request: CreateTodo, context: AppContext) -> Todo:
    return await context.todos.create(title=request.title)

A route binds them together. Tenchi immediately checks that every boundary parameter and the return annotation exactly match the contract, so invalid wiring fails during application composition rather than on a request:

def create_todo_headers(todo: Todo) -> CreatedTodoHeaders:
    return CreatedTodoHeaders(Location=f"/todos/{todo.id}")


routes = route_group(
    route(
        create_todo_contract,
        create_todo,
        response_headers=create_todo_headers,
    ),
)

The synchronous response-header projector keeps HTTP metadata at the route boundary while the use case continues to return only domain data. Tenchi validates and serializes those headers before the request scope commits. The typed client validates them on every call; use call_with_response() when you also want the typed headers and underlying httpx response.

Applications use this structure:

app/
  features/<feature>/   # contracts, schemas, ports, routes, use cases
  shared/               # shared errors and domain concepts
  infra/                # concrete port implementations
  server/               # context, hooks, route composition, ASGI app
tests/                  # HTTP integration tests

The main pieces are:

  • Pydantic validation for request bodies, path parameters, query parameters, request and successful response headers, and response bodies; field aliases are the names used on the wire and in OpenAPI, and nullable request types can send JSON null explicitly. Declared media types are enforced against wire Content-Type: mismatched requests receive a framework-owned 415 and the typed client rejects mismatched responses. Charset-qualified text contracts are encoded and decoded strictly in both directions; unsupported declared charsets fail when the contract is built.
  • typing.Protocol ports and explicit dependency wiring instead of a DI container.
  • Declared application errors with a stable JSON envelope.
  • A named exception hierarchy that distinguishes configuration mistakes from runtime application and transport failures.
  • A contract-driven async client, OpenAPI 3.1 generation, and optional Swagger UI route.
  • Lifespan resources, request-scoped contexts, authentication hooks, middleware, request deadlines, outcome observers, pagination, health checks, and in-process testing helpers.

public defaults to False. Set public=True for operations that an authentication hook should exempt, then inspect the metadata in the hook:

health = contract(method="GET", path="/health", response=Health, public=True)


def authenticate(info: RequestInfo, context: AppContext) -> AppContext | None:
    if info.contract.public:
        return None
    # Authenticate and return an enriched context, or raise AppError.

When OpenAPI security schemes are configured, public operations receive an empty per-operation security requirement. health_route() and openapi_route() and swagger_ui_route() are public by default; pass public=False to protect them. The metadata itself does not authenticate requests—application hooks remain in control.

For endpoints with more than one successful status, declare response definitions and select one in a synchronous presenter. Their body and header types become the contract's aggregate typed-client result, so they are the only source of truth. The same mechanism is Tenchi's controlled HTTP escape hatch: a passthrough definition may return a Starlette StreamingResponse, FileResponse, or redirect while its status, media type, media-type parameters, and headers remain contract-owned. No-body definitions accept only a concrete response with an empty materialized body; streaming definitions must declare their body type. The typed client reports the selected definition on ClientResponse.definition and validates its body and headers.

from tenchi.responses import PresentedResponse, present, response

created = response(Todo, status=201)
existing = response(Todo, status=200)

put_todo_contract = contract(
    method="PUT",
    path="/todos",
    request=CreateTodo,
    responses=(created, existing),
    timeout=5.0,
)

def present_put(result: PutTodoResult) -> PresentedResponse:
    outcome = created if result.created else existing
    return present(outcome, result.todo)

route(put_todo_contract, put_todo, present=present_put)

When one status permits alternative top-level body schemas, pass them as separate positional alternatives so Pyright preserves the exact union:

flexible = response(Todo, str, status=200)  # ResponseDef[Todo | str, None]

Nested unions retain their ordinary spelling, such as response(list[Todo | str], status=200). Each response definition has one fixed object-shaped header schema; differing header shapes belong in separate definitions.

timeout= cooperatively cancels overdue work, lets request-scope cleanup and rollback finish, then returns the framework's 504 even if application code catches the injected cancellation. create_app(observers=...) delivers an immutable RequestOutcome, including a read-only header mapping, after each matched route has finalized. Observer failures are logged and never change the response.

See examples/todos for the small teaching app and examples/taskboard for a larger application with authentication, authorization, SQLite transactions, optimistic concurrency through ETag / If-Match, idempotent task creation, multiple successful outcomes, request observation, deadlines, and background work.

CLI

tenchi new my_app
tenchi make feature notes --dry-run
tenchi make feature notes --json
tenchi make use-case notes create_note --dry-run
tenchi routes
tenchi map
tenchi map --feature notes --kind route,use-case,port --json
tenchi openapi
tenchi openapi --diff openapi.json
tenchi openapi --diff-ref origin/main --snapshot openapi.json
tenchi openapi --check openapi.json
tenchi openapi --write openapi.json
tenchi doctor --json
tenchi check
tenchi mcp
tenchi dev

openapi --write stores canonical, key-sorted JSON. Before accepting a changed snapshot, run openapi --diff to classify changes as breaking, additive, metadata-only, or unknown. Breaking and unknown changes return a non-zero status; additive and metadata-only changes pass. Use --diff-format json for machine-readable output. --diff-ref reads the snapshot at a Git commit, which keeps a pull-request gate historical even when the branch updates its snapshot. openapi --check remains the exact drift check for tests and CI. Pass the same --routes, --title, --version, --description, and --security options in every command when your document uses them. Run --diff before replacing the baseline with --write. --output and -o remain aliases for --write. For programmatic checks, import analyze_openapi_compatibility from tenchi.compatibility.

tenchi map combines source declarations with the composed route group into a deterministic graph of features, contracts, routes, use cases, policies, ports, adapters, context, entrypoints, and tests. Every relationship includes source evidence and a confidence level. Use --feature for a feature plus its direct cross-feature dependencies, --kind for a comma-separated node projection, and --json for the versioned result.

Generator --dry-run output lists every file without writing it. make, map, doctor, and check accept --json and return versioned results for agents and automation. tenchi check runs Ruff formatting and linting, Pyright, pytest, doctor, and the OpenAPI snapshot check even when an earlier step fails; failed output is bounded and each step reports its duration. Tenchi snapshots the JSON Schema for these results and the MCP tool surface; breaking protocol changes require a new schema_version. See the coding-agent workflow for the complete inspect, preview, edit, validate, and compatibility loop.

tenchi mcp serves the same versioned map, route, doctor, generator-preview, OpenAPI-diff, and check results over stdio. Inspection and preview tools do not write application files; project-owned commands executed by check retain their normal side effects. Generated apps register the command in .mcp.json. Existing apps install the optional development dependency with uv add --dev "tenchi[mcp]".

Run tenchi <command> --help for command options.

Development

uv sync
uv run pytest
uv run ruff format --check .
uv run ruff check .
uv run pyright

The documentation is a separate Bun and Next.js application:

cd docs
bun install
bun run dev

Run bun run check in docs/ to lint, type-check, test, and build the static GitHub Pages export. Search data and llms.txt files are generated during the build from the registered MDX pages.

Tenchi is licensed under the MIT License.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tenchi-0.10.0.tar.gz (421.8 kB view details)

Uploaded Source

Built Distribution

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

tenchi-0.10.0-py3-none-any.whl (120.9 kB view details)

Uploaded Python 3

File details

Details for the file tenchi-0.10.0.tar.gz.

File metadata

  • Download URL: tenchi-0.10.0.tar.gz
  • Upload date:
  • Size: 421.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for tenchi-0.10.0.tar.gz
Algorithm Hash digest
SHA256 6aa533a8cc7ac67849d17f36939517005b505b2e1e0319d3422a20f49c469d1a
MD5 9ecd7bcbe752d55582f099d0c42b4a08
BLAKE2b-256 7b00c06569ba07ca3bc28bc620a6ea23911f708e336c35b72de8ed54d2f086c0

See more details on using hashes here.

File details

Details for the file tenchi-0.10.0-py3-none-any.whl.

File metadata

  • Download URL: tenchi-0.10.0-py3-none-any.whl
  • Upload date:
  • Size: 120.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for tenchi-0.10.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a352248859b4282f7da6d0535fd12a137bf83ff53849adf8118bc5fdbbcc5b6f
MD5 e765ca56640879d6b967459238ef1e6c
BLAKE2b-256 3a6f6cd1cfd7b47751c988da5a35d0e06156fa4195bbdf0220531cd69d2a98e6

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page