matchapi 0.1.0__tar.gz → 0.2.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.
- matchapi-0.2.0/CHANGELOG.md +47 -0
- matchapi-0.2.0/CONTEXT.md +36 -0
- {matchapi-0.1.0 → matchapi-0.2.0}/PKG-INFO +60 -53
- {matchapi-0.1.0 → matchapi-0.2.0}/README.md +57 -50
- matchapi-0.2.0/bench/README.md +46 -0
- matchapi-0.2.0/bench/bare.py +16 -0
- matchapi-0.2.0/bench/bare_body.py +8 -0
- matchapi-0.2.0/bench/run.py +117 -0
- matchapi-0.2.0/bench/service.py +17 -0
- matchapi-0.2.0/bench/service_asgi.py +14 -0
- matchapi-0.2.0/docs/design.md +143 -0
- {matchapi-0.1.0 → matchapi-0.2.0}/pyproject.toml +5 -10
- {matchapi-0.1.0 → matchapi-0.2.0}/src/matchapi/__init__.py +45 -124
- {matchapi-0.1.0 → matchapi-0.2.0}/tests/sample.py +4 -0
- {matchapi-0.1.0 → matchapi-0.2.0}/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.0}/.gitignore +0 -0
- {matchapi-0.1.0 → matchapi-0.2.0}/LICENSE +0 -0
- {matchapi-0.1.0 → matchapi-0.2.0}/src/matchapi/py.typed +0 -0
|
@@ -0,0 +1,47 @@
|
|
|
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.0] - 2026-10-12
|
|
10
|
+
|
|
11
|
+
The handler is decorated and granian serves it. The framework loses its command line and its ASGI adapter;
|
|
12
|
+
the handler contract, the request and the response rules are unchanged.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- `@matchapi.app` over `handle`: the decorated function is an RSGI application for
|
|
17
|
+
[granian](https://github.com/emmett-framework/granian) and is still called as the function in tests. Run with
|
|
18
|
+
`granian app:handle`.
|
|
19
|
+
- granian replaces uvicorn as the one dependency. HTTP/2, TLS, `--workers` and `--reload` are granian's;
|
|
20
|
+
`--reload` needs `pip install 'granian[reload]'`.
|
|
21
|
+
- An exception inside a streaming response body is logged with the method and path, and the stream ends.
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- The `matchapi` command (`matchapi app`, `python -m matchapi`), `matchapi.main`, and the rule that found
|
|
26
|
+
`handle` or the only public function of a module.
|
|
27
|
+
- The ASGI application: uvicorn, hypercorn and ASGI middleware can no longer run a matchapi service.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- `bench/`: the framework's share of a request, measured against a bare RSGI application and against 0.1.0 under
|
|
32
|
+
uvicorn.
|
|
33
|
+
|
|
34
|
+
## [0.1.0] - 2026-10-12
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `Request`: method, path, `route` as `(method, *segments)`, query, headers, body and `json()`; built by hand in
|
|
39
|
+
tests.
|
|
40
|
+
- `Response`, `HTTPError` and `respond()`: the rules that turn a handler's return value into a response.
|
|
41
|
+
- `app(handle)`: the handler as an ASGI application; sync handlers run in the thread pool, async ones in the loop.
|
|
42
|
+
- The `matchapi` command: `matchapi app` serves the module's `handle` with uvicorn; `--host`, `--port`,
|
|
43
|
+
`--workers`, `--reload`.
|
|
44
|
+
|
|
45
|
+
[Unreleased]: https://github.com/troyan-dy/matchapi/compare/v0.2.0...HEAD
|
|
46
|
+
[0.2.0]: https://github.com/troyan-dy/matchapi/compare/v0.1.0...v0.2.0
|
|
47
|
+
[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,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: matchapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: An HTTP service is one function: 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
|
|
@@ -9,7 +9,7 @@ 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: framework,granian,http,match,microservice,pattern-matching,rsgi,web,webhook
|
|
13
13
|
Classifier: Development Status :: 3 - Alpha
|
|
14
14
|
Classifier: Framework :: AsyncIO
|
|
15
15
|
Classifier: Intended Audience :: Developers
|
|
@@ -24,7 +24,7 @@ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
|
24
24
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
25
25
|
Classifier: Typing :: Typed
|
|
26
26
|
Requires-Python: >=3.11
|
|
27
|
-
Requires-Dist:
|
|
27
|
+
Requires-Dist: granian>=2.8
|
|
28
28
|
Description-Content-Type: text/markdown
|
|
29
29
|
|
|
30
30
|
# matchapi
|
|
@@ -38,6 +38,10 @@ An HTTP service is one function. `match` replaces the router.
|
|
|
38
38
|
|
|
39
39
|
```python
|
|
40
40
|
# app.py
|
|
41
|
+
import matchapi
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@matchapi.app
|
|
41
45
|
async def handle(req):
|
|
42
46
|
match req.route:
|
|
43
47
|
case "GET", "ping":
|
|
@@ -53,11 +57,12 @@ async def handle(req):
|
|
|
53
57
|
|
|
54
58
|
```console
|
|
55
59
|
$ pip install matchapi
|
|
56
|
-
$
|
|
57
|
-
INFO
|
|
58
|
-
INFO
|
|
59
|
-
INFO:
|
|
60
|
-
INFO
|
|
60
|
+
$ granian app:handle
|
|
61
|
+
[INFO] Starting granian (main PID: 505)
|
|
62
|
+
[INFO] Listening at: http://127.0.0.1:8000
|
|
63
|
+
[INFO] Spawning worker-1 with PID: 512
|
|
64
|
+
[INFO] Started worker-1
|
|
65
|
+
[INFO] Started worker-1 runtime-1
|
|
61
66
|
```
|
|
62
67
|
|
|
63
68
|
```console
|
|
@@ -67,24 +72,24 @@ $ curl localhost:8000/users/42
|
|
|
67
72
|
{"id":42,"name":"ann"}
|
|
68
73
|
$ curl -i -X POST localhost:8000/users -d '{"name": "bob"}'
|
|
69
74
|
HTTP/1.1 201 Created
|
|
70
|
-
date: Mon, 12 Oct 2026 00:10:08 GMT
|
|
71
|
-
server: uvicorn
|
|
72
75
|
content-type: application/json
|
|
73
76
|
content-length: 21
|
|
77
|
+
server: granian
|
|
78
|
+
date: Mon, 12 Oct 2026 01:26:59 GMT
|
|
74
79
|
|
|
75
80
|
{"id":7,"name":"bob"}
|
|
76
81
|
$ curl -i localhost:8000/nothing
|
|
77
82
|
HTTP/1.1 404 Not Found
|
|
78
|
-
date: Mon, 12 Oct 2026 00:10:08 GMT
|
|
79
|
-
server: uvicorn
|
|
80
83
|
content-type: text/plain; charset=utf-8
|
|
81
84
|
content-length: 9
|
|
85
|
+
server: granian
|
|
86
|
+
date: Mon, 12 Oct 2026 01:26:59 GMT
|
|
82
87
|
|
|
83
88
|
Not Found
|
|
84
89
|
```
|
|
85
90
|
|
|
86
|
-
|
|
87
|
-
`
|
|
91
|
+
One import and one decorator; no route table, no schema classes, no dependency markers, no middleware stack. The
|
|
92
|
+
decorated `handle` is still the function it was: a test calls it with a request and compares the return value.
|
|
88
93
|
|
|
89
94
|
- [Why](#why)
|
|
90
95
|
- [The model](#the-model)
|
|
@@ -110,7 +115,8 @@ models. A service with three endpoints pays for all of it.
|
|
|
110
115
|
Python has had structural pattern matching since 3.10. A `match` over `(method, *segments)` is a router already:
|
|
111
116
|
it binds path parameters, it falls through to a default, it nests, and the language, not a library, defines how it
|
|
112
117
|
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
|
|
118
|
+
turn a return value into one, and a decorator that lets [granian](https://github.com/emmett-framework/granian)
|
|
119
|
+
serve the function.
|
|
114
120
|
|
|
115
121
|
## The model
|
|
116
122
|
|
|
@@ -119,6 +125,11 @@ turn a return value into one, and a command that serves a module.
|
|
|
119
125
|
The service is a function that takes a `Request` and returns something. That is the whole contract. A function is
|
|
120
126
|
easy to test, easy to compose, easy to move: two services in one process are a function that calls two others.
|
|
121
127
|
|
|
128
|
+
`@matchapi.app` marks the function as the application granian runs. It changes nothing about the function:
|
|
129
|
+
`handle(req)` calls it, `handle.__name__` and `handle.__doc__` are its own. The one thing that differs is
|
|
130
|
+
`inspect.iscoroutinefunction(handle)`, which is `False` for the decorated object; matchapi itself never asks it,
|
|
131
|
+
it looks at what a call returns.
|
|
132
|
+
|
|
122
133
|
### `req.route` is the subject of `match`
|
|
123
134
|
|
|
124
135
|
`req.route` is a tuple of the method followed by the non-empty path segments: `GET /users/42` is
|
|
@@ -148,11 +159,11 @@ strings for the cases where a plain comparison reads better.
|
|
|
148
159
|
| `path` | `str` | `"/users/42"` |
|
|
149
160
|
| `route` | `tuple[str, ...]` | `("POST", "users", "42")` |
|
|
150
161
|
| `query` | `dict[str, str]`, last value wins | `{"q": "x"}` |
|
|
151
|
-
| `headers` | `dict[str, str]`, lower-case names | `{"content-type": "application/json"}` |
|
|
162
|
+
| `headers` | `dict[str, str]`, lower-case names, last value wins | `{"content-type": "application/json"}` |
|
|
152
163
|
| `body` | `bytes`, read in full before the call | `b'{"name": "bob"}'` |
|
|
153
164
|
| `json()` | the body as JSON; a broken body is a 400 | `{"name": "bob"}` |
|
|
154
165
|
|
|
155
|
-
There is no
|
|
166
|
+
There is no scope or stream underneath: the body is read before the handler runs, so a sync handler needs no
|
|
156
167
|
`await`. `Request("POST", "/users", body=b"...")`
|
|
157
168
|
builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
|
|
158
169
|
|
|
@@ -180,45 +191,35 @@ so a module-level `NOT_FOUND = Response(404, "no")` is safe to reuse.
|
|
|
180
191
|
`{"detail": "..."}` with that status; `detail` defaults to the standard phrase. `req.json()` raises it as a 400.
|
|
181
192
|
|
|
182
193
|
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.
|
|
194
|
+
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
|
|
195
|
+
same way; the status is out already, so the stream just ends. A client that leaves in the middle of a stream is
|
|
196
|
+
granian's business, not an error of the handler.
|
|
184
197
|
|
|
185
198
|
### Sync or async, the same way
|
|
186
199
|
|
|
187
|
-
`def handle(req)` and `async def handle(req)` are both fine. A sync handler runs in a thread
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
200
|
+
`def handle(req)` and `async def handle(req)` are both fine. A sync handler runs in a thread, an async one in the
|
|
201
|
+
event loop. The kind of handler is read from the result of a call, not from the function, so a
|
|
202
|
+
`functools.partial`, a callable object or a compiled function all work, and a sync function that returns a
|
|
203
|
+
coroutine from some branches is awaited on those.
|
|
191
204
|
|
|
192
205
|
## Running
|
|
193
206
|
|
|
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
|
-
```
|
|
207
|
+
The server is [granian](https://github.com/emmett-framework/granian), the one dependency of the package; the
|
|
208
|
+
decorated function is an [RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md)
|
|
209
|
+
application, granian's native interface.
|
|
216
210
|
|
|
217
211
|
```console
|
|
218
|
-
$
|
|
219
|
-
$ granian
|
|
212
|
+
$ granian app:handle # app.py in the working directory, its function `handle`
|
|
213
|
+
$ granian api.users:handle # a module in a package
|
|
214
|
+
$ granian app:handle --host 0.0.0.0 --port 80 --workers 4
|
|
215
|
+
$ granian app:handle --http 2 # HTTP/2 without TLS (h2c)
|
|
216
|
+
$ granian app:handle --reload # development; needs `pip install 'granian[reload]'`
|
|
217
|
+
$ python -m granian app:handle # the same command
|
|
220
218
|
```
|
|
221
219
|
|
|
220
|
+
`--workers` and `--reload` make every process import the module itself. The rest of the options, TLS, an event
|
|
221
|
+
loop, access logs, are granian's: `granian --help`.
|
|
222
|
+
|
|
222
223
|
## Testing
|
|
223
224
|
|
|
224
225
|
The handler is a function and `Request` is a constructor, so a test needs no client and no server:
|
|
@@ -252,6 +253,7 @@ line matter, `matchapi.respond(value)` turns a return value into the `Response`
|
|
|
252
253
|
**Authorization** is a line before the `match`:
|
|
253
254
|
|
|
254
255
|
```python
|
|
256
|
+
@matchapi.app
|
|
255
257
|
async def handle(req):
|
|
256
258
|
if req.headers.get("authorization") != f"Bearer {TOKEN}":
|
|
257
259
|
return 401
|
|
@@ -262,6 +264,7 @@ async def handle(req):
|
|
|
262
264
|
**Two services in one process** are two functions and a prefix:
|
|
263
265
|
|
|
264
266
|
```python
|
|
267
|
+
@matchapi.app
|
|
265
268
|
async def handle(req):
|
|
266
269
|
match req.route:
|
|
267
270
|
case _, "users", *rest:
|
|
@@ -277,6 +280,7 @@ async def handle(req):
|
|
|
277
280
|
```python
|
|
278
281
|
from dataclasses import dataclass
|
|
279
282
|
|
|
283
|
+
import matchapi
|
|
280
284
|
from matchapi import HTTPError
|
|
281
285
|
|
|
282
286
|
|
|
@@ -286,6 +290,7 @@ class NewUser:
|
|
|
286
290
|
age: int = 0
|
|
287
291
|
|
|
288
292
|
|
|
293
|
+
@matchapi.app
|
|
289
294
|
async def handle(req):
|
|
290
295
|
match req.route:
|
|
291
296
|
case "POST", "users":
|
|
@@ -306,18 +311,20 @@ return Response(200, (chunk for chunk in lines()))
|
|
|
306
311
|
|
|
307
312
|
## What matchapi does not do
|
|
308
313
|
|
|
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
|
|
314
|
+
No router or decorators for routes, no middleware stack, no parameter conversion from type hints, no OpenAPI, no
|
|
315
|
+
cookies or sessions, no templates, no static files, no websockets, no streaming request bodies, no ASGI or WSGI.
|
|
316
|
+
Each of these is either a few lines in the handler or not matchapi's job: the server is granian and the only
|
|
317
|
+
interface is RSGI, so there is no middleware layer to plug into.
|
|
312
318
|
|
|
313
|
-
The line is deliberate. The framework fits in one file of about
|
|
314
|
-
handler stays a function that works without
|
|
319
|
+
The line is deliberate. The framework fits in one file of about 200 lines that reads in half an hour, and the
|
|
320
|
+
handler stays a function that works without a server.
|
|
315
321
|
|
|
316
322
|
## Design notes
|
|
317
323
|
|
|
318
324
|
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
|
-
|
|
325
|
+
went instead of the signature magic, why a decorator rather than a command, why the body is read before the
|
|
326
|
+
call, why there is only RSGI through granian, and why there is no compiled core. [CONTEXT.md](CONTEXT.md) is the
|
|
327
|
+
glossary. [bench/](bench/) measures the framework's own share of a request.
|
|
321
328
|
|
|
322
329
|
## Development
|
|
323
330
|
|
|
@@ -9,6 +9,10 @@ An HTTP service is one function. `match` replaces the router.
|
|
|
9
9
|
|
|
10
10
|
```python
|
|
11
11
|
# app.py
|
|
12
|
+
import matchapi
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@matchapi.app
|
|
12
16
|
async def handle(req):
|
|
13
17
|
match req.route:
|
|
14
18
|
case "GET", "ping":
|
|
@@ -24,11 +28,12 @@ async def handle(req):
|
|
|
24
28
|
|
|
25
29
|
```console
|
|
26
30
|
$ pip install matchapi
|
|
27
|
-
$
|
|
28
|
-
INFO
|
|
29
|
-
INFO
|
|
30
|
-
INFO:
|
|
31
|
-
INFO
|
|
31
|
+
$ granian app:handle
|
|
32
|
+
[INFO] Starting granian (main PID: 505)
|
|
33
|
+
[INFO] Listening at: http://127.0.0.1:8000
|
|
34
|
+
[INFO] Spawning worker-1 with PID: 512
|
|
35
|
+
[INFO] Started worker-1
|
|
36
|
+
[INFO] Started worker-1 runtime-1
|
|
32
37
|
```
|
|
33
38
|
|
|
34
39
|
```console
|
|
@@ -38,24 +43,24 @@ $ curl localhost:8000/users/42
|
|
|
38
43
|
{"id":42,"name":"ann"}
|
|
39
44
|
$ curl -i -X POST localhost:8000/users -d '{"name": "bob"}'
|
|
40
45
|
HTTP/1.1 201 Created
|
|
41
|
-
date: Mon, 12 Oct 2026 00:10:08 GMT
|
|
42
|
-
server: uvicorn
|
|
43
46
|
content-type: application/json
|
|
44
47
|
content-length: 21
|
|
48
|
+
server: granian
|
|
49
|
+
date: Mon, 12 Oct 2026 01:26:59 GMT
|
|
45
50
|
|
|
46
51
|
{"id":7,"name":"bob"}
|
|
47
52
|
$ curl -i localhost:8000/nothing
|
|
48
53
|
HTTP/1.1 404 Not Found
|
|
49
|
-
date: Mon, 12 Oct 2026 00:10:08 GMT
|
|
50
|
-
server: uvicorn
|
|
51
54
|
content-type: text/plain; charset=utf-8
|
|
52
55
|
content-length: 9
|
|
56
|
+
server: granian
|
|
57
|
+
date: Mon, 12 Oct 2026 01:26:59 GMT
|
|
53
58
|
|
|
54
59
|
Not Found
|
|
55
60
|
```
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
`
|
|
62
|
+
One import and one decorator; no route table, no schema classes, no dependency markers, no middleware stack. The
|
|
63
|
+
decorated `handle` is still the function it was: a test calls it with a request and compares the return value.
|
|
59
64
|
|
|
60
65
|
- [Why](#why)
|
|
61
66
|
- [The model](#the-model)
|
|
@@ -81,7 +86,8 @@ models. A service with three endpoints pays for all of it.
|
|
|
81
86
|
Python has had structural pattern matching since 3.10. A `match` over `(method, *segments)` is a router already:
|
|
82
87
|
it binds path parameters, it falls through to a default, it nests, and the language, not a library, defines how it
|
|
83
88
|
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
|
|
89
|
+
turn a return value into one, and a decorator that lets [granian](https://github.com/emmett-framework/granian)
|
|
90
|
+
serve the function.
|
|
85
91
|
|
|
86
92
|
## The model
|
|
87
93
|
|
|
@@ -90,6 +96,11 @@ turn a return value into one, and a command that serves a module.
|
|
|
90
96
|
The service is a function that takes a `Request` and returns something. That is the whole contract. A function is
|
|
91
97
|
easy to test, easy to compose, easy to move: two services in one process are a function that calls two others.
|
|
92
98
|
|
|
99
|
+
`@matchapi.app` marks the function as the application granian runs. It changes nothing about the function:
|
|
100
|
+
`handle(req)` calls it, `handle.__name__` and `handle.__doc__` are its own. The one thing that differs is
|
|
101
|
+
`inspect.iscoroutinefunction(handle)`, which is `False` for the decorated object; matchapi itself never asks it,
|
|
102
|
+
it looks at what a call returns.
|
|
103
|
+
|
|
93
104
|
### `req.route` is the subject of `match`
|
|
94
105
|
|
|
95
106
|
`req.route` is a tuple of the method followed by the non-empty path segments: `GET /users/42` is
|
|
@@ -119,11 +130,11 @@ strings for the cases where a plain comparison reads better.
|
|
|
119
130
|
| `path` | `str` | `"/users/42"` |
|
|
120
131
|
| `route` | `tuple[str, ...]` | `("POST", "users", "42")` |
|
|
121
132
|
| `query` | `dict[str, str]`, last value wins | `{"q": "x"}` |
|
|
122
|
-
| `headers` | `dict[str, str]`, lower-case names | `{"content-type": "application/json"}` |
|
|
133
|
+
| `headers` | `dict[str, str]`, lower-case names, last value wins | `{"content-type": "application/json"}` |
|
|
123
134
|
| `body` | `bytes`, read in full before the call | `b'{"name": "bob"}'` |
|
|
124
135
|
| `json()` | the body as JSON; a broken body is a 400 | `{"name": "bob"}` |
|
|
125
136
|
|
|
126
|
-
There is no
|
|
137
|
+
There is no scope or stream underneath: the body is read before the handler runs, so a sync handler needs no
|
|
127
138
|
`await`. `Request("POST", "/users", body=b"...")`
|
|
128
139
|
builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
|
|
129
140
|
|
|
@@ -151,45 +162,35 @@ so a module-level `NOT_FOUND = Response(404, "no")` is safe to reuse.
|
|
|
151
162
|
`{"detail": "..."}` with that status; `detail` defaults to the standard phrase. `req.json()` raises it as a 400.
|
|
152
163
|
|
|
153
164
|
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.
|
|
165
|
+
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
|
|
166
|
+
same way; the status is out already, so the stream just ends. A client that leaves in the middle of a stream is
|
|
167
|
+
granian's business, not an error of the handler.
|
|
155
168
|
|
|
156
169
|
### Sync or async, the same way
|
|
157
170
|
|
|
158
|
-
`def handle(req)` and `async def handle(req)` are both fine. A sync handler runs in a thread
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
171
|
+
`def handle(req)` and `async def handle(req)` are both fine. A sync handler runs in a thread, an async one in the
|
|
172
|
+
event loop. The kind of handler is read from the result of a call, not from the function, so a
|
|
173
|
+
`functools.partial`, a callable object or a compiled function all work, and a sync function that returns a
|
|
174
|
+
coroutine from some branches is awaited on those.
|
|
162
175
|
|
|
163
176
|
## Running
|
|
164
177
|
|
|
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
|
-
```
|
|
178
|
+
The server is [granian](https://github.com/emmett-framework/granian), the one dependency of the package; the
|
|
179
|
+
decorated function is an [RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md)
|
|
180
|
+
application, granian's native interface.
|
|
187
181
|
|
|
188
182
|
```console
|
|
189
|
-
$
|
|
190
|
-
$ granian
|
|
183
|
+
$ granian app:handle # app.py in the working directory, its function `handle`
|
|
184
|
+
$ granian api.users:handle # a module in a package
|
|
185
|
+
$ granian app:handle --host 0.0.0.0 --port 80 --workers 4
|
|
186
|
+
$ granian app:handle --http 2 # HTTP/2 without TLS (h2c)
|
|
187
|
+
$ granian app:handle --reload # development; needs `pip install 'granian[reload]'`
|
|
188
|
+
$ python -m granian app:handle # the same command
|
|
191
189
|
```
|
|
192
190
|
|
|
191
|
+
`--workers` and `--reload` make every process import the module itself. The rest of the options, TLS, an event
|
|
192
|
+
loop, access logs, are granian's: `granian --help`.
|
|
193
|
+
|
|
193
194
|
## Testing
|
|
194
195
|
|
|
195
196
|
The handler is a function and `Request` is a constructor, so a test needs no client and no server:
|
|
@@ -223,6 +224,7 @@ line matter, `matchapi.respond(value)` turns a return value into the `Response`
|
|
|
223
224
|
**Authorization** is a line before the `match`:
|
|
224
225
|
|
|
225
226
|
```python
|
|
227
|
+
@matchapi.app
|
|
226
228
|
async def handle(req):
|
|
227
229
|
if req.headers.get("authorization") != f"Bearer {TOKEN}":
|
|
228
230
|
return 401
|
|
@@ -233,6 +235,7 @@ async def handle(req):
|
|
|
233
235
|
**Two services in one process** are two functions and a prefix:
|
|
234
236
|
|
|
235
237
|
```python
|
|
238
|
+
@matchapi.app
|
|
236
239
|
async def handle(req):
|
|
237
240
|
match req.route:
|
|
238
241
|
case _, "users", *rest:
|
|
@@ -248,6 +251,7 @@ async def handle(req):
|
|
|
248
251
|
```python
|
|
249
252
|
from dataclasses import dataclass
|
|
250
253
|
|
|
254
|
+
import matchapi
|
|
251
255
|
from matchapi import HTTPError
|
|
252
256
|
|
|
253
257
|
|
|
@@ -257,6 +261,7 @@ class NewUser:
|
|
|
257
261
|
age: int = 0
|
|
258
262
|
|
|
259
263
|
|
|
264
|
+
@matchapi.app
|
|
260
265
|
async def handle(req):
|
|
261
266
|
match req.route:
|
|
262
267
|
case "POST", "users":
|
|
@@ -277,18 +282,20 @@ return Response(200, (chunk for chunk in lines()))
|
|
|
277
282
|
|
|
278
283
|
## What matchapi does not do
|
|
279
284
|
|
|
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
|
|
285
|
+
No router or decorators for routes, no middleware stack, no parameter conversion from type hints, no OpenAPI, no
|
|
286
|
+
cookies or sessions, no templates, no static files, no websockets, no streaming request bodies, no ASGI or WSGI.
|
|
287
|
+
Each of these is either a few lines in the handler or not matchapi's job: the server is granian and the only
|
|
288
|
+
interface is RSGI, so there is no middleware layer to plug into.
|
|
283
289
|
|
|
284
|
-
The line is deliberate. The framework fits in one file of about
|
|
285
|
-
handler stays a function that works without
|
|
290
|
+
The line is deliberate. The framework fits in one file of about 200 lines that reads in half an hour, and the
|
|
291
|
+
handler stays a function that works without a server.
|
|
286
292
|
|
|
287
293
|
## Design notes
|
|
288
294
|
|
|
289
295
|
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
|
-
|
|
296
|
+
went instead of the signature magic, why a decorator rather than a command, why the body is read before the
|
|
297
|
+
call, why there is only RSGI through granian, and why there is no compiled core. [CONTEXT.md](CONTEXT.md) is the
|
|
298
|
+
glossary. [bench/](bench/) measures the framework's own share of a request.
|
|
292
299
|
|
|
293
300
|
## Development
|
|
294
301
|
|
|
@@ -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")
|