matchapi 0.1.0__tar.gz → 0.2.1__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.
- matchapi-0.2.1/CHANGELOG.md +56 -0
- matchapi-0.2.1/CONTEXT.md +36 -0
- {matchapi-0.1.0 → matchapi-0.2.1}/PKG-INFO +68 -54
- {matchapi-0.1.0 → matchapi-0.2.1}/README.md +61 -50
- matchapi-0.2.1/bench/README.md +46 -0
- matchapi-0.2.1/bench/bare.py +16 -0
- matchapi-0.2.1/bench/bare_body.py +8 -0
- matchapi-0.2.1/bench/run.py +117 -0
- matchapi-0.2.1/bench/service.py +17 -0
- matchapi-0.2.1/bench/service_asgi.py +14 -0
- matchapi-0.2.1/docs/design.md +143 -0
- {matchapi-0.1.0 → matchapi-0.2.1}/pyproject.toml +29 -11
- {matchapi-0.1.0 → matchapi-0.2.1}/src/matchapi/__init__.py +45 -124
- {matchapi-0.1.0 → matchapi-0.2.1}/tests/sample.py +4 -0
- {matchapi-0.1.0 → matchapi-0.2.1}/tests/test_matchapi.py +135 -195
- matchapi-0.1.0/CHANGELOG.md +0 -21
- matchapi-0.1.0/docs/design.md +0 -110
- matchapi-0.1.0/src/matchapi/__main__.py +0 -3
- matchapi-0.1.0/tests/asgi_example.py +0 -6
- {matchapi-0.1.0 → matchapi-0.2.1}/.gitignore +0 -0
- {matchapi-0.1.0 → matchapi-0.2.1}/LICENSE +0 -0
- {matchapi-0.1.0 → matchapi-0.2.1}/src/matchapi/py.typed +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
|
+
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.1] - 2026-10-12
|
|
10
|
+
|
|
11
|
+
Package metadata only; the code is unchanged.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- The PyPI description, keywords and classifiers name granian and RSGI, so the package is found by the stack it
|
|
16
|
+
runs on. The README opens with the same line.
|
|
17
|
+
|
|
18
|
+
## [0.2.0] - 2026-10-12
|
|
19
|
+
|
|
20
|
+
The handler is decorated and granian serves it. The framework loses its command line and its ASGI adapter;
|
|
21
|
+
the handler contract, the request and the response rules are unchanged.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- `@matchapi.app` over `handle`: the decorated function is an RSGI application for
|
|
26
|
+
[granian](https://github.com/emmett-framework/granian) and is still called as the function in tests. Run with
|
|
27
|
+
`granian app:handle`.
|
|
28
|
+
- granian replaces uvicorn as the one dependency. HTTP/2, TLS, `--workers` and `--reload` are granian's;
|
|
29
|
+
`--reload` needs `pip install 'granian[reload]'`.
|
|
30
|
+
- An exception inside a streaming response body is logged with the method and path, and the stream ends.
|
|
31
|
+
|
|
32
|
+
### Removed
|
|
33
|
+
|
|
34
|
+
- The `matchapi` command (`matchapi app`, `python -m matchapi`), `matchapi.main`, and the rule that found
|
|
35
|
+
`handle` or the only public function of a module.
|
|
36
|
+
- The ASGI application: uvicorn, hypercorn and ASGI middleware can no longer run a matchapi service.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- `bench/`: the framework's share of a request, measured against a bare RSGI application and against 0.1.0 under
|
|
41
|
+
uvicorn.
|
|
42
|
+
|
|
43
|
+
## [0.1.0] - 2026-10-12
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- `Request`: method, path, `route` as `(method, *segments)`, query, headers, body and `json()`; built by hand in
|
|
48
|
+
tests.
|
|
49
|
+
- `Response`, `HTTPError` and `respond()`: the rules that turn a handler's return value into a response.
|
|
50
|
+
- `app(handle)`: the handler as an ASGI application; sync handlers run in the thread pool, async ones in the loop.
|
|
51
|
+
- The `matchapi` command: `matchapi app` serves the module's `handle` with uvicorn; `--host`, `--port`,
|
|
52
|
+
`--workers`, `--reload`.
|
|
53
|
+
|
|
54
|
+
[Unreleased]: https://github.com/troyan-dy/matchapi/compare/v0.2.0...HEAD
|
|
55
|
+
[0.2.0]: https://github.com/troyan-dy/matchapi/compare/v0.1.0...v0.2.0
|
|
56
|
+
[0.1.0]: https://github.com/troyan-dy/matchapi/releases/tag/v0.1.0
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# matchapi
|
|
2
|
+
|
|
3
|
+
An HTTP service is one function, and `match` over the route replaces the router. This glossary fixes the words
|
|
4
|
+
the README, the design notes and the code use for the pieces of that model.
|
|
5
|
+
|
|
6
|
+
## Language
|
|
7
|
+
|
|
8
|
+
**Handler**:
|
|
9
|
+
The user's function `(Request) -> value`: the whole service. It is called directly in tests.
|
|
10
|
+
_Avoid_: view, endpoint, controller, route function
|
|
11
|
+
|
|
12
|
+
**App**:
|
|
13
|
+
The handler wrapped by the `matchapi.app` decorator: the object a server runs. Calling it is calling the handler.
|
|
14
|
+
_Avoid_: application object, server, service object
|
|
15
|
+
|
|
16
|
+
**Request**:
|
|
17
|
+
The incoming request as plain data: method, path, route, query, headers, body. No transport underneath.
|
|
18
|
+
_Avoid_: scope, context
|
|
19
|
+
|
|
20
|
+
**Route**:
|
|
21
|
+
The tuple `(method, *segments)` that the handler's `match` is written over: `GET /users/42` is
|
|
22
|
+
`("GET", "users", "42")`.
|
|
23
|
+
_Avoid_: path (that is the string `/users/42`), URL, endpoint
|
|
24
|
+
|
|
25
|
+
**Response**:
|
|
26
|
+
What the server sends: status, headers, body. Built from the handler's return value by the response rules.
|
|
27
|
+
_Avoid_: result, reply
|
|
28
|
+
|
|
29
|
+
**Response rules**:
|
|
30
|
+
The mapping from a handler's return value to a Response: `dict` is JSON, `str` is text, `int` is a status, a tuple
|
|
31
|
+
adds status and headers, `Response` is returned as is.
|
|
32
|
+
_Avoid_: serialization, rendering
|
|
33
|
+
|
|
34
|
+
**Server**:
|
|
35
|
+
The program that speaks HTTP and calls the app once per request: granian. Not part of matchapi.
|
|
36
|
+
_Avoid_: runtime, runner, worker
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: matchapi
|
|
3
|
-
Version: 0.1
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: One-function HTTP framework for granian over RSGI: match replaces the router
|
|
5
5
|
Project-URL: Homepage, https://github.com/troyan-dy/matchapi
|
|
6
6
|
Project-URL: Repository, https://github.com/troyan-dy/matchapi
|
|
7
7
|
Project-URL: Issues, https://github.com/troyan-dy/matchapi/issues
|
|
@@ -9,8 +9,9 @@ Project-URL: Changelog, https://github.com/troyan-dy/matchapi/blob/master/CHANGE
|
|
|
9
9
|
Author: Dmitriy Troyan
|
|
10
10
|
License-Expression: MIT
|
|
11
11
|
License-File: LICENSE
|
|
12
|
-
Keywords:
|
|
12
|
+
Keywords: api,async,asyncio,framework,granian,http,json,match,microframework,microservice,pattern-matching,rest,rsgi,rust,single-file,structural-pattern-matching,web,web-framework,webhook
|
|
13
13
|
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
14
15
|
Classifier: Framework :: AsyncIO
|
|
15
16
|
Classifier: Intended Audience :: Developers
|
|
16
17
|
Classifier: Operating System :: OS Independent
|
|
@@ -20,11 +21,13 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
20
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
22
23
|
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
23
26
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
24
27
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
25
28
|
Classifier: Typing :: Typed
|
|
26
29
|
Requires-Python: >=3.11
|
|
27
|
-
Requires-Dist:
|
|
30
|
+
Requires-Dist: granian>=2.8
|
|
28
31
|
Description-Content-Type: text/markdown
|
|
29
32
|
|
|
30
33
|
# matchapi
|
|
@@ -36,8 +39,16 @@ Description-Content-Type: text/markdown
|
|
|
36
39
|
|
|
37
40
|
An HTTP service is one function. `match` replaces the router.
|
|
38
41
|
|
|
42
|
+
A one-file Python web framework for [granian](https://github.com/emmett-framework/granian) over
|
|
43
|
+
[RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md), granian's native Rust-backed
|
|
44
|
+
interface: no router, no middleware, no ASGI adapter. Python 3.11+.
|
|
45
|
+
|
|
39
46
|
```python
|
|
40
47
|
# app.py
|
|
48
|
+
import matchapi
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@matchapi.app
|
|
41
52
|
async def handle(req):
|
|
42
53
|
match req.route:
|
|
43
54
|
case "GET", "ping":
|
|
@@ -53,11 +64,12 @@ async def handle(req):
|
|
|
53
64
|
|
|
54
65
|
```console
|
|
55
66
|
$ pip install matchapi
|
|
56
|
-
$
|
|
57
|
-
INFO
|
|
58
|
-
INFO
|
|
59
|
-
INFO:
|
|
60
|
-
INFO
|
|
67
|
+
$ granian app:handle
|
|
68
|
+
[INFO] Starting granian (main PID: 505)
|
|
69
|
+
[INFO] Listening at: http://127.0.0.1:8000
|
|
70
|
+
[INFO] Spawning worker-1 with PID: 512
|
|
71
|
+
[INFO] Started worker-1
|
|
72
|
+
[INFO] Started worker-1 runtime-1
|
|
61
73
|
```
|
|
62
74
|
|
|
63
75
|
```console
|
|
@@ -67,24 +79,24 @@ $ curl localhost:8000/users/42
|
|
|
67
79
|
{"id":42,"name":"ann"}
|
|
68
80
|
$ curl -i -X POST localhost:8000/users -d '{"name": "bob"}'
|
|
69
81
|
HTTP/1.1 201 Created
|
|
70
|
-
date: Mon, 12 Oct 2026 00:10:08 GMT
|
|
71
|
-
server: uvicorn
|
|
72
82
|
content-type: application/json
|
|
73
83
|
content-length: 21
|
|
84
|
+
server: granian
|
|
85
|
+
date: Mon, 12 Oct 2026 01:26:59 GMT
|
|
74
86
|
|
|
75
87
|
{"id":7,"name":"bob"}
|
|
76
88
|
$ curl -i localhost:8000/nothing
|
|
77
89
|
HTTP/1.1 404 Not Found
|
|
78
|
-
date: Mon, 12 Oct 2026 00:10:08 GMT
|
|
79
|
-
server: uvicorn
|
|
80
90
|
content-type: text/plain; charset=utf-8
|
|
81
91
|
content-length: 9
|
|
92
|
+
server: granian
|
|
93
|
+
date: Mon, 12 Oct 2026 01:26:59 GMT
|
|
82
94
|
|
|
83
95
|
Not Found
|
|
84
96
|
```
|
|
85
97
|
|
|
86
|
-
|
|
87
|
-
`
|
|
98
|
+
One import and one decorator; no route table, no schema classes, no dependency markers, no middleware stack. The
|
|
99
|
+
decorated `handle` is still the function it was: a test calls it with a request and compares the return value.
|
|
88
100
|
|
|
89
101
|
- [Why](#why)
|
|
90
102
|
- [The model](#the-model)
|
|
@@ -110,7 +122,8 @@ models. A service with three endpoints pays for all of it.
|
|
|
110
122
|
Python has had structural pattern matching since 3.10. A `match` over `(method, *segments)` is a router already:
|
|
111
123
|
it binds path parameters, it falls through to a default, it nests, and the language, not a library, defines how it
|
|
112
124
|
reads. matchapi is what is left of a framework once the router is gone: a `Request`, a `Response`, the rules that
|
|
113
|
-
turn a return value into one, and a
|
|
125
|
+
turn a return value into one, and a decorator that lets [granian](https://github.com/emmett-framework/granian)
|
|
126
|
+
serve the function.
|
|
114
127
|
|
|
115
128
|
## The model
|
|
116
129
|
|
|
@@ -119,6 +132,11 @@ turn a return value into one, and a command that serves a module.
|
|
|
119
132
|
The service is a function that takes a `Request` and returns something. That is the whole contract. A function is
|
|
120
133
|
easy to test, easy to compose, easy to move: two services in one process are a function that calls two others.
|
|
121
134
|
|
|
135
|
+
`@matchapi.app` marks the function as the application granian runs. It changes nothing about the function:
|
|
136
|
+
`handle(req)` calls it, `handle.__name__` and `handle.__doc__` are its own. The one thing that differs is
|
|
137
|
+
`inspect.iscoroutinefunction(handle)`, which is `False` for the decorated object; matchapi itself never asks it,
|
|
138
|
+
it looks at what a call returns.
|
|
139
|
+
|
|
122
140
|
### `req.route` is the subject of `match`
|
|
123
141
|
|
|
124
142
|
`req.route` is a tuple of the method followed by the non-empty path segments: `GET /users/42` is
|
|
@@ -148,11 +166,11 @@ strings for the cases where a plain comparison reads better.
|
|
|
148
166
|
| `path` | `str` | `"/users/42"` |
|
|
149
167
|
| `route` | `tuple[str, ...]` | `("POST", "users", "42")` |
|
|
150
168
|
| `query` | `dict[str, str]`, last value wins | `{"q": "x"}` |
|
|
151
|
-
| `headers` | `dict[str, str]`, lower-case names | `{"content-type": "application/json"}` |
|
|
169
|
+
| `headers` | `dict[str, str]`, lower-case names, last value wins | `{"content-type": "application/json"}` |
|
|
152
170
|
| `body` | `bytes`, read in full before the call | `b'{"name": "bob"}'` |
|
|
153
171
|
| `json()` | the body as JSON; a broken body is a 400 | `{"name": "bob"}` |
|
|
154
172
|
|
|
155
|
-
There is no
|
|
173
|
+
There is no scope or stream underneath: the body is read before the handler runs, so a sync handler needs no
|
|
156
174
|
`await`. `Request("POST", "/users", body=b"...")`
|
|
157
175
|
builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
|
|
158
176
|
|
|
@@ -180,45 +198,35 @@ so a module-level `NOT_FOUND = Response(404, "no")` is safe to reuse.
|
|
|
180
198
|
`{"detail": "..."}` with that status; `detail` defaults to the standard phrase. `req.json()` raises it as a 400.
|
|
181
199
|
|
|
182
200
|
Any other exception is a 500 with the body `Internal Server Error`; the traceback goes to the `matchapi` logger
|
|
183
|
-
together with the method and path. Nothing of the exception reaches the client.
|
|
201
|
+
together with the method and path. Nothing of the exception reaches the client. An exception from the iterator of a streaming body is logged the
|
|
202
|
+
same way; the status is out already, so the stream just ends. A client that leaves in the middle of a stream is
|
|
203
|
+
granian's business, not an error of the handler.
|
|
184
204
|
|
|
185
205
|
### Sync or async, the same way
|
|
186
206
|
|
|
187
|
-
`def handle(req)` and `async def handle(req)` are both fine. A sync handler runs in a thread
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
207
|
+
`def handle(req)` and `async def handle(req)` are both fine. A sync handler runs in a thread, an async one in the
|
|
208
|
+
event loop. The kind of handler is read from the result of a call, not from the function, so a
|
|
209
|
+
`functools.partial`, a callable object or a compiled function all work, and a sync function that returns a
|
|
210
|
+
coroutine from some branches is awaited on those.
|
|
191
211
|
|
|
192
212
|
## Running
|
|
193
213
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
$ matchapi api.users # a module in a package
|
|
198
|
-
$ matchapi app --host 0.0.0.0 --port 80 --workers 4
|
|
199
|
-
$ matchapi app --reload # development
|
|
200
|
-
$ python -m matchapi app # the same command
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
Without `:function`, the command takes `handle`, and if there is none, the only public function the module defines.
|
|
204
|
-
The server is uvicorn, the one dependency of the package. `--workers` and `--reload` make every process load the
|
|
205
|
-
module itself.
|
|
206
|
-
|
|
207
|
-
For a server of your own, `matchapi.app(handle)` is an ASGI application:
|
|
208
|
-
|
|
209
|
-
```python
|
|
210
|
-
# asgi.py
|
|
211
|
-
import matchapi
|
|
212
|
-
from app import handle
|
|
213
|
-
|
|
214
|
-
app = matchapi.app(handle)
|
|
215
|
-
```
|
|
214
|
+
The server is [granian](https://github.com/emmett-framework/granian), the one dependency of the package; the
|
|
215
|
+
decorated function is an [RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md)
|
|
216
|
+
application, granian's native interface.
|
|
216
217
|
|
|
217
218
|
```console
|
|
218
|
-
$
|
|
219
|
-
$ granian
|
|
219
|
+
$ granian app:handle # app.py in the working directory, its function `handle`
|
|
220
|
+
$ granian api.users:handle # a module in a package
|
|
221
|
+
$ granian app:handle --host 0.0.0.0 --port 80 --workers 4
|
|
222
|
+
$ granian app:handle --http 2 # HTTP/2 without TLS (h2c)
|
|
223
|
+
$ granian app:handle --reload # development; needs `pip install 'granian[reload]'`
|
|
224
|
+
$ python -m granian app:handle # the same command
|
|
220
225
|
```
|
|
221
226
|
|
|
227
|
+
`--workers` and `--reload` make every process import the module itself. The rest of the options, TLS, an event
|
|
228
|
+
loop, access logs, are granian's: `granian --help`.
|
|
229
|
+
|
|
222
230
|
## Testing
|
|
223
231
|
|
|
224
232
|
The handler is a function and `Request` is a constructor, so a test needs no client and no server:
|
|
@@ -252,6 +260,7 @@ line matter, `matchapi.respond(value)` turns a return value into the `Response`
|
|
|
252
260
|
**Authorization** is a line before the `match`:
|
|
253
261
|
|
|
254
262
|
```python
|
|
263
|
+
@matchapi.app
|
|
255
264
|
async def handle(req):
|
|
256
265
|
if req.headers.get("authorization") != f"Bearer {TOKEN}":
|
|
257
266
|
return 401
|
|
@@ -262,6 +271,7 @@ async def handle(req):
|
|
|
262
271
|
**Two services in one process** are two functions and a prefix:
|
|
263
272
|
|
|
264
273
|
```python
|
|
274
|
+
@matchapi.app
|
|
265
275
|
async def handle(req):
|
|
266
276
|
match req.route:
|
|
267
277
|
case _, "users", *rest:
|
|
@@ -277,6 +287,7 @@ async def handle(req):
|
|
|
277
287
|
```python
|
|
278
288
|
from dataclasses import dataclass
|
|
279
289
|
|
|
290
|
+
import matchapi
|
|
280
291
|
from matchapi import HTTPError
|
|
281
292
|
|
|
282
293
|
|
|
@@ -286,6 +297,7 @@ class NewUser:
|
|
|
286
297
|
age: int = 0
|
|
287
298
|
|
|
288
299
|
|
|
300
|
+
@matchapi.app
|
|
289
301
|
async def handle(req):
|
|
290
302
|
match req.route:
|
|
291
303
|
case "POST", "users":
|
|
@@ -306,18 +318,20 @@ return Response(200, (chunk for chunk in lines()))
|
|
|
306
318
|
|
|
307
319
|
## What matchapi does not do
|
|
308
320
|
|
|
309
|
-
No router or decorators, no middleware stack, no parameter conversion from type hints, no OpenAPI, no
|
|
310
|
-
sessions, no templates, no static files, no websockets, no streaming request bodies, no WSGI.
|
|
311
|
-
either a few lines in the handler or
|
|
321
|
+
No router or decorators for routes, no middleware stack, no parameter conversion from type hints, no OpenAPI, no
|
|
322
|
+
cookies or sessions, no templates, no static files, no websockets, no streaming request bodies, no ASGI or WSGI.
|
|
323
|
+
Each of these is either a few lines in the handler or not matchapi's job: the server is granian and the only
|
|
324
|
+
interface is RSGI, so there is no middleware layer to plug into.
|
|
312
325
|
|
|
313
|
-
The line is deliberate. The framework fits in one file of about
|
|
314
|
-
handler stays a function that works without
|
|
326
|
+
The line is deliberate. The framework fits in one file of about 200 lines that reads in half an hour, and the
|
|
327
|
+
handler stays a function that works without a server.
|
|
315
328
|
|
|
316
329
|
## Design notes
|
|
317
330
|
|
|
318
331
|
The decisions and the alternatives that were rejected are in [docs/design.md](docs/design.md): why the router
|
|
319
|
-
went instead of the signature magic, why
|
|
320
|
-
|
|
332
|
+
went instead of the signature magic, why a decorator rather than a command, why the body is read before the
|
|
333
|
+
call, why there is only RSGI through granian, and why there is no compiled core. [CONTEXT.md](CONTEXT.md) is the
|
|
334
|
+
glossary. [bench/](bench/) measures the framework's own share of a request.
|
|
321
335
|
|
|
322
336
|
## Development
|
|
323
337
|
|
|
@@ -7,8 +7,16 @@
|
|
|
7
7
|
|
|
8
8
|
An HTTP service is one function. `match` replaces the router.
|
|
9
9
|
|
|
10
|
+
A one-file Python web framework for [granian](https://github.com/emmett-framework/granian) over
|
|
11
|
+
[RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md), granian's native Rust-backed
|
|
12
|
+
interface: no router, no middleware, no ASGI adapter. Python 3.11+.
|
|
13
|
+
|
|
10
14
|
```python
|
|
11
15
|
# app.py
|
|
16
|
+
import matchapi
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@matchapi.app
|
|
12
20
|
async def handle(req):
|
|
13
21
|
match req.route:
|
|
14
22
|
case "GET", "ping":
|
|
@@ -24,11 +32,12 @@ async def handle(req):
|
|
|
24
32
|
|
|
25
33
|
```console
|
|
26
34
|
$ pip install matchapi
|
|
27
|
-
$
|
|
28
|
-
INFO
|
|
29
|
-
INFO
|
|
30
|
-
INFO:
|
|
31
|
-
INFO
|
|
35
|
+
$ granian app:handle
|
|
36
|
+
[INFO] Starting granian (main PID: 505)
|
|
37
|
+
[INFO] Listening at: http://127.0.0.1:8000
|
|
38
|
+
[INFO] Spawning worker-1 with PID: 512
|
|
39
|
+
[INFO] Started worker-1
|
|
40
|
+
[INFO] Started worker-1 runtime-1
|
|
32
41
|
```
|
|
33
42
|
|
|
34
43
|
```console
|
|
@@ -38,24 +47,24 @@ $ curl localhost:8000/users/42
|
|
|
38
47
|
{"id":42,"name":"ann"}
|
|
39
48
|
$ curl -i -X POST localhost:8000/users -d '{"name": "bob"}'
|
|
40
49
|
HTTP/1.1 201 Created
|
|
41
|
-
date: Mon, 12 Oct 2026 00:10:08 GMT
|
|
42
|
-
server: uvicorn
|
|
43
50
|
content-type: application/json
|
|
44
51
|
content-length: 21
|
|
52
|
+
server: granian
|
|
53
|
+
date: Mon, 12 Oct 2026 01:26:59 GMT
|
|
45
54
|
|
|
46
55
|
{"id":7,"name":"bob"}
|
|
47
56
|
$ curl -i localhost:8000/nothing
|
|
48
57
|
HTTP/1.1 404 Not Found
|
|
49
|
-
date: Mon, 12 Oct 2026 00:10:08 GMT
|
|
50
|
-
server: uvicorn
|
|
51
58
|
content-type: text/plain; charset=utf-8
|
|
52
59
|
content-length: 9
|
|
60
|
+
server: granian
|
|
61
|
+
date: Mon, 12 Oct 2026 01:26:59 GMT
|
|
53
62
|
|
|
54
63
|
Not Found
|
|
55
64
|
```
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
`
|
|
66
|
+
One import and one decorator; no route table, no schema classes, no dependency markers, no middleware stack. The
|
|
67
|
+
decorated `handle` is still the function it was: a test calls it with a request and compares the return value.
|
|
59
68
|
|
|
60
69
|
- [Why](#why)
|
|
61
70
|
- [The model](#the-model)
|
|
@@ -81,7 +90,8 @@ models. A service with three endpoints pays for all of it.
|
|
|
81
90
|
Python has had structural pattern matching since 3.10. A `match` over `(method, *segments)` is a router already:
|
|
82
91
|
it binds path parameters, it falls through to a default, it nests, and the language, not a library, defines how it
|
|
83
92
|
reads. matchapi is what is left of a framework once the router is gone: a `Request`, a `Response`, the rules that
|
|
84
|
-
turn a return value into one, and a
|
|
93
|
+
turn a return value into one, and a decorator that lets [granian](https://github.com/emmett-framework/granian)
|
|
94
|
+
serve the function.
|
|
85
95
|
|
|
86
96
|
## The model
|
|
87
97
|
|
|
@@ -90,6 +100,11 @@ turn a return value into one, and a command that serves a module.
|
|
|
90
100
|
The service is a function that takes a `Request` and returns something. That is the whole contract. A function is
|
|
91
101
|
easy to test, easy to compose, easy to move: two services in one process are a function that calls two others.
|
|
92
102
|
|
|
103
|
+
`@matchapi.app` marks the function as the application granian runs. It changes nothing about the function:
|
|
104
|
+
`handle(req)` calls it, `handle.__name__` and `handle.__doc__` are its own. The one thing that differs is
|
|
105
|
+
`inspect.iscoroutinefunction(handle)`, which is `False` for the decorated object; matchapi itself never asks it,
|
|
106
|
+
it looks at what a call returns.
|
|
107
|
+
|
|
93
108
|
### `req.route` is the subject of `match`
|
|
94
109
|
|
|
95
110
|
`req.route` is a tuple of the method followed by the non-empty path segments: `GET /users/42` is
|
|
@@ -119,11 +134,11 @@ strings for the cases where a plain comparison reads better.
|
|
|
119
134
|
| `path` | `str` | `"/users/42"` |
|
|
120
135
|
| `route` | `tuple[str, ...]` | `("POST", "users", "42")` |
|
|
121
136
|
| `query` | `dict[str, str]`, last value wins | `{"q": "x"}` |
|
|
122
|
-
| `headers` | `dict[str, str]`, lower-case names | `{"content-type": "application/json"}` |
|
|
137
|
+
| `headers` | `dict[str, str]`, lower-case names, last value wins | `{"content-type": "application/json"}` |
|
|
123
138
|
| `body` | `bytes`, read in full before the call | `b'{"name": "bob"}'` |
|
|
124
139
|
| `json()` | the body as JSON; a broken body is a 400 | `{"name": "bob"}` |
|
|
125
140
|
|
|
126
|
-
There is no
|
|
141
|
+
There is no scope or stream underneath: the body is read before the handler runs, so a sync handler needs no
|
|
127
142
|
`await`. `Request("POST", "/users", body=b"...")`
|
|
128
143
|
builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
|
|
129
144
|
|
|
@@ -151,45 +166,35 @@ so a module-level `NOT_FOUND = Response(404, "no")` is safe to reuse.
|
|
|
151
166
|
`{"detail": "..."}` with that status; `detail` defaults to the standard phrase. `req.json()` raises it as a 400.
|
|
152
167
|
|
|
153
168
|
Any other exception is a 500 with the body `Internal Server Error`; the traceback goes to the `matchapi` logger
|
|
154
|
-
together with the method and path. Nothing of the exception reaches the client.
|
|
169
|
+
together with the method and path. Nothing of the exception reaches the client. An exception from the iterator of a streaming body is logged the
|
|
170
|
+
same way; the status is out already, so the stream just ends. A client that leaves in the middle of a stream is
|
|
171
|
+
granian's business, not an error of the handler.
|
|
155
172
|
|
|
156
173
|
### Sync or async, the same way
|
|
157
174
|
|
|
158
|
-
`def handle(req)` and `async def handle(req)` are both fine. A sync handler runs in a thread
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
`def handle(req)` and `async def handle(req)` are both fine. A sync handler runs in a thread, an async one in the
|
|
176
|
+
event loop. The kind of handler is read from the result of a call, not from the function, so a
|
|
177
|
+
`functools.partial`, a callable object or a compiled function all work, and a sync function that returns a
|
|
178
|
+
coroutine from some branches is awaited on those.
|
|
162
179
|
|
|
163
180
|
## Running
|
|
164
181
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
$ matchapi api.users # a module in a package
|
|
169
|
-
$ matchapi app --host 0.0.0.0 --port 80 --workers 4
|
|
170
|
-
$ matchapi app --reload # development
|
|
171
|
-
$ python -m matchapi app # the same command
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
Without `:function`, the command takes `handle`, and if there is none, the only public function the module defines.
|
|
175
|
-
The server is uvicorn, the one dependency of the package. `--workers` and `--reload` make every process load the
|
|
176
|
-
module itself.
|
|
177
|
-
|
|
178
|
-
For a server of your own, `matchapi.app(handle)` is an ASGI application:
|
|
179
|
-
|
|
180
|
-
```python
|
|
181
|
-
# asgi.py
|
|
182
|
-
import matchapi
|
|
183
|
-
from app import handle
|
|
184
|
-
|
|
185
|
-
app = matchapi.app(handle)
|
|
186
|
-
```
|
|
182
|
+
The server is [granian](https://github.com/emmett-framework/granian), the one dependency of the package; the
|
|
183
|
+
decorated function is an [RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md)
|
|
184
|
+
application, granian's native interface.
|
|
187
185
|
|
|
188
186
|
```console
|
|
189
|
-
$
|
|
190
|
-
$ granian
|
|
187
|
+
$ granian app:handle # app.py in the working directory, its function `handle`
|
|
188
|
+
$ granian api.users:handle # a module in a package
|
|
189
|
+
$ granian app:handle --host 0.0.0.0 --port 80 --workers 4
|
|
190
|
+
$ granian app:handle --http 2 # HTTP/2 without TLS (h2c)
|
|
191
|
+
$ granian app:handle --reload # development; needs `pip install 'granian[reload]'`
|
|
192
|
+
$ python -m granian app:handle # the same command
|
|
191
193
|
```
|
|
192
194
|
|
|
195
|
+
`--workers` and `--reload` make every process import the module itself. The rest of the options, TLS, an event
|
|
196
|
+
loop, access logs, are granian's: `granian --help`.
|
|
197
|
+
|
|
193
198
|
## Testing
|
|
194
199
|
|
|
195
200
|
The handler is a function and `Request` is a constructor, so a test needs no client and no server:
|
|
@@ -223,6 +228,7 @@ line matter, `matchapi.respond(value)` turns a return value into the `Response`
|
|
|
223
228
|
**Authorization** is a line before the `match`:
|
|
224
229
|
|
|
225
230
|
```python
|
|
231
|
+
@matchapi.app
|
|
226
232
|
async def handle(req):
|
|
227
233
|
if req.headers.get("authorization") != f"Bearer {TOKEN}":
|
|
228
234
|
return 401
|
|
@@ -233,6 +239,7 @@ async def handle(req):
|
|
|
233
239
|
**Two services in one process** are two functions and a prefix:
|
|
234
240
|
|
|
235
241
|
```python
|
|
242
|
+
@matchapi.app
|
|
236
243
|
async def handle(req):
|
|
237
244
|
match req.route:
|
|
238
245
|
case _, "users", *rest:
|
|
@@ -248,6 +255,7 @@ async def handle(req):
|
|
|
248
255
|
```python
|
|
249
256
|
from dataclasses import dataclass
|
|
250
257
|
|
|
258
|
+
import matchapi
|
|
251
259
|
from matchapi import HTTPError
|
|
252
260
|
|
|
253
261
|
|
|
@@ -257,6 +265,7 @@ class NewUser:
|
|
|
257
265
|
age: int = 0
|
|
258
266
|
|
|
259
267
|
|
|
268
|
+
@matchapi.app
|
|
260
269
|
async def handle(req):
|
|
261
270
|
match req.route:
|
|
262
271
|
case "POST", "users":
|
|
@@ -277,18 +286,20 @@ return Response(200, (chunk for chunk in lines()))
|
|
|
277
286
|
|
|
278
287
|
## What matchapi does not do
|
|
279
288
|
|
|
280
|
-
No router or decorators, no middleware stack, no parameter conversion from type hints, no OpenAPI, no
|
|
281
|
-
sessions, no templates, no static files, no websockets, no streaming request bodies, no WSGI.
|
|
282
|
-
either a few lines in the handler or
|
|
289
|
+
No router or decorators for routes, no middleware stack, no parameter conversion from type hints, no OpenAPI, no
|
|
290
|
+
cookies or sessions, no templates, no static files, no websockets, no streaming request bodies, no ASGI or WSGI.
|
|
291
|
+
Each of these is either a few lines in the handler or not matchapi's job: the server is granian and the only
|
|
292
|
+
interface is RSGI, so there is no middleware layer to plug into.
|
|
283
293
|
|
|
284
|
-
The line is deliberate. The framework fits in one file of about
|
|
285
|
-
handler stays a function that works without
|
|
294
|
+
The line is deliberate. The framework fits in one file of about 200 lines that reads in half an hour, and the
|
|
295
|
+
handler stays a function that works without a server.
|
|
286
296
|
|
|
287
297
|
## Design notes
|
|
288
298
|
|
|
289
299
|
The decisions and the alternatives that were rejected are in [docs/design.md](docs/design.md): why the router
|
|
290
|
-
went instead of the signature magic, why
|
|
291
|
-
|
|
300
|
+
went instead of the signature magic, why a decorator rather than a command, why the body is read before the
|
|
301
|
+
call, why there is only RSGI through granian, and why there is no compiled core. [CONTEXT.md](CONTEXT.md) is the
|
|
302
|
+
glossary. [bench/](bench/) measures the framework's own share of a request.
|
|
292
303
|
|
|
293
304
|
## Development
|
|
294
305
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# bench
|
|
2
|
+
|
|
3
|
+
The framework's share of a request. Four servers answer the README's routes, `oha` hits each one, and the table
|
|
4
|
+
is the result:
|
|
5
|
+
|
|
6
|
+
- **bare RSGI, granian**: [bare.py](bare.py), an RSGI application with no framework; the ceiling, what granian
|
|
7
|
+
does alone.
|
|
8
|
+
- **bare RSGI reading the body, granian**: [bare_body.py](bare_body.py), the same one with `await protocol()`
|
|
9
|
+
first, which matchapi must do because the body is read before the handler runs
|
|
10
|
+
([design.md](../docs/design.md), D3).
|
|
11
|
+
- **matchapi, granian**: [service.py](service.py), the README's service.
|
|
12
|
+
- **matchapi 0.1.0, uvicorn**: [service_asgi.py](service_asgi.py), the same service as 0.1.0 wrote it, served by
|
|
13
|
+
the `matchapi` command of 0.1.0 (uvicorn, no uvloop or httptools, access log on, since the command had no flag
|
|
14
|
+
for either); installed from PyPI into a throwaway environment by `uv`.
|
|
15
|
+
|
|
16
|
+
```console
|
|
17
|
+
$ uv run bench/run.py # 10 s per server and path, 64 connections
|
|
18
|
+
$ uv run bench/run.py -z 3s -c 16 # a quick look
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Needs [oha](https://github.com/hatoo/oha) and `uv` on the PATH. The bench is not part of CI: the numbers below
|
|
22
|
+
are one run on one laptop, and only the ratios between the rows mean anything.
|
|
23
|
+
|
|
24
|
+
## Results
|
|
25
|
+
|
|
26
|
+
Python 3.14.6, Darwin arm64, 2026-10-12; oha, 64 connections, 10s per cell, one worker.
|
|
27
|
+
|
|
28
|
+
| Server | Path | Requests/s | p50 ms | p99 ms | Of the ceiling |
|
|
29
|
+
|---|---|---:|---:|---:|---:|
|
|
30
|
+
| bare RSGI, granian | `/ping` | 46,381 | 1.36 | 2.08 | 100% |
|
|
31
|
+
| bare RSGI, granian | `/users/42` | 40,847 | 1.54 | 2.43 | 100% |
|
|
32
|
+
| bare RSGI reading the body, granian | `/ping` | 26,550 | 2.38 | 3.22 | 57% |
|
|
33
|
+
| bare RSGI reading the body, granian | `/users/42` | 24,841 | 2.55 | 3.57 | 61% |
|
|
34
|
+
| matchapi, granian | `/ping` | 23,788 | 2.65 | 4.08 | 51% |
|
|
35
|
+
| matchapi, granian | `/users/42` | 22,853 | 2.78 | 3.65 | 56% |
|
|
36
|
+
| matchapi 0.1.0, uvicorn | `/ping` | 9,831 | 6.44 | 7.42 | 21% |
|
|
37
|
+
| matchapi 0.1.0, uvicorn | `/users/42` | 9,580 | 6.57 | 8.84 | 23% |
|
|
38
|
+
|
|
39
|
+
What the rows say:
|
|
40
|
+
|
|
41
|
+
- Reading the body before the handler costs about forty percent of the ceiling on a request that has no body:
|
|
42
|
+
the one `await protocol()` is the expensive part of serving a route that answers from memory.
|
|
43
|
+
- matchapi's own code, the `Request`, the `match`, the response rules, is the rest: about ten percent of a bare
|
|
44
|
+
application that reads the body the same way, a few microseconds per request. That is the share D8 of
|
|
45
|
+
[design.md](../docs/design.md) keeps an eye on.
|
|
46
|
+
- 0.2.0 under granian serves about 2.4 times the requests of 0.1.0 under uvicorn. Most of that is the server.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""A bare RSGI application with the README's routes: the ceiling, what granian does with no framework at all."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
TEXT = [("content-type", "text/plain; charset=utf-8")]
|
|
6
|
+
JSON = [("content-type", "application/json")]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def app(scope, protocol):
|
|
10
|
+
if scope.method == "GET" and scope.path == "/ping":
|
|
11
|
+
protocol.response_str(200, TEXT, "pong")
|
|
12
|
+
elif scope.method == "GET" and scope.path.startswith("/users/"):
|
|
13
|
+
body = json.dumps({"id": int(scope.path[7:]), "name": "ann"}, separators=(",", ":"))
|
|
14
|
+
protocol.response_bytes(200, JSON, body.encode())
|
|
15
|
+
else:
|
|
16
|
+
protocol.response_str(404, TEXT, "Not Found")
|