tenchi

Tenchi

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

uv add tenchi

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

Contracts

A contract declares the method, path, inputs, response, status, and application errors for an endpoint.

from pydantic import BaseModel, Field
from tenchi.contracts import contract

class CreateTodo(BaseModel):
    title: str = Field(min_length=1)

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

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

Use cases and ports

Ports are protocols. A frozen context carries their implementations. Use cases are ordinary async functions.

from dataclasses import dataclass
from typing import Protocol

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

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

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

Application wiring

Bind contracts to use cases, compose the routes, and provide concrete dependencies at the application root.

from tenchi.health import health_route
from tenchi.openapi import openapi_route
from tenchi.routes import route, route_group
from tenchi.server import create_app

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

routes = route_group(
    todo_routes,
    openapi_route(todo_routes, title="Todos", version="1.0.0"),
    health_route(),
)

app = create_app(
    routes=routes,
    context_factory=lambda: AppContext(todos=repository),
)

create_app() also accepts lifespan resources, hooks, middleware, and request-body limits.

Errors

Define stable application errors and declare them on a contract or route group. Undeclared application errors become framework-owned 500s.

from tenchi.errors import AppError, ErrorDef

todo_not_found = ErrorDef(
    code="TODO_NOT_FOUND",
    status=404,
    message="Todo not found",
)

get_todo_contract = contract(
    method="GET",
    path="/todos/{todo_id}",
    params=GetTodoParams,
    response=Todo,
    errors=(todo_not_found,),
)

async def get_todo(params: GetTodoParams, context: AppContext) -> Todo:
    todo = await context.todos.get(params.todo_id)
    if todo is None:
        raise AppError(todo_not_found, details={"todo_id": params.todo_id})
    return todo

Authentication belongs in boundary hooks. Authorization stays in use cases and pure policy functions.

Client and OpenAPI

The async client serializes inputs and validates responses using the same contract.

from tenchi.client import Client

async with Client(base_url="https://api.example.com") as client:
    todo = await client.call(
        create_todo_contract,
        request=CreateTodo(title="Buy milk"),
    )

openapi_route() serves an OpenAPI 3.1 document from the same declarations.

Workers and scripts

execute() validates job data against a use case’s request annotation and applies the same context scoping as the server.

from tenchi.execution import execute

await execute(
    notify_member_added,
    request_json=job.payload,
    context=context,
)

Pagination

Subclass PageQuery for filters, return Page[Item], and build the result with page().

from tenchi.pagination import Page, PageQuery, page

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

async def list_todos(
    query: ListTodosQuery,
    context: AppContext,
) -> Page[Todo]:
    items = await context.todos.list()
    start = query.offset
    window = items[start : start + query.limit]
    return page(window, total=len(items), query=query)

Testing

open_client provides the typed client in-process; open_http exposes raw httpx responses. Both run the app lifespan.

from tenchi.testing import open_client, open_http

async with open_client(app) as client:
    todo = await client.call(
        create_todo_contract,
        request=CreateTodo(title="Buy milk"),
    )

async with open_http(app) as http:
    response = await http.get("/health")
    assert response.status_code == 200

CLI

tenchi new my_app
tenchi make feature notes
tenchi make use-case notes create_note
tenchi routes
tenchi openapi
tenchi doctor
tenchi dev