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 body, successful response headers, 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
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,
)
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
def create_todo_headers(todo: Todo) -> CreatedTodoHeaders:
return CreatedTodoHeaders(Location=f"/todos/{todo.id}")
todo_routes = route_group(
route(
create_todo_contract,
create_todo,
response_headers=create_todo_headers,
),
)
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),
)
The synchronous projector keeps HTTP metadata at the route boundary;
Tenchi validates its fixed, scalar fields before the request scope
commits. Aliases are the header names on the wire.
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:
created = await client.call_with_response(
create_todo_contract,
request=CreateTodo(title="Buy milk"),
)
todo = created.body
location = created.headers.location
status = created.http_response.status_code
call() still returns only the validated body. Both client
methods validate declared success headers. openapi_route()
serves an OpenAPI 3.1 document from the same declarations, including
the headers.
The taskboard example builds optimistic concurrency on this surface:
task responses emit ETag, PATCH requests require
If-Match, and stale writes return a declared 412. Task
creation also requires a typed Idempotency-Key so
concurrent retries create once, replay the original response, and
reject a reused key with different input as a declared 409.
Outcomes, deadlines, and observation
Routes that can succeed in more than one way declare named outcomes. A synchronous presenter selects the status-specific body and headers while the use case keeps returning domain data.
from tenchi.responses import PresentedResponse, present, success
created = success(name="created", status=201, response=Todo)
existing = success(name="existing", status=200, response=Todo)
put_todo_contract = contract(
method="PUT",
path="/todos",
request=CreateTodo,
response=Todo,
successes=(created, existing),
timeout=5.0,
)
def present_put(result: PutTodoResult) -> PresentedResponse:
outcome = created if result.created else existing
return present(outcome, body=result.todo)
route(put_todo_contract, put_todo, present=present_put)
A success declared with passthrough=True may instead
carry a Starlette streaming, file, or redirect response. Tenchi
preserves the response while checking its contract-owned status,
media-type parameters, and declared headers. Outcomes without a
body accept only a concrete, provably empty response; streaming
outcomes declare their body type. The typed client validates the
selected outcome and exposes it as
ClientResponse.success.
timeout= cooperatively cancels overdue work, waits for
request-scope cleanup, and returns a framework 504 even if
application code catches the cancellation.
create_app(observers=...) delivers immutable outcomes
with read-only request headers after matched requests finalize for
metrics or tracing; observer failures are logged and isolated from
the response.
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