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.
- tenchi-0.1.0/.github/workflows/ci.yml +20 -0
- tenchi-0.1.0/.github/workflows/release.yml +34 -0
- tenchi-0.1.0/.gitignore +219 -0
- tenchi-0.1.0/LICENSE +21 -0
- tenchi-0.1.0/PKG-INFO +403 -0
- tenchi-0.1.0/README.md +378 -0
- tenchi-0.1.0/examples/todos/app/__init__.py +0 -0
- tenchi-0.1.0/examples/todos/app/features/__init__.py +0 -0
- tenchi-0.1.0/examples/todos/app/features/todos/__init__.py +0 -0
- tenchi-0.1.0/examples/todos/app/features/todos/contracts.py +34 -0
- tenchi-0.1.0/examples/todos/app/features/todos/ports.py +11 -0
- tenchi-0.1.0/examples/todos/app/features/todos/routes.py +12 -0
- tenchi-0.1.0/examples/todos/app/features/todos/schemas.py +19 -0
- tenchi-0.1.0/examples/todos/app/features/todos/tests/__init__.py +0 -0
- tenchi-0.1.0/examples/todos/app/features/todos/tests/test_create_todo.py +15 -0
- tenchi-0.1.0/examples/todos/app/features/todos/tests/test_get_todo.py +28 -0
- tenchi-0.1.0/examples/todos/app/features/todos/tests/test_list_todos.py +24 -0
- tenchi-0.1.0/examples/todos/app/features/todos/use_cases/__init__.py +0 -0
- tenchi-0.1.0/examples/todos/app/features/todos/use_cases/create_todo.py +7 -0
- tenchi-0.1.0/examples/todos/app/features/todos/use_cases/get_todo.py +12 -0
- tenchi-0.1.0/examples/todos/app/features/todos/use_cases/list_todos.py +10 -0
- tenchi-0.1.0/examples/todos/app/infra/__init__.py +0 -0
- tenchi-0.1.0/examples/todos/app/infra/memory_todo_repository.py +21 -0
- tenchi-0.1.0/examples/todos/app/infra/port_wiring.py +16 -0
- tenchi-0.1.0/examples/todos/app/infra/sqlite_todo_repository.py +66 -0
- tenchi-0.1.0/examples/todos/app/server/__init__.py +0 -0
- tenchi-0.1.0/examples/todos/app/server/asgi.py +35 -0
- tenchi-0.1.0/examples/todos/app/server/context.py +8 -0
- tenchi-0.1.0/examples/todos/app/server/routes.py +10 -0
- tenchi-0.1.0/examples/todos/app/shared/__init__.py +0 -0
- tenchi-0.1.0/examples/todos/app/shared/errors.py +7 -0
- tenchi-0.1.0/examples/todos/tests/test_sqlite_todo_repository.py +28 -0
- tenchi-0.1.0/examples/todos/tests/test_todos_client.py +64 -0
- tenchi-0.1.0/examples/todos/tests/test_todos_http.py +141 -0
- tenchi-0.1.0/examples/todos/tests/test_todos_lifespan.py +56 -0
- tenchi-0.1.0/examples/todos/tests/test_todos_openapi.py +38 -0
- tenchi-0.1.0/pyproject.toml +70 -0
- tenchi-0.1.0/src/tenchi/__init__.py +32 -0
- tenchi-0.1.0/src/tenchi/cli.py +321 -0
- tenchi-0.1.0/src/tenchi/client.py +203 -0
- tenchi-0.1.0/src/tenchi/contracts.py +132 -0
- tenchi-0.1.0/src/tenchi/errors.py +113 -0
- tenchi-0.1.0/src/tenchi/openapi.py +237 -0
- tenchi-0.1.0/src/tenchi/py.typed +0 -0
- tenchi-0.1.0/src/tenchi/routes.py +157 -0
- tenchi-0.1.0/src/tenchi/scaffold.py +385 -0
- tenchi-0.1.0/src/tenchi/server.py +346 -0
- tenchi-0.1.0/tests/test_cli.py +302 -0
- tenchi-0.1.0/tests/test_client.py +182 -0
- tenchi-0.1.0/tests/test_contracts.py +64 -0
- tenchi-0.1.0/tests/test_errors.py +47 -0
- tenchi-0.1.0/tests/test_lifespan.py +122 -0
- tenchi-0.1.0/tests/test_openapi.py +292 -0
- tenchi-0.1.0/tests/test_routes.py +115 -0
- tenchi-0.1.0/tests/test_server.py +304 -0
- 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
|
tenchi-0.1.0/.gitignore
ADDED
|
@@ -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.
|