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.
@@ -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.0
4
- Summary: An HTTP service is one function: match replaces the router
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: asgi,framework,http,match,microservice,pattern-matching,web,webhook
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: uvicorn>=0.30
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
- $ matchapi app
57
- INFO: Started server process [62579]
58
- INFO: Waiting for application startup.
59
- INFO: Application startup complete.
60
- INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
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
- No decorators, no route table, no schema classes, no dependency markers. The file above does not even import
87
- `matchapi`: it is a Python function that takes a request and returns a value, and it can be called like one.
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 command that serves a module.
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 `scope` or stream underneath: the body is read before the handler runs, so a sync handler needs 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 pool, an async one in
188
- the event loop; both are served by the same uvicorn. The kind of handler is read from the result of a call, not
189
- from the function, so a `functools.partial`, a callable object or a compiled function all work, and a sync
190
- function that returns a coroutine from some branches is awaited on those.
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
- ```console
195
- $ matchapi app # app.py in the working directory, its function `handle`
196
- $ matchapi app:serve # another function
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
- $ uvicorn asgi:app
219
- $ granian --interface asgi asgi:app
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 cookies or
310
- sessions, no templates, no static files, no websockets, no streaming request bodies, no WSGI. Each of these is
311
- either a few lines in the handler or a job for an ASGI middleware wrapped around `matchapi.app(handle)`.
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 300 lines that reads in half an hour, and the
314
- handler stays a function that works without it.
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 there is a command rather than an `app = ...` line, why the body is read
320
- before the call, why there is only ASGI, and why there is no compiled core.
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
- $ matchapi app
28
- INFO: Started server process [62579]
29
- INFO: Waiting for application startup.
30
- INFO: Application startup complete.
31
- INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
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
- No decorators, no route table, no schema classes, no dependency markers. The file above does not even import
58
- `matchapi`: it is a Python function that takes a request and returns a value, and it can be called like one.
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 command that serves a module.
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 `scope` or stream underneath: the body is read before the handler runs, so a sync handler needs 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 pool, an async one in
159
- the event loop; both are served by the same uvicorn. The kind of handler is read from the result of a call, not
160
- from the function, so a `functools.partial`, a callable object or a compiled function all work, and a sync
161
- function that returns a coroutine from some branches is awaited on those.
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
- ```console
166
- $ matchapi app # app.py in the working directory, its function `handle`
167
- $ matchapi app:serve # another function
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
- $ uvicorn asgi:app
190
- $ granian --interface asgi asgi:app
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 cookies or
281
- sessions, no templates, no static files, no websockets, no streaming request bodies, no WSGI. Each of these is
282
- either a few lines in the handler or a job for an ASGI middleware wrapped around `matchapi.app(handle)`.
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 300 lines that reads in half an hour, and the
285
- handler stays a function that works without it.
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 there is a command rather than an `app = ...` line, why the body is read
291
- before the call, why there is only ASGI, and why there is no compiled core.
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")
@@ -0,0 +1,8 @@
1
+ """The bare RSGI application that also reads the body before answering, as matchapi must (design.md, D3)."""
2
+
3
+ from bare import app as answer
4
+
5
+
6
+ async def app(scope, protocol):
7
+ await protocol()
8
+ await answer(scope, protocol)