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 contract-first, Python-native framework for building REST APIs around use cases, ports, and explicit dependency wiring. It is the Python sibling of Beignet: the same architecture — contracts at the HTTP boundary, use cases at the center, protocol-based ports, infrastructure adapters, and explicit server composition — expressed with plain functions, dataclasses, typing.Protocol, Pydantic v2, and Starlette instead of TypeScript machinery.

Installation

Tenchi requires Python 3.12+.

uv add tenchi          # or: pip install tenchi

To work on this repository:

uv sync                # install the package and dev tools
uv run pytest          # tests (framework + todos example)
uv run ruff check .    # lint
uv run pyright         # strict type checking

Architecture

Applications follow a prescriptive structure. Each feature owns its contracts, schemas, ports, routes, use cases, and tests; infrastructure implements ports; server composition owns concrete wiring:

app/
  features/
    todos/
      contracts.py        # HTTP boundary: method, path, request/response, errors
      schemas.py          # Pydantic models shared by contracts, use cases, ports
      ports.py            # typing.Protocol interfaces the feature needs
      routes.py           # binds contracts to use cases
      use_cases/          # application workflows (plain async functions)
      tests/              # use-case tests, no HTTP required
  shared/
    errors.py             # application error definitions with stable codes
  infra/
    memory_todo_repository.py   # concrete port implementations
    port_wiring.py              # constructs concrete adapters
  server/
    context.py            # AppContext dataclass holding ports
    routes.py             # composes feature route groups
    asgi.py               # concrete wiring + ASGI app
tests/                    # HTTP integration tests

Dependency direction is strict: schemas and use cases never import infrastructure or the HTTP runtime; routes bind contracts to use cases but construct nothing concrete; only server/ (and infra/) know which implementations are in play.

The basic flow

Schemas are ordinary Pydantic models:

# app/features/todos/schemas.py
from pydantic import BaseModel

class CreateTodo(BaseModel):
    title: str

class Todo(BaseModel):
    id: str
    title: str
    completed: bool

Ports describe what application code needs, as protocols:

# app/features/todos/ports.py
from typing import Protocol
from .schemas import Todo

class TodoRepository(Protocol):
    async def create(self, *, title: str) -> Todo: ...
    async def list(self) -> list[Todo]: ...

The application context is a frozen dataclass of ports:

# app/server/context.py
from dataclasses import dataclass
from app.features.todos.ports import TodoRepository

@dataclass(frozen=True, slots=True)
class AppContext:
    todos: TodoRepository

Use cases are plain async functions — no base classes, no decorators:

# app/features/todos/use_cases/create_todo.py
from app.server.context import AppContext
from ..schemas import CreateTodo, Todo

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

Contracts define and validate the HTTP boundary. Any type Pydantic can validate works, including list[Todo]:

# app/features/todos/contracts.py
from tenchi.contracts import contract
from .schemas import CreateTodo, Todo

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

Contracts can also carry documentation metadata (summary=, description=, tags=, deprecated=) and non-JSON media types: pair request_media_type="text/plain" with request=str, or "application/octet-stream" with bytes, and the server, client, and OpenAPI document all follow (useful for webhook endpoints that need the raw body).

Contracts can also declare path parameters (params=), query parameters (query=), and request headers (headers=), each validated into its own model and passed to the use case as a keyword argument of the same name. Header names map to fields by lowercasing and swapping - for _ (X-Api-Keyx_api_key); the client and OpenAPI document reverse the mapping. For example:

class ListTodosQuery(BaseModel):
    completed: bool | None = None

list_todos_contract = contract(
    method="GET",
    path="/todos",
    query=ListTodosQuery,
    response=list[Todo],
)

async def list_todos(query: ListTodosQuery, context: AppContext) -> list[Todo]:
    ...

Routes bind contracts to use cases. Binding is validated eagerly, so a use case that cannot accept what its contract declares fails at import time:

# app/features/todos/routes.py
from tenchi.routes import route, route_group
from .contracts import create_todo_contract
from .use_cases.create_todo import create_todo

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

Server composition owns concrete wiring and produces the ASGI app. The lifespan owns process-scoped resources — it opens them at startup, closes them at shutdown, and whatever it yields is handed to the context factory, which runs once per request:

# app/server/asgi.py
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

from tenchi.server import create_app
from app.features.todos.ports import TodoRepository
from app.infra.port_wiring import open_todo_repository
from app.server.context import AppContext
from app.server.routes import routes

@asynccontextmanager
async def lifespan() -> AsyncGenerator[TodoRepository]:
    async with open_todo_repository("todos.db") as todos:
        yield todos

def create_context(todos: TodoRepository) -> AppContext:
    return AppContext(todos=todos)

app = create_app(routes=routes, context_factory=create_context, lifespan=lifespan)

For apps without real resources, lifespan is optional and the context factory can take zero arguments and close over module-scoped objects (see the memory-backed fixtures in the example tests).

Run it with any ASGI server:

uvicorn app.server.asgi:app --reload
curl -X POST localhost:8000/todos -H 'content-type: application/json' \
  -d '{"title": "Buy milk"}'

Hooks and authentication

Authentication belongs at the HTTP boundary; business authorization belongs in use cases. The boundary seam is create_app(hooks=...): each hook receives a RequestInfo (method, path, lowercased headers, and the matched contract) plus the request context, runs before input validation, and either raises an AppError to reject or returns an enriched context to attach identity:

# app/server/hooks.py
from dataclasses import replace
from tenchi.errors import AppError
from tenchi.server import RequestInfo

def require_api_key(info: RequestInfo, context: AppContext) -> AppContext | None:
    if "public" in info.contract.tags:
        return None
    key = info.headers.get("x-api-key")
    if key is None:
        raise AppError(unauthorized)
    return replace(context, user=lookup_user(key))

Hook-raised errors follow the same honesty rule as use-case errors: they must be declared to be exposed. Declare them once for a whole group — this also documents the 401 on every route in the OpenAPI document:

# app/server/routes.py
api_routes = route_group(todo_routes, errors=(unauthorized,))

The todos example wires an optional API-key hook this way; see examples/todos/app/server/hooks.py.

Typed client

The same contracts drive a typed httpx-based client — no code generation, no drift. call() returns the contract's response type, so todo below is statically a Todo and todos a list[Todo]:

from tenchi.client import Client

async with Client(base_url="http://localhost:8000") as client:
    todo = await client.call(create_todo_contract, request=CreateTodo(title="Buy milk"))
    todos = await client.call(list_todos_contract, query=ListTodosQuery(completed=False))

Declared errors come back as the same AppError the server raised, carrying the same ErrorDef; anything undeclared raises UnexpectedResponseError:

try:
    await client.call(get_todo_contract, params=GetTodoParams(todo_id="missing"))
except AppError as err:
    assert err.definition == todo_not_found

For tests, pass your own httpx.AsyncClient with an ASGITransport via Client(http=...) to call the app in-process.

Errors

Application errors carry a stable code, an HTTP status, and optional structured details. Contracts declare the errors they are expected to return; declared errors map to their status, and everything else — including undeclared AppErrors — becomes a framework-owned 500 so contracts stay honest:

# app/shared/errors.py
from tenchi.errors import ErrorDef

todo_not_found = ErrorDef(code="TODO_NOT_FOUND", status=404, message="Todo not found")
# in a use case
raise AppError(todo_not_found, details={"todo_id": params.todo_id})

Errors can carry response headers — declare the names on the definition (they appear in the OpenAPI document) and set values per instance:

throttled = ErrorDef(code="THROTTLED", status=429, message="Slow down",
                     headers=("Retry-After",))
raise AppError(throttled, headers={"Retry-After": "30"})
# in a contract
get_todo_contract = contract(
    method="GET",
    path="/todos/{todo_id}",
    params=GetTodoParams,
    response=Todo,
    errors=(todo_not_found,),
)

Error responses use a flat envelope, {"code", "message", "details"?}, and every error response carries an x-tenchi-error-source header set to app or framework so the two are always distinguishable.

Testing

Use cases test without HTTP — construct a context with a fake or memory adapter and call the function:

async def test_create_todo() -> None:
    context = AppContext(todos=MemoryTodoRepository())
    todo = await create_todo(CreateTodo(title="Buy milk"), context)
    assert todo.title == "Buy milk"

Integration tests exercise the full boundary with httpx.ASGITransport; see examples/todos/tests/test_todos_http.py. When the app uses a lifespan, wrap it in asgi-lifespan's LifespanManager so startup and shutdown run (ASGITransport alone does not trigger lifespan events); see examples/todos/tests/test_todos_lifespan.py.

OpenAPI

Contracts carry everything an OpenAPI document needs, so generation is a pure function — no decorators, no runtime introspection of handlers:

from tenchi.openapi import openapi_schema

document = openapi_schema(api_routes, title="Todos", version="0.1.0")

Request bodies use validation-mode JSON Schema, responses use serialization mode, path/query parameters come from the params/query models, declared errors appear as error responses under their status with the standard envelope schema, and routes with validated input document the framework's 422 automatically.

To serve the document, compose openapi_route alongside your routes in server/routes.py — it is generated once at startup and served by the same route machinery it describes (and it does not document itself):

from tenchi.openapi import openapi_route

api_routes = route_group(todo_routes)
routes = route_group(
    api_routes,
    openapi_route(api_routes, title="Todos", version="0.1.0"),
)

CLI

tenchi new my_app                      # scaffold a new application
tenchi make feature notes              # generate a feature skeleton
tenchi make use-case notes create_note # generate a use-case stub and test
tenchi routes                          # print the bound route table
tenchi openapi [-o openapi.json]       # print or write the OpenAPI document
tenchi doctor                          # check dependency direction and structure
tenchi dev                             # serve app.server.asgi:app with reload

Generators create files and print wiring instructions — they never edit existing modules, because dependency wiring stays explicit and app-owned. Everything they generate passes Ruff, Pyright strict, pytest, and tenchi doctor as-is.

tenchi doctor statically enforces the dependency direction: use cases that import concrete infrastructure, schemas that import the HTTP runtime, shared code that depends on features, and similar violations are reported with file, line, and the rule broken:

app/features/todos/use_cases/create_todo.py:1  imports app.infra.port_wiring: use cases must not import concrete infrastructure

tenchi new generates the todos starter — feature, ports, memory adapter, wiring, and passing tests — so a new project starts from a working vertical slice:

uv run tenchi new my_app
cd my_app && uv sync && uv run pytest

tenchi routes prints every bound route with its status, use case, and declared error codes:

POST  /todos            201  app.features.todos.use_cases.create_todo.create_todo
GET   /todos            200  app.features.todos.use_cases.list_todos.list_todos
GET   /todos/{todo_id}  200  app.features.todos.use_cases.get_todo.get_todo  [TODO_NOT_FOUND]
GET   /openapi.json     200  tenchi.openapi.openapi_route.<locals>.get_openapi

Example

A complete todos application using the prescribed structure lives in examples/todos/. It ships two adapters for the same port: the SQLite repository (aiosqlite) wired into the running app through the lifespan, and the memory repository used by unit tests — swapping them touches only infra/ and server/.

Status

Tenchi is an early vertical slice: contracts (body, path, and query validation), route binding, ASGI dispatch, lifespan-managed resources with request-scoped context, ports, expected-error mapping, a contract-driven typed client, OpenAPI 3.1 generation, and the full CLI (new, make feature, make use-case, routes, openapi, doctor, dev). Provider-backed infrastructure is planned but intentionally not started.

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.2.0.tar.gz (81.1 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.2.0-py3-none-any.whl (32.4 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: tenchi-0.2.0.tar.gz
  • Upload date:
  • Size: 81.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","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.2.0.tar.gz
Algorithm Hash digest
SHA256 ae7f878983fac270984f30294765591a77f8e8f8134ce2e5d42e0506f284db2c
MD5 332ee2f7d6103dc0cd6f9c480a5b932a
BLAKE2b-256 b6450bc5f834833ca9a8dfe606020aca759f9197aa8d6850818d5a04131bed53

See more details on using hashes here.

File details

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

File metadata

  • Download URL: tenchi-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 32.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","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.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2864f6f1574bf59b9333a3514bc8a379ba4d451c29c7d7a0b5c2855b443d53a7
MD5 dd797170e1654d97cacda9532ef9ec1e
BLAKE2b-256 0d84a50645eb66f42a5943703cff90ff83f05c4176ad39ef0b7b25fc392153f0

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