tenchi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. tenchi-0.1.0/.github/workflows/ci.yml +20 -0
  2. tenchi-0.1.0/.github/workflows/release.yml +34 -0
  3. tenchi-0.1.0/.gitignore +219 -0
  4. tenchi-0.1.0/LICENSE +21 -0
  5. tenchi-0.1.0/PKG-INFO +403 -0
  6. tenchi-0.1.0/README.md +378 -0
  7. tenchi-0.1.0/examples/todos/app/__init__.py +0 -0
  8. tenchi-0.1.0/examples/todos/app/features/__init__.py +0 -0
  9. tenchi-0.1.0/examples/todos/app/features/todos/__init__.py +0 -0
  10. tenchi-0.1.0/examples/todos/app/features/todos/contracts.py +34 -0
  11. tenchi-0.1.0/examples/todos/app/features/todos/ports.py +11 -0
  12. tenchi-0.1.0/examples/todos/app/features/todos/routes.py +12 -0
  13. tenchi-0.1.0/examples/todos/app/features/todos/schemas.py +19 -0
  14. tenchi-0.1.0/examples/todos/app/features/todos/tests/__init__.py +0 -0
  15. tenchi-0.1.0/examples/todos/app/features/todos/tests/test_create_todo.py +15 -0
  16. tenchi-0.1.0/examples/todos/app/features/todos/tests/test_get_todo.py +28 -0
  17. tenchi-0.1.0/examples/todos/app/features/todos/tests/test_list_todos.py +24 -0
  18. tenchi-0.1.0/examples/todos/app/features/todos/use_cases/__init__.py +0 -0
  19. tenchi-0.1.0/examples/todos/app/features/todos/use_cases/create_todo.py +7 -0
  20. tenchi-0.1.0/examples/todos/app/features/todos/use_cases/get_todo.py +12 -0
  21. tenchi-0.1.0/examples/todos/app/features/todos/use_cases/list_todos.py +10 -0
  22. tenchi-0.1.0/examples/todos/app/infra/__init__.py +0 -0
  23. tenchi-0.1.0/examples/todos/app/infra/memory_todo_repository.py +21 -0
  24. tenchi-0.1.0/examples/todos/app/infra/port_wiring.py +16 -0
  25. tenchi-0.1.0/examples/todos/app/infra/sqlite_todo_repository.py +66 -0
  26. tenchi-0.1.0/examples/todos/app/server/__init__.py +0 -0
  27. tenchi-0.1.0/examples/todos/app/server/asgi.py +35 -0
  28. tenchi-0.1.0/examples/todos/app/server/context.py +8 -0
  29. tenchi-0.1.0/examples/todos/app/server/routes.py +10 -0
  30. tenchi-0.1.0/examples/todos/app/shared/__init__.py +0 -0
  31. tenchi-0.1.0/examples/todos/app/shared/errors.py +7 -0
  32. tenchi-0.1.0/examples/todos/tests/test_sqlite_todo_repository.py +28 -0
  33. tenchi-0.1.0/examples/todos/tests/test_todos_client.py +64 -0
  34. tenchi-0.1.0/examples/todos/tests/test_todos_http.py +141 -0
  35. tenchi-0.1.0/examples/todos/tests/test_todos_lifespan.py +56 -0
  36. tenchi-0.1.0/examples/todos/tests/test_todos_openapi.py +38 -0
  37. tenchi-0.1.0/pyproject.toml +70 -0
  38. tenchi-0.1.0/src/tenchi/__init__.py +32 -0
  39. tenchi-0.1.0/src/tenchi/cli.py +321 -0
  40. tenchi-0.1.0/src/tenchi/client.py +203 -0
  41. tenchi-0.1.0/src/tenchi/contracts.py +132 -0
  42. tenchi-0.1.0/src/tenchi/errors.py +113 -0
  43. tenchi-0.1.0/src/tenchi/openapi.py +237 -0
  44. tenchi-0.1.0/src/tenchi/py.typed +0 -0
  45. tenchi-0.1.0/src/tenchi/routes.py +157 -0
  46. tenchi-0.1.0/src/tenchi/scaffold.py +385 -0
  47. tenchi-0.1.0/src/tenchi/server.py +346 -0
  48. tenchi-0.1.0/tests/test_cli.py +302 -0
  49. tenchi-0.1.0/tests/test_client.py +182 -0
  50. tenchi-0.1.0/tests/test_contracts.py +64 -0
  51. tenchi-0.1.0/tests/test_errors.py +47 -0
  52. tenchi-0.1.0/tests/test_lifespan.py +122 -0
  53. tenchi-0.1.0/tests/test_openapi.py +292 -0
  54. tenchi-0.1.0/tests/test_routes.py +115 -0
  55. tenchi-0.1.0/tests/test_server.py +304 -0
  56. tenchi-0.1.0/uv.lock +749 -0
@@ -0,0 +1,20 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ checks:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: uv sync --locked
17
+ - run: uv run ruff format --check .
18
+ - run: uv run ruff check .
19
+ - run: uv run pyright
20
+ - run: uv run pytest
@@ -0,0 +1,34 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write
14
+ contents: read
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v5
18
+ with:
19
+ python-version: "3.12"
20
+ - run: uv sync --locked
21
+ - name: Check tag matches project version
22
+ if: github.ref_type == 'tag'
23
+ run: |
24
+ version=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
25
+ if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then
26
+ echo "Tag ${GITHUB_REF_NAME} does not match project version v${version}"
27
+ exit 1
28
+ fi
29
+ - run: uv run ruff format --check .
30
+ - run: uv run ruff check .
31
+ - run: uv run pyright
32
+ - run: uv run pytest
33
+ - run: uv build
34
+ - run: uv publish --trusted-publishing always
@@ -0,0 +1,219 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
219
+ todos.db
tenchi-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Taylor Bryant
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tenchi-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,403 @@
1
+ Metadata-Version: 2.4
2
+ Name: tenchi
3
+ Version: 0.1.0
4
+ Summary: A contract-first, Python-native framework for building REST APIs around use cases, ports, and explicit wiring.
5
+ Project-URL: Homepage, https://github.com/taylorbryant/tenchi
6
+ Project-URL: Repository, https://github.com/taylorbryant/tenchi
7
+ Project-URL: Issues, https://github.com/taylorbryant/tenchi/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: api,asgi,contracts,framework,pydantic,rest,starlette
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: httpx>=0.27
21
+ Requires-Dist: pydantic>=2.7
22
+ Requires-Dist: starlette>=0.37
23
+ Requires-Dist: typing-extensions>=4.10
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Tenchi
27
+
28
+ Tenchi is a contract-first, Python-native framework for building REST APIs
29
+ around use cases, ports, and explicit dependency wiring. It is the Python
30
+ sibling of Beignet: the same architecture — contracts at the HTTP boundary,
31
+ use cases at the center, protocol-based ports, infrastructure adapters, and
32
+ explicit server composition — expressed with plain functions, dataclasses,
33
+ `typing.Protocol`, Pydantic v2, and Starlette instead of TypeScript
34
+ machinery.
35
+
36
+ ## Installation
37
+
38
+ Tenchi requires Python 3.12+.
39
+
40
+ ```sh
41
+ uv add tenchi # or: pip install tenchi
42
+ ```
43
+
44
+ To work on this repository:
45
+
46
+ ```sh
47
+ uv sync # install the package and dev tools
48
+ uv run pytest # tests (framework + todos example)
49
+ uv run ruff check . # lint
50
+ uv run pyright # strict type checking
51
+ ```
52
+
53
+ ## Architecture
54
+
55
+ Applications follow a prescriptive structure. Each feature owns its
56
+ contracts, schemas, ports, routes, use cases, and tests; infrastructure
57
+ implements ports; server composition owns concrete wiring:
58
+
59
+ ```txt
60
+ app/
61
+ features/
62
+ todos/
63
+ contracts.py # HTTP boundary: method, path, request/response, errors
64
+ schemas.py # Pydantic models shared by contracts, use cases, ports
65
+ ports.py # typing.Protocol interfaces the feature needs
66
+ routes.py # binds contracts to use cases
67
+ use_cases/ # application workflows (plain async functions)
68
+ tests/ # use-case tests, no HTTP required
69
+ shared/
70
+ errors.py # application error definitions with stable codes
71
+ infra/
72
+ memory_todo_repository.py # concrete port implementations
73
+ port_wiring.py # constructs concrete adapters
74
+ server/
75
+ context.py # AppContext dataclass holding ports
76
+ routes.py # composes feature route groups
77
+ asgi.py # concrete wiring + ASGI app
78
+ tests/ # HTTP integration tests
79
+ ```
80
+
81
+ Dependency direction is strict: schemas and use cases never import
82
+ infrastructure or the HTTP runtime; routes bind contracts to use cases but
83
+ construct nothing concrete; only `server/` (and `infra/`) know which
84
+ implementations are in play.
85
+
86
+ ## The basic flow
87
+
88
+ Schemas are ordinary Pydantic models:
89
+
90
+ ```python
91
+ # app/features/todos/schemas.py
92
+ from pydantic import BaseModel
93
+
94
+ class CreateTodo(BaseModel):
95
+ title: str
96
+
97
+ class Todo(BaseModel):
98
+ id: str
99
+ title: str
100
+ completed: bool
101
+ ```
102
+
103
+ Ports describe what application code needs, as protocols:
104
+
105
+ ```python
106
+ # app/features/todos/ports.py
107
+ from typing import Protocol
108
+ from .schemas import Todo
109
+
110
+ class TodoRepository(Protocol):
111
+ async def create(self, *, title: str) -> Todo: ...
112
+ async def list(self) -> list[Todo]: ...
113
+ ```
114
+
115
+ The application context is a frozen dataclass of ports:
116
+
117
+ ```python
118
+ # app/server/context.py
119
+ from dataclasses import dataclass
120
+ from app.features.todos.ports import TodoRepository
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class AppContext:
124
+ todos: TodoRepository
125
+ ```
126
+
127
+ Use cases are plain async functions — no base classes, no decorators:
128
+
129
+ ```python
130
+ # app/features/todos/use_cases/create_todo.py
131
+ from app.server.context import AppContext
132
+ from ..schemas import CreateTodo, Todo
133
+
134
+ async def create_todo(request: CreateTodo, context: AppContext) -> Todo:
135
+ return await context.todos.create(title=request.title)
136
+ ```
137
+
138
+ Contracts define and validate the HTTP boundary. Any type Pydantic can
139
+ validate works, including `list[Todo]`:
140
+
141
+ ```python
142
+ # app/features/todos/contracts.py
143
+ from tenchi.contracts import contract
144
+ from .schemas import CreateTodo, Todo
145
+
146
+ create_todo_contract = contract(
147
+ method="POST",
148
+ path="/todos",
149
+ request=CreateTodo,
150
+ response=Todo,
151
+ status=201,
152
+ )
153
+ ```
154
+
155
+ Contracts can also carry documentation metadata (`summary=`,
156
+ `description=`, `tags=`, `deprecated=`) and non-JSON media types: pair
157
+ `request_media_type="text/plain"` with `request=str`, or
158
+ `"application/octet-stream"` with `bytes`, and the server, client, and
159
+ OpenAPI document all follow (useful for webhook endpoints that need the
160
+ raw body).
161
+
162
+ Contracts can also declare path parameters (`params=`) and query parameters
163
+ (`query=`), each validated into its own model and passed to the use case as
164
+ a keyword argument of the same name:
165
+
166
+ ```python
167
+ class ListTodosQuery(BaseModel):
168
+ completed: bool | None = None
169
+
170
+ list_todos_contract = contract(
171
+ method="GET",
172
+ path="/todos",
173
+ query=ListTodosQuery,
174
+ response=list[Todo],
175
+ )
176
+
177
+ async def list_todos(query: ListTodosQuery, context: AppContext) -> list[Todo]:
178
+ ...
179
+ ```
180
+
181
+ Routes bind contracts to use cases. Binding is validated eagerly, so a use
182
+ case that cannot accept what its contract declares fails at import time:
183
+
184
+ ```python
185
+ # app/features/todos/routes.py
186
+ from tenchi.routes import route, route_group
187
+ from .contracts import create_todo_contract
188
+ from .use_cases.create_todo import create_todo
189
+
190
+ routes = route_group(
191
+ route(create_todo_contract, create_todo),
192
+ )
193
+ ```
194
+
195
+ Server composition owns concrete wiring and produces the ASGI app. The
196
+ lifespan owns process-scoped resources — it opens them at startup, closes
197
+ them at shutdown, and whatever it yields is handed to the context factory,
198
+ which runs once per request:
199
+
200
+ ```python
201
+ # app/server/asgi.py
202
+ from collections.abc import AsyncGenerator
203
+ from contextlib import asynccontextmanager
204
+
205
+ from tenchi.server import create_app
206
+ from app.features.todos.ports import TodoRepository
207
+ from app.infra.port_wiring import open_todo_repository
208
+ from app.server.context import AppContext
209
+ from app.server.routes import routes
210
+
211
+ @asynccontextmanager
212
+ async def lifespan() -> AsyncGenerator[TodoRepository]:
213
+ async with open_todo_repository("todos.db") as todos:
214
+ yield todos
215
+
216
+ def create_context(todos: TodoRepository) -> AppContext:
217
+ return AppContext(todos=todos)
218
+
219
+ app = create_app(routes=routes, context_factory=create_context, lifespan=lifespan)
220
+ ```
221
+
222
+ For apps without real resources, `lifespan` is optional and the context
223
+ factory can take zero arguments and close over module-scoped objects (see
224
+ the memory-backed fixtures in the example tests).
225
+
226
+ Run it with any ASGI server:
227
+
228
+ ```sh
229
+ uvicorn app.server.asgi:app --reload
230
+ curl -X POST localhost:8000/todos -H 'content-type: application/json' \
231
+ -d '{"title": "Buy milk"}'
232
+ ```
233
+
234
+ ## Typed client
235
+
236
+ The same contracts drive a typed `httpx`-based client — no code generation,
237
+ no drift. `call()` returns the contract's response type, so `todo` below is
238
+ statically a `Todo` and `todos` a `list[Todo]`:
239
+
240
+ ```python
241
+ from tenchi.client import Client
242
+
243
+ async with Client(base_url="http://localhost:8000") as client:
244
+ todo = await client.call(create_todo_contract, request=CreateTodo(title="Buy milk"))
245
+ todos = await client.call(list_todos_contract, query=ListTodosQuery(completed=False))
246
+ ```
247
+
248
+ Declared errors come back as the same `AppError` the server raised, carrying
249
+ the same `ErrorDef`; anything undeclared raises `UnexpectedResponseError`:
250
+
251
+ ```python
252
+ try:
253
+ await client.call(get_todo_contract, params=GetTodoParams(todo_id="missing"))
254
+ except AppError as err:
255
+ assert err.definition == todo_not_found
256
+ ```
257
+
258
+ For tests, pass your own `httpx.AsyncClient` with an `ASGITransport` via
259
+ `Client(http=...)` to call the app in-process.
260
+
261
+ ## Errors
262
+
263
+ Application errors carry a stable code, an HTTP status, and optional
264
+ structured details. Contracts declare the errors they are expected to
265
+ return; declared errors map to their status, and everything else — including
266
+ undeclared `AppError`s — becomes a framework-owned 500 so contracts stay
267
+ honest:
268
+
269
+ ```python
270
+ # app/shared/errors.py
271
+ from tenchi.errors import ErrorDef
272
+
273
+ todo_not_found = ErrorDef(code="TODO_NOT_FOUND", status=404, message="Todo not found")
274
+ ```
275
+
276
+ ```python
277
+ # in a use case
278
+ raise AppError(todo_not_found, details={"todo_id": params.todo_id})
279
+ ```
280
+
281
+ Errors can carry response headers — declare the names on the definition
282
+ (they appear in the OpenAPI document) and set values per instance:
283
+
284
+ ```python
285
+ throttled = ErrorDef(code="THROTTLED", status=429, message="Slow down",
286
+ headers=("Retry-After",))
287
+ raise AppError(throttled, headers={"Retry-After": "30"})
288
+ ```
289
+
290
+ ```python
291
+ # in a contract
292
+ get_todo_contract = contract(
293
+ method="GET",
294
+ path="/todos/{todo_id}",
295
+ params=GetTodoParams,
296
+ response=Todo,
297
+ errors=(todo_not_found,),
298
+ )
299
+ ```
300
+
301
+ Error responses use a flat envelope, `{"code", "message", "details"?}`, and
302
+ every error response carries an `x-tenchi-error-source` header set to `app`
303
+ or `framework` so the two are always distinguishable.
304
+
305
+ ## Testing
306
+
307
+ Use cases test without HTTP — construct a context with a fake or memory
308
+ adapter and call the function:
309
+
310
+ ```python
311
+ async def test_create_todo() -> None:
312
+ context = AppContext(todos=MemoryTodoRepository())
313
+ todo = await create_todo(CreateTodo(title="Buy milk"), context)
314
+ assert todo.title == "Buy milk"
315
+ ```
316
+
317
+ Integration tests exercise the full boundary with `httpx.ASGITransport`; see
318
+ `examples/todos/tests/test_todos_http.py`. When the app uses a lifespan,
319
+ wrap it in `asgi-lifespan`'s `LifespanManager` so startup and shutdown run
320
+ (`ASGITransport` alone does not trigger lifespan events); see
321
+ `examples/todos/tests/test_todos_lifespan.py`.
322
+
323
+ ## OpenAPI
324
+
325
+ Contracts carry everything an OpenAPI document needs, so generation is a
326
+ pure function — no decorators, no runtime introspection of handlers:
327
+
328
+ ```python
329
+ from tenchi.openapi import openapi_schema
330
+
331
+ document = openapi_schema(api_routes, title="Todos", version="0.1.0")
332
+ ```
333
+
334
+ Request bodies use validation-mode JSON Schema, responses use
335
+ serialization mode, path/query parameters come from the `params`/`query`
336
+ models, declared errors appear as error responses under their status with
337
+ the standard envelope schema, and routes with validated input document the
338
+ framework's 422 automatically.
339
+
340
+ To serve the document, compose `openapi_route` alongside your routes in
341
+ `server/routes.py` — it is generated once at startup and served by the same
342
+ route machinery it describes (and it does not document itself):
343
+
344
+ ```python
345
+ from tenchi.openapi import openapi_route
346
+
347
+ api_routes = route_group(todo_routes)
348
+ routes = route_group(
349
+ api_routes,
350
+ openapi_route(api_routes, title="Todos", version="0.1.0"),
351
+ )
352
+ ```
353
+
354
+ ## CLI
355
+
356
+ ```sh
357
+ tenchi new my_app # scaffold a new application
358
+ tenchi make feature notes # generate a feature skeleton
359
+ tenchi make use-case notes create_note # generate a use-case stub and test
360
+ tenchi routes # print the bound route table
361
+ tenchi openapi [-o openapi.json] # print or write the OpenAPI document
362
+ tenchi dev # serve app.server.asgi:app with reload
363
+ ```
364
+
365
+ Generators create files and print wiring instructions — they never edit
366
+ existing modules, because dependency wiring stays explicit and app-owned.
367
+ Everything they generate passes Ruff, Pyright strict, and pytest as-is.
368
+
369
+ `tenchi new` generates the todos starter — feature, ports, memory adapter,
370
+ wiring, and passing tests — so a new project starts from a working vertical
371
+ slice:
372
+
373
+ ```sh
374
+ uv run tenchi new my_app
375
+ cd my_app && uv sync && uv run pytest
376
+ ```
377
+
378
+ `tenchi routes` prints every bound route with its status, use case, and
379
+ declared error codes:
380
+
381
+ ```txt
382
+ POST /todos 201 app.features.todos.use_cases.create_todo.create_todo
383
+ GET /todos 200 app.features.todos.use_cases.list_todos.list_todos
384
+ GET /todos/{todo_id} 200 app.features.todos.use_cases.get_todo.get_todo [TODO_NOT_FOUND]
385
+ GET /openapi.json 200 tenchi.openapi.openapi_route.<locals>.get_openapi
386
+ ```
387
+
388
+ ## Example
389
+
390
+ A complete todos application using the prescribed structure lives in
391
+ [`examples/todos/`](examples/todos/). It ships two adapters for the same
392
+ port: the SQLite repository (aiosqlite) wired into the running app through
393
+ the lifespan, and the memory repository used by unit tests — swapping them
394
+ touches only `infra/` and `server/`.
395
+
396
+ ## Status
397
+
398
+ Tenchi is an early vertical slice: contracts (body, path, and query
399
+ validation), route binding, ASGI dispatch, lifespan-managed resources with
400
+ request-scoped context, ports, expected-error mapping, a contract-driven
401
+ typed client, OpenAPI 3.1 generation, and a CLI (`new`, `make feature`,
402
+ `make use-case`, `routes`, `openapi`, `dev`). `tenchi doctor` and
403
+ provider-backed infrastructure are planned but intentionally not started.