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.
@@ -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.1.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: asgi,framework,http,match,microservice,pattern-matching,web,webhook
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: uvicorn>=0.30
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
- $ 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)
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
- 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.
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 command that serves a module.
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 `scope` or stream underneath: the body is read before the handler runs, so a sync handler needs 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 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.
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
- ```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
- ```
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
- $ uvicorn asgi:app
219
- $ granian --interface asgi asgi:app
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 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)`.
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 300 lines that reads in half an hour, and the
314
- handler stays a function that works without it.
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 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.
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
- $ 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)
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
- 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.
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 command that serves a module.
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 `scope` or stream underneath: the body is read before the handler runs, so a sync handler needs 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 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.
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
- ```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
- ```
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
- $ uvicorn asgi:app
190
- $ granian --interface asgi asgi:app
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 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)`.
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 300 lines that reads in half an hour, and the
285
- handler stays a function that works without it.
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 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.
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")
@@ -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)