matchapi 0.2.0__tar.gz → 0.3.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 → matchapi-0.3.0}/CHANGELOG.md +37 -1
- matchapi-0.2.0/README.md → matchapi-0.3.0/PKG-INFO +79 -2
- matchapi-0.2.0/PKG-INFO → matchapi-0.3.0/README.md +47 -31
- matchapi-0.3.0/bench/README.md +114 -0
- matchapi-0.3.0/bench/bare.py +27 -0
- matchapi-0.3.0/bench/bare_body.py +7 -0
- matchapi-0.3.0/bench/chart.py +117 -0
- matchapi-0.3.0/bench/run.py +151 -0
- matchapi-0.3.0/bench/with_blacksheep.py +20 -0
- matchapi-0.3.0/bench/with_emmett.py +25 -0
- matchapi-0.3.0/bench/with_fastapi.py +26 -0
- matchapi-0.3.0/bench/with_litestar.py +21 -0
- matchapi-0.3.0/bench/with_starlette.py +27 -0
- matchapi-0.3.0/docs/bench.svg +101 -0
- {matchapi-0.2.0 → matchapi-0.3.0}/docs/design.md +34 -13
- {matchapi-0.2.0 → matchapi-0.3.0}/pyproject.toml +26 -3
- {matchapi-0.2.0 → matchapi-0.3.0}/src/matchapi/__init__.py +88 -25
- {matchapi-0.2.0 → matchapi-0.3.0}/tests/test_matchapi.py +53 -7
- matchapi-0.2.0/bench/README.md +0 -46
- matchapi-0.2.0/bench/bare.py +0 -16
- matchapi-0.2.0/bench/bare_body.py +0 -8
- matchapi-0.2.0/bench/run.py +0 -117
- matchapi-0.2.0/bench/service_asgi.py +0 -14
- {matchapi-0.2.0 → matchapi-0.3.0}/.gitignore +0 -0
- {matchapi-0.2.0 → matchapi-0.3.0}/CONTEXT.md +0 -0
- {matchapi-0.2.0 → matchapi-0.3.0}/LICENSE +0 -0
- {matchapi-0.2.0 → matchapi-0.3.0}/bench/service.py +0 -0
- {matchapi-0.2.0 → matchapi-0.3.0}/src/matchapi/py.typed +0 -0
- {matchapi-0.2.0 → matchapi-0.3.0}/tests/sample.py +0 -0
|
@@ -6,6 +6,40 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-10-12
|
|
10
|
+
|
|
11
|
+
A request pays for what the handler reads. On HTTP/1.1 matchapi serves as many requests as a bare RSGI
|
|
12
|
+
application answering the same routes, and more than Emmett, BlackSheep, Starlette, Litestar and FastAPI under
|
|
13
|
+
the same granian (`bench/`).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- An HTTP/1 request without `content-length` or `transfer-encoding` has no body (RFC 9112, section 6.3), so the
|
|
18
|
+
app no longer awaits one; `req.body` is `b""`. HTTP/2 requests still await the body.
|
|
19
|
+
- `req.route`, `req.query` and `req.headers` are computed on first read and kept. They are read-only properties
|
|
20
|
+
now: assigning to them raises `AttributeError`.
|
|
21
|
+
- `str`, `dict`, `list` and `(status, one of those)` return values go to granian without a `Response`, and
|
|
22
|
+
granian sets `content-length`; `respond()` still describes the same response.
|
|
23
|
+
- JSON responses reuse one encoder instead of building one per `json.dumps` call.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `bench/` runs Emmett, BlackSheep, Starlette, Litestar and FastAPI, pinned, under the same granian next to
|
|
28
|
+
matchapi, a `POST` with a JSON body, and `--http2`. The previous matchapi from PyPI replaces 0.1.0 under
|
|
29
|
+
uvicorn.
|
|
30
|
+
- The README opens with the measured numbers: a Performance section with the HTTP/1.1 table and a chart,
|
|
31
|
+
`docs/bench.svg`, drawn from `bench/README.md` by `bench/chart.py`; badges for coverage, `mypy --strict`, Ruff,
|
|
32
|
+
the one dependency and the one file.
|
|
33
|
+
|
|
34
|
+
## [0.2.1] - 2026-10-12
|
|
35
|
+
|
|
36
|
+
Package metadata only; the code is unchanged.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- The PyPI description, keywords and classifiers name granian and RSGI, so the package is found by the stack it
|
|
41
|
+
runs on. The README opens with the same line.
|
|
42
|
+
|
|
9
43
|
## [0.2.0] - 2026-10-12
|
|
10
44
|
|
|
11
45
|
The handler is decorated and granian serves it. The framework loses its command line and its ASGI adapter;
|
|
@@ -42,6 +76,8 @@ the handler contract, the request and the response rules are unchanged.
|
|
|
42
76
|
- The `matchapi` command: `matchapi app` serves the module's `handle` with uvicorn; `--host`, `--port`,
|
|
43
77
|
`--workers`, `--reload`.
|
|
44
78
|
|
|
45
|
-
[Unreleased]: https://github.com/troyan-dy/matchapi/compare/v0.
|
|
79
|
+
[Unreleased]: https://github.com/troyan-dy/matchapi/compare/v0.3.0...HEAD
|
|
80
|
+
[0.3.0]: https://github.com/troyan-dy/matchapi/compare/v0.2.1...v0.3.0
|
|
81
|
+
[0.2.1]: https://github.com/troyan-dy/matchapi/compare/v0.2.0...v0.2.1
|
|
46
82
|
[0.2.0]: https://github.com/troyan-dy/matchapi/compare/v0.1.0...v0.2.0
|
|
47
83
|
[0.1.0]: https://github.com/troyan-dy/matchapi/releases/tag/v0.1.0
|
|
@@ -1,12 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: matchapi
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: One-function HTTP framework for granian over RSGI: match replaces the router
|
|
5
|
+
Project-URL: Homepage, https://github.com/troyan-dy/matchapi
|
|
6
|
+
Project-URL: Repository, https://github.com/troyan-dy/matchapi
|
|
7
|
+
Project-URL: Issues, https://github.com/troyan-dy/matchapi/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/troyan-dy/matchapi/blob/master/CHANGELOG.md
|
|
9
|
+
Author: Dmitriy Troyan
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
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
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
26
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
27
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
|
+
Requires-Dist: granian>=2.8
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
1
33
|
# matchapi
|
|
2
34
|
|
|
3
35
|
[](https://pypi.org/project/matchapi/)
|
|
4
36
|
[](https://pypi.org/project/matchapi/)
|
|
5
37
|
[](https://github.com/troyan-dy/matchapi/actions/workflows/ci.yml)
|
|
38
|
+
[](https://github.com/troyan-dy/matchapi/blob/master/README.md#development)
|
|
39
|
+
[](https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict)
|
|
40
|
+
[](https://github.com/astral-sh/ruff)
|
|
41
|
+
[](https://github.com/troyan-dy/matchapi/blob/master/README.md#performance)
|
|
42
|
+
[](https://github.com/emmett-framework/granian)
|
|
43
|
+
[](https://github.com/troyan-dy/matchapi/blob/master/src/matchapi/__init__.py)
|
|
6
44
|
[](LICENSE)
|
|
7
45
|
|
|
8
46
|
An HTTP service is one function. `match` replaces the router.
|
|
9
47
|
|
|
48
|
+
A one-file Python web framework for [granian](https://github.com/emmett-framework/granian) over
|
|
49
|
+
[RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md), granian's native Rust-backed
|
|
50
|
+
interface: no router, no middleware, no ASGI adapter. Python 3.11+.
|
|
51
|
+
|
|
52
|
+
- **Fast.** On HTTP/1.1 within a few percent of a bare RSGI application written by hand, and 1.16 to 3.1 times
|
|
53
|
+
the requests per second of Emmett, BlackSheep, Starlette, Litestar and FastAPI under the same granian.
|
|
54
|
+
[Performance](#performance).
|
|
55
|
+
- **Small.** One file of about 300 lines that reads in half an hour, pure Python, one dependency: granian.
|
|
56
|
+
- **Tested.** 100% line and branch coverage, `mypy --strict`, a real granian in the test suite.
|
|
57
|
+
|
|
10
58
|
```python
|
|
11
59
|
# app.py
|
|
12
60
|
import matchapi
|
|
@@ -62,6 +110,7 @@ Not Found
|
|
|
62
110
|
One import and one decorator; no route table, no schema classes, no dependency markers, no middleware stack. The
|
|
63
111
|
decorated `handle` is still the function it was: a test calls it with a request and compares the return value.
|
|
64
112
|
|
|
113
|
+
- [Performance](#performance)
|
|
65
114
|
- [Why](#why)
|
|
66
115
|
- [The model](#the-model)
|
|
67
116
|
- [One function](#one-function)
|
|
@@ -77,6 +126,29 @@ decorated `handle` is still the function it was: a test calls it with a request
|
|
|
77
126
|
- [Design notes](#design-notes)
|
|
78
127
|
- [Development](#development)
|
|
79
128
|
|
|
129
|
+
## Performance
|
|
130
|
+
|
|
131
|
+

|
|
132
|
+
|
|
133
|
+
The README's service, written the way each framework writes it, under the same granian, one worker, `oha` with
|
|
134
|
+
64 connections; the multiplier is how many times matchapi's requests per second:
|
|
135
|
+
|
|
136
|
+
| Requests/s, HTTP/1.1, higher is better | `GET /ping` | `GET /users/42` | `POST /users` |
|
|
137
|
+
|---|---:|---:|---:|
|
|
138
|
+
| bare RSGI, no framework (the ceiling) | 45,264 | 42,993 | 24,432 |
|
|
139
|
+
| **matchapi** | **45,049** | **43,055** | **23,439** |
|
|
140
|
+
| Emmett 2.8 (RSGI) | 38,733 (1.16×) | 30,135 (1.43×) | 17,574 (1.33×) |
|
|
141
|
+
| BlackSheep 2.6 | 39,000 (1.16×) | 33,794 (1.27×) | 13,878 (1.69×) |
|
|
142
|
+
| Starlette 1.7 | 32,612 (1.38×) | 30,206 (1.43×) | 13,071 (1.79×) |
|
|
143
|
+
| Litestar 2.24 | 26,005 (1.73×) | 23,974 (1.80×) | 11,878 (1.97×) |
|
|
144
|
+
| FastAPI 0.143 | 17,819 (2.53×) | 13,940 (3.09×) | 8,501 (2.76×) |
|
|
145
|
+
|
|
146
|
+
matchapi parses nothing the handler does not read: `route`, `query` and `headers` are computed on first use, an
|
|
147
|
+
HTTP/1 request without a body is not waited on, and the commonest return values go to granian without an
|
|
148
|
+
intermediate object. Over HTTP/2 every request still awaits its body, and there Emmett and BlackSheep are ahead on
|
|
149
|
+
`GET`. [bench/](bench/) has both protocols, the method and the command that reproduces the tables; one laptop,
|
|
150
|
+
Python 3.14, granian 2.8.4.
|
|
151
|
+
|
|
80
152
|
## Why
|
|
81
153
|
|
|
82
154
|
Every Python web framework, from Flask to FastAPI, is a router with a decorator in front of it and a growing set of
|
|
@@ -138,6 +210,11 @@ There is no scope or stream underneath: the body is read before the handler runs
|
|
|
138
210
|
`await`. `Request("POST", "/users", body=b"...")`
|
|
139
211
|
builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
|
|
140
212
|
|
|
213
|
+
Nothing is parsed that the handler does not read: `route`, `query` and `headers` are computed on first use, and
|
|
214
|
+
an HTTP/1 request without `content-length` or `transfer-encoding` has no body to wait for. On HTTP/1.1
|
|
215
|
+
[bench/](bench/) cannot tell matchapi from a bare RSGI application answering the same routes by hand, and puts
|
|
216
|
+
it ahead of Emmett, BlackSheep, Starlette, Litestar and FastAPI under the same granian.
|
|
217
|
+
|
|
141
218
|
### The return value is the response
|
|
142
219
|
|
|
143
220
|
| Return | Response |
|
|
@@ -287,7 +364,7 @@ cookies or sessions, no templates, no static files, no websockets, no streaming
|
|
|
287
364
|
Each of these is either a few lines in the handler or not matchapi's job: the server is granian and the only
|
|
288
365
|
interface is RSGI, so there is no middleware layer to plug into.
|
|
289
366
|
|
|
290
|
-
The line is deliberate. The framework fits in one file of about
|
|
367
|
+
The line is deliberate. The framework fits in one file of about 300 lines that reads in half an hour, and the
|
|
291
368
|
handler stays a function that works without a server.
|
|
292
369
|
|
|
293
370
|
## Design notes
|
|
@@ -295,7 +372,7 @@ handler stays a function that works without a server.
|
|
|
295
372
|
The decisions and the alternatives that were rejected are in [docs/design.md](docs/design.md): why the router
|
|
296
373
|
went instead of the signature magic, why a decorator rather than a command, why the body is read before the
|
|
297
374
|
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
|
|
375
|
+
glossary. [bench/](bench/) measures matchapi against a bare RSGI application and against other frameworks.
|
|
299
376
|
|
|
300
377
|
## Development
|
|
301
378
|
|
|
@@ -1,41 +1,28 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: matchapi
|
|
3
|
-
Version: 0.2.0
|
|
4
|
-
Summary: An HTTP service is one function: match replaces the router
|
|
5
|
-
Project-URL: Homepage, https://github.com/troyan-dy/matchapi
|
|
6
|
-
Project-URL: Repository, https://github.com/troyan-dy/matchapi
|
|
7
|
-
Project-URL: Issues, https://github.com/troyan-dy/matchapi/issues
|
|
8
|
-
Project-URL: Changelog, https://github.com/troyan-dy/matchapi/blob/master/CHANGELOG.md
|
|
9
|
-
Author: Dmitriy Troyan
|
|
10
|
-
License-Expression: MIT
|
|
11
|
-
License-File: LICENSE
|
|
12
|
-
Keywords: framework,granian,http,match,microservice,pattern-matching,rsgi,web,webhook
|
|
13
|
-
Classifier: Development Status :: 3 - Alpha
|
|
14
|
-
Classifier: Framework :: AsyncIO
|
|
15
|
-
Classifier: Intended Audience :: Developers
|
|
16
|
-
Classifier: Operating System :: OS Independent
|
|
17
|
-
Classifier: Programming Language :: Python :: 3
|
|
18
|
-
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
-
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
24
|
-
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
25
|
-
Classifier: Typing :: Typed
|
|
26
|
-
Requires-Python: >=3.11
|
|
27
|
-
Requires-Dist: granian>=2.8
|
|
28
|
-
Description-Content-Type: text/markdown
|
|
29
|
-
|
|
30
1
|
# matchapi
|
|
31
2
|
|
|
32
3
|
[](https://pypi.org/project/matchapi/)
|
|
33
4
|
[](https://pypi.org/project/matchapi/)
|
|
34
5
|
[](https://github.com/troyan-dy/matchapi/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/troyan-dy/matchapi/blob/master/README.md#development)
|
|
7
|
+
[](https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict)
|
|
8
|
+
[](https://github.com/astral-sh/ruff)
|
|
9
|
+
[](https://github.com/troyan-dy/matchapi/blob/master/README.md#performance)
|
|
10
|
+
[](https://github.com/emmett-framework/granian)
|
|
11
|
+
[](https://github.com/troyan-dy/matchapi/blob/master/src/matchapi/__init__.py)
|
|
35
12
|
[](LICENSE)
|
|
36
13
|
|
|
37
14
|
An HTTP service is one function. `match` replaces the router.
|
|
38
15
|
|
|
16
|
+
A one-file Python web framework for [granian](https://github.com/emmett-framework/granian) over
|
|
17
|
+
[RSGI](https://github.com/emmett-framework/granian/blob/master/docs/spec/RSGI.md), granian's native Rust-backed
|
|
18
|
+
interface: no router, no middleware, no ASGI adapter. Python 3.11+.
|
|
19
|
+
|
|
20
|
+
- **Fast.** On HTTP/1.1 within a few percent of a bare RSGI application written by hand, and 1.16 to 3.1 times
|
|
21
|
+
the requests per second of Emmett, BlackSheep, Starlette, Litestar and FastAPI under the same granian.
|
|
22
|
+
[Performance](#performance).
|
|
23
|
+
- **Small.** One file of about 300 lines that reads in half an hour, pure Python, one dependency: granian.
|
|
24
|
+
- **Tested.** 100% line and branch coverage, `mypy --strict`, a real granian in the test suite.
|
|
25
|
+
|
|
39
26
|
```python
|
|
40
27
|
# app.py
|
|
41
28
|
import matchapi
|
|
@@ -91,6 +78,7 @@ Not Found
|
|
|
91
78
|
One import and one decorator; no route table, no schema classes, no dependency markers, no middleware stack. The
|
|
92
79
|
decorated `handle` is still the function it was: a test calls it with a request and compares the return value.
|
|
93
80
|
|
|
81
|
+
- [Performance](#performance)
|
|
94
82
|
- [Why](#why)
|
|
95
83
|
- [The model](#the-model)
|
|
96
84
|
- [One function](#one-function)
|
|
@@ -106,6 +94,29 @@ decorated `handle` is still the function it was: a test calls it with a request
|
|
|
106
94
|
- [Design notes](#design-notes)
|
|
107
95
|
- [Development](#development)
|
|
108
96
|
|
|
97
|
+
## Performance
|
|
98
|
+
|
|
99
|
+

|
|
100
|
+
|
|
101
|
+
The README's service, written the way each framework writes it, under the same granian, one worker, `oha` with
|
|
102
|
+
64 connections; the multiplier is how many times matchapi's requests per second:
|
|
103
|
+
|
|
104
|
+
| Requests/s, HTTP/1.1, higher is better | `GET /ping` | `GET /users/42` | `POST /users` |
|
|
105
|
+
|---|---:|---:|---:|
|
|
106
|
+
| bare RSGI, no framework (the ceiling) | 45,264 | 42,993 | 24,432 |
|
|
107
|
+
| **matchapi** | **45,049** | **43,055** | **23,439** |
|
|
108
|
+
| Emmett 2.8 (RSGI) | 38,733 (1.16×) | 30,135 (1.43×) | 17,574 (1.33×) |
|
|
109
|
+
| BlackSheep 2.6 | 39,000 (1.16×) | 33,794 (1.27×) | 13,878 (1.69×) |
|
|
110
|
+
| Starlette 1.7 | 32,612 (1.38×) | 30,206 (1.43×) | 13,071 (1.79×) |
|
|
111
|
+
| Litestar 2.24 | 26,005 (1.73×) | 23,974 (1.80×) | 11,878 (1.97×) |
|
|
112
|
+
| FastAPI 0.143 | 17,819 (2.53×) | 13,940 (3.09×) | 8,501 (2.76×) |
|
|
113
|
+
|
|
114
|
+
matchapi parses nothing the handler does not read: `route`, `query` and `headers` are computed on first use, an
|
|
115
|
+
HTTP/1 request without a body is not waited on, and the commonest return values go to granian without an
|
|
116
|
+
intermediate object. Over HTTP/2 every request still awaits its body, and there Emmett and BlackSheep are ahead on
|
|
117
|
+
`GET`. [bench/](bench/) has both protocols, the method and the command that reproduces the tables; one laptop,
|
|
118
|
+
Python 3.14, granian 2.8.4.
|
|
119
|
+
|
|
109
120
|
## Why
|
|
110
121
|
|
|
111
122
|
Every Python web framework, from Flask to FastAPI, is a router with a decorator in front of it and a growing set of
|
|
@@ -167,6 +178,11 @@ There is no scope or stream underneath: the body is read before the handler runs
|
|
|
167
178
|
`await`. `Request("POST", "/users", body=b"...")`
|
|
168
179
|
builds one by hand, which is how a test calls the handler; `query` takes the raw string or a dict.
|
|
169
180
|
|
|
181
|
+
Nothing is parsed that the handler does not read: `route`, `query` and `headers` are computed on first use, and
|
|
182
|
+
an HTTP/1 request without `content-length` or `transfer-encoding` has no body to wait for. On HTTP/1.1
|
|
183
|
+
[bench/](bench/) cannot tell matchapi from a bare RSGI application answering the same routes by hand, and puts
|
|
184
|
+
it ahead of Emmett, BlackSheep, Starlette, Litestar and FastAPI under the same granian.
|
|
185
|
+
|
|
170
186
|
### The return value is the response
|
|
171
187
|
|
|
172
188
|
| Return | Response |
|
|
@@ -316,7 +332,7 @@ cookies or sessions, no templates, no static files, no websockets, no streaming
|
|
|
316
332
|
Each of these is either a few lines in the handler or not matchapi's job: the server is granian and the only
|
|
317
333
|
interface is RSGI, so there is no middleware layer to plug into.
|
|
318
334
|
|
|
319
|
-
The line is deliberate. The framework fits in one file of about
|
|
335
|
+
The line is deliberate. The framework fits in one file of about 300 lines that reads in half an hour, and the
|
|
320
336
|
handler stays a function that works without a server.
|
|
321
337
|
|
|
322
338
|
## Design notes
|
|
@@ -324,7 +340,7 @@ handler stays a function that works without a server.
|
|
|
324
340
|
The decisions and the alternatives that were rejected are in [docs/design.md](docs/design.md): why the router
|
|
325
341
|
went instead of the signature magic, why a decorator rather than a command, why the body is read before the
|
|
326
342
|
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
|
|
343
|
+
glossary. [bench/](bench/) measures matchapi against a bare RSGI application and against other frameworks.
|
|
328
344
|
|
|
329
345
|
## Development
|
|
330
346
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# bench
|
|
2
|
+
|
|
3
|
+
matchapi against the ceiling and against other frameworks. Every server answers the README's routes under the
|
|
4
|
+
same granian, `oha` hits each one, and the tables are the result:
|
|
5
|
+
|
|
6
|
+
- **bare RSGI**: [bare.py](bare.py), an RSGI application with no framework that reads the body only for the
|
|
7
|
+
route that has one; the ceiling, what granian does alone.
|
|
8
|
+
- **bare RSGI reading every body**: [bare_body.py](bare_body.py), the same one awaiting the body of every
|
|
9
|
+
request, as matchapi must under HTTP/2 ([design.md](../docs/design.md), D3).
|
|
10
|
+
- **matchapi**: [service.py](service.py), the README's service, from this checkout.
|
|
11
|
+
- **matchapi 0.2.1**: the same file against the previous release from PyPI.
|
|
12
|
+
- **Emmett, BlackSheep, Starlette, Litestar, FastAPI**: [with_emmett.py](with_emmett.py),
|
|
13
|
+
[with_blacksheep.py](with_blacksheep.py), [with_starlette.py](with_starlette.py),
|
|
14
|
+
[with_litestar.py](with_litestar.py), [with_fastapi.py](with_fastapi.py), the same routes written the way each
|
|
15
|
+
framework's documentation writes them. Emmett speaks RSGI like matchapi; the others are ASGI applications
|
|
16
|
+
under `granian --interface asgi`.
|
|
17
|
+
|
|
18
|
+
Everything outside this checkout is pinned and installed into a throwaway environment by `uv`, with the granian
|
|
19
|
+
of this checkout. One worker, granian's defaults otherwise, so every framework gets the same event loop.
|
|
20
|
+
|
|
21
|
+
```console
|
|
22
|
+
$ uv run bench/run.py # HTTP/1.1, 10 s per server and request, 64 connections
|
|
23
|
+
$ uv run bench/run.py --http2 # the same over HTTP/2 (h2c)
|
|
24
|
+
$ uv run bench/run.py -z 3s -c 16 # a quick look
|
|
25
|
+
$ uv run bench/chart.py # the README's chart, docs/bench.svg, from the HTTP/1.1 table below
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Needs [oha](https://github.com/hatoo/oha) and `uv` on the PATH. The bench is not part of CI: the numbers below
|
|
29
|
+
are one run on one laptop, and only the ratios between the rows mean anything. A run stops if any response has
|
|
30
|
+
a status other than the one the route answers with.
|
|
31
|
+
|
|
32
|
+
## Results
|
|
33
|
+
|
|
34
|
+
### HTTP/1.1
|
|
35
|
+
|
|
36
|
+
Python 3.14.6, Darwin arm64, granian 2.8.4, 2026-10-12; oha, HTTP/1.1, 64 connections, 10s per cell, one worker.
|
|
37
|
+
|
|
38
|
+
| Server | Request | Requests/s | p50 ms | p99 ms | Of the ceiling |
|
|
39
|
+
|---|---|---:|---:|---:|---:|
|
|
40
|
+
| bare RSGI | `GET /ping` | 45,264 | 1.39 | 2.13 | 100% |
|
|
41
|
+
| bare RSGI | `GET /users/42` | 42,993 | 1.48 | 2.10 | 100% |
|
|
42
|
+
| bare RSGI | `POST /users` | 24,432 | 2.59 | 3.48 | 100% |
|
|
43
|
+
| bare RSGI reading every body | `GET /ping` | 27,284 | 2.32 | 3.39 | 60% |
|
|
44
|
+
| bare RSGI reading every body | `GET /users/42` | 24,598 | 2.57 | 3.47 | 57% |
|
|
45
|
+
| bare RSGI reading every body | `POST /users` | 23,979 | 2.57 | 4.64 | 98% |
|
|
46
|
+
| matchapi | `GET /ping` | 45,049 | 1.40 | 2.12 | 100% |
|
|
47
|
+
| matchapi | `GET /users/42` | 43,055 | 1.46 | 2.37 | 100% |
|
|
48
|
+
| matchapi | `POST /users` | 23,439 | 2.67 | 4.18 | 96% |
|
|
49
|
+
| matchapi 0.2.1 | `GET /ping` | 24,006 | 2.62 | 3.95 | 53% |
|
|
50
|
+
| matchapi 0.2.1 | `GET /users/42` | 23,293 | 2.72 | 3.68 | 54% |
|
|
51
|
+
| matchapi 0.2.1 | `POST /users` | 20,808 | 3.03 | 4.81 | 85% |
|
|
52
|
+
| Emmett 2.8 (RSGI) | `GET /ping` | 38,733 | 1.64 | 2.41 | 86% |
|
|
53
|
+
| Emmett 2.8 (RSGI) | `GET /users/42` | 30,135 | 2.06 | 3.96 | 70% |
|
|
54
|
+
| Emmett 2.8 (RSGI) | `POST /users` | 17,574 | 3.55 | 5.19 | 72% |
|
|
55
|
+
| BlackSheep 2.6 (ASGI) | `GET /ping` | 39,000 | 1.63 | 2.39 | 86% |
|
|
56
|
+
| BlackSheep 2.6 (ASGI) | `GET /users/42` | 33,794 | 1.88 | 2.79 | 79% |
|
|
57
|
+
| BlackSheep 2.6 (ASGI) | `POST /users` | 13,878 | 4.43 | 6.88 | 57% |
|
|
58
|
+
| Starlette 1.7 (ASGI) | `GET /ping` | 32,612 | 1.94 | 3.06 | 72% |
|
|
59
|
+
| Starlette 1.7 (ASGI) | `GET /users/42` | 30,206 | 2.10 | 3.06 | 70% |
|
|
60
|
+
| Starlette 1.7 (ASGI) | `POST /users` | 13,071 | 4.69 | 10.83 | 53% |
|
|
61
|
+
| Litestar 2.24 (ASGI) | `GET /ping` | 26,005 | 2.44 | 3.86 | 57% |
|
|
62
|
+
| Litestar 2.24 (ASGI) | `GET /users/42` | 23,974 | 2.65 | 3.88 | 56% |
|
|
63
|
+
| Litestar 2.24 (ASGI) | `POST /users` | 11,878 | 5.31 | 7.10 | 49% |
|
|
64
|
+
| FastAPI 0.143 (ASGI) | `GET /ping` | 17,819 | 3.55 | 4.65 | 39% |
|
|
65
|
+
| FastAPI 0.143 (ASGI) | `GET /users/42` | 13,940 | 4.51 | 5.71 | 32% |
|
|
66
|
+
| FastAPI 0.143 (ASGI) | `POST /users` | 8,501 | 7.47 | 9.92 | 35% |
|
|
67
|
+
|
|
68
|
+
### HTTP/2
|
|
69
|
+
|
|
70
|
+
Python 3.14.6, Darwin arm64, granian 2.8.4, 2026-10-12; oha, HTTP/2, 64 connections, 10s per cell, one worker.
|
|
71
|
+
|
|
72
|
+
| Server | Request | Requests/s | p50 ms | p99 ms | Of the ceiling |
|
|
73
|
+
|---|---|---:|---:|---:|---:|
|
|
74
|
+
| bare RSGI | `GET /ping` | 41,217 | 1.53 | 2.25 | 100% |
|
|
75
|
+
| bare RSGI | `GET /users/42` | 38,342 | 1.65 | 2.40 | 100% |
|
|
76
|
+
| bare RSGI | `POST /users` | 22,500 | 2.75 | 5.26 | 100% |
|
|
77
|
+
| bare RSGI reading every body | `GET /ping` | 26,675 | 2.36 | 3.36 | 65% |
|
|
78
|
+
| bare RSGI reading every body | `GET /users/42` | 25,586 | 2.47 | 3.44 | 67% |
|
|
79
|
+
| bare RSGI reading every body | `POST /users` | 23,221 | 2.73 | 3.56 | 103% |
|
|
80
|
+
| matchapi | `GET /ping` | 25,344 | 2.48 | 3.21 | 61% |
|
|
81
|
+
| matchapi | `GET /users/42` | 25,044 | 2.53 | 3.42 | 65% |
|
|
82
|
+
| matchapi | `POST /users` | 23,107 | 2.73 | 3.93 | 103% |
|
|
83
|
+
| matchapi 0.2.1 | `GET /ping` | 24,296 | 2.61 | 3.55 | 59% |
|
|
84
|
+
| matchapi 0.2.1 | `GET /users/42` | 23,012 | 2.75 | 3.71 | 60% |
|
|
85
|
+
| matchapi 0.2.1 | `POST /users` | 19,922 | 3.19 | 4.01 | 89% |
|
|
86
|
+
| Emmett 2.8 (RSGI) | `GET /ping` | 34,240 | 1.86 | 2.45 | 83% |
|
|
87
|
+
| Emmett 2.8 (RSGI) | `GET /users/42` | 26,988 | 2.37 | 2.97 | 70% |
|
|
88
|
+
| Emmett 2.8 (RSGI) | `POST /users` | 15,489 | 4.06 | 5.63 | 69% |
|
|
89
|
+
| BlackSheep 2.6 (ASGI) | `GET /ping` | 34,698 | 1.84 | 2.45 | 84% |
|
|
90
|
+
| BlackSheep 2.6 (ASGI) | `GET /users/42` | 30,883 | 2.07 | 2.71 | 81% |
|
|
91
|
+
| BlackSheep 2.6 (ASGI) | `POST /users` | 13,574 | 4.69 | 5.57 | 60% |
|
|
92
|
+
| Starlette 1.7 (ASGI) | `GET /ping` | 29,971 | 2.13 | 2.78 | 73% |
|
|
93
|
+
| Starlette 1.7 (ASGI) | `GET /users/42` | 26,462 | 2.36 | 4.66 | 69% |
|
|
94
|
+
| Starlette 1.7 (ASGI) | `POST /users` | 12,686 | 5.01 | 6.85 | 56% |
|
|
95
|
+
| Litestar 2.24 (ASGI) | `GET /ping` | 23,102 | 2.77 | 3.48 | 56% |
|
|
96
|
+
| Litestar 2.24 (ASGI) | `GET /users/42` | 20,711 | 3.05 | 3.90 | 54% |
|
|
97
|
+
| Litestar 2.24 (ASGI) | `POST /users` | 10,941 | 5.82 | 6.79 | 49% |
|
|
98
|
+
| FastAPI 0.143 (ASGI) | `GET /ping` | 16,120 | 3.97 | 4.68 | 39% |
|
|
99
|
+
| FastAPI 0.143 (ASGI) | `GET /users/42` | 12,359 | 5.16 | 5.84 | 32% |
|
|
100
|
+
| FastAPI 0.143 (ASGI) | `POST /users` | 7,930 | 7.96 | 13.59 | 35% |
|
|
101
|
+
|
|
102
|
+
### What the rows say
|
|
103
|
+
|
|
104
|
+
- On HTTP/1.1 matchapi is the ceiling: the `GET` rows are within noise of the bare application, and the `POST`
|
|
105
|
+
row, which must await and parse a body, is within a few percent of it. Nothing in the framework is left to
|
|
106
|
+
optimise there ([design.md](../docs/design.md), D8 and D9).
|
|
107
|
+
- On HTTP/1.1 matchapi serves more requests than every framework measured: `GET /users/42` 1.27 times
|
|
108
|
+
BlackSheep and 1.43 times Emmett, `POST /users` 1.33 times Emmett, and 2.5 to 3.1 times FastAPI.
|
|
109
|
+
- Against 0.2.1, the release before it, `GET` is 1.9 times faster on HTTP/1.1: 0.2.1 awaited the body of every
|
|
110
|
+
request, and that await alone costs about forty percent of the ceiling (the "reading every body" rows).
|
|
111
|
+
- On HTTP/2 every request still awaits the body (D3), so the `GET` rows sit where "bare RSGI reading every
|
|
112
|
+
body" sits, about two thirds of the ceiling, behind Emmett, BlackSheep and Starlette, which read the body
|
|
113
|
+
only when a handler asks for it. `POST` on HTTP/2 is still ahead of every framework. The open question in
|
|
114
|
+
design.md describes the ways out; both change the contract.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""A bare RSGI application with the README's routes: the ceiling, what granian does with no framework at all.
|
|
2
|
+
|
|
3
|
+
It reads the request body only for the route that has one, as a hand-written application would.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
TEXT = [("content-type", "text/plain; charset=utf-8")]
|
|
9
|
+
JSON = [("content-type", "application/json")]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def answer(scope, protocol, body):
|
|
13
|
+
if scope.method == "GET" and scope.path == "/ping":
|
|
14
|
+
protocol.response_str(200, TEXT, "pong")
|
|
15
|
+
elif scope.method == "GET" and scope.path.startswith("/users/"):
|
|
16
|
+
user = {"id": int(scope.path[7:]), "name": "ann"}
|
|
17
|
+
protocol.response_str(200, JSON, json.dumps(user, separators=(",", ":")))
|
|
18
|
+
elif scope.method == "POST" and scope.path == "/users":
|
|
19
|
+
user = {"id": 7, "name": json.loads(body)["name"]}
|
|
20
|
+
protocol.response_str(201, JSON, json.dumps(user, separators=(",", ":")))
|
|
21
|
+
else:
|
|
22
|
+
protocol.response_str(404, TEXT, "Not Found")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def app(scope, protocol):
|
|
26
|
+
body = await protocol() if scope.method == "POST" else b""
|
|
27
|
+
await answer(scope, protocol, body)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""The README's chart: the HTTP/1.1 table of bench/README.md as docs/bench.svg.
|
|
2
|
+
|
|
3
|
+
$ uv run bench/chart.py
|
|
4
|
+
|
|
5
|
+
matchapi in the accent, the other frameworks in gray, the bare RSGI application as the line every bar is
|
|
6
|
+
measured against; light and dark from the viewer's color scheme.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from html import escape
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
14
|
+
FRAMEWORKS = ["matchapi", "Emmett", "BlackSheep", "Starlette", "Litestar", "FastAPI"]
|
|
15
|
+
PANELS = ["GET /users/42", "POST /users"]
|
|
16
|
+
|
|
17
|
+
WIDTH, PANEL, GAP, LABEL, ROW, BAR, TOP = 880, 420, 40, 96, 30, 18, 78
|
|
18
|
+
SCALE = 50_000 # one x-scale for both panels
|
|
19
|
+
FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
|
|
20
|
+
SOURCE = "bench/README.md · Python 3.14, granian 2.8.4, oha with 64 connections · higher is better"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def table() -> dict[tuple[str, str], float]:
|
|
24
|
+
"""`(server, request) -> requests/s` from the HTTP/1.1 section."""
|
|
25
|
+
text = (ROOT / "bench" / "README.md").read_text().split("### HTTP/1.1", 1)[1].split("### HTTP/2", 1)[0]
|
|
26
|
+
rows = re.findall(r"^\| ([^|]+) \| `([^`]+)` \| ([\d,]+) \|", text, re.MULTILINE)
|
|
27
|
+
return {(server.strip(), request): float(rps.replace(",", "")) for server, request, rps in rows}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def name(server: str) -> str:
|
|
31
|
+
"""`Emmett 2.8 (RSGI)` is `Emmett`; `matchapi 0.2.1`, the previous release, is not on the chart."""
|
|
32
|
+
if server in ("bare RSGI", "matchapi"):
|
|
33
|
+
return server
|
|
34
|
+
return "" if server.startswith("matchapi ") else server.split(" ")[0]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bar(x: float, y: float, width: float) -> str:
|
|
38
|
+
"""Square at the baseline, 4px round at the data end."""
|
|
39
|
+
r = min(4.0, width / 2)
|
|
40
|
+
return (
|
|
41
|
+
f"M{x:.1f},{y:.1f}h{width - r:.1f}a{r},{r} 0 0 1 {r},{r}v{BAR - 2 * r:.1f}"
|
|
42
|
+
f"a{r},{r} 0 0 1 {-r},{r}h{-(width - r):.1f}z"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def panel(data: dict[tuple[str, str], float], request: str, left: float) -> list[str]:
|
|
47
|
+
plot, inner = left + LABEL, PANEL - LABEL - 64
|
|
48
|
+
servers = {name(server): rps for (server, req), rps in data.items() if req == request}
|
|
49
|
+
ceiling = servers["bare RSGI"]
|
|
50
|
+
rows = sorted((rps, framework) for framework, rps in servers.items() if framework in FRAMEWORKS)[::-1]
|
|
51
|
+
height = len(rows) * ROW
|
|
52
|
+
out = [f'<text class="head" x="{left}" y="{TOP - 30}">{escape(request)}</text>']
|
|
53
|
+
for tick in range(0, SCALE + 1, 10_000):
|
|
54
|
+
x = plot + inner * tick / SCALE
|
|
55
|
+
out.append(f'<line class="grid" x1="{x:.1f}" x2="{x:.1f}" y1="{TOP - 6}" y2="{TOP + height}"/>')
|
|
56
|
+
out.append(f'<text class="tick" x="{x:.1f}" y="{TOP + height + 16}">{tick // 1000}k</text>')
|
|
57
|
+
for i, (rps, framework) in enumerate(rows):
|
|
58
|
+
y = TOP + i * ROW + (ROW - BAR) / 2
|
|
59
|
+
width = inner * rps / SCALE
|
|
60
|
+
kind = "accent" if framework == "matchapi" else "other"
|
|
61
|
+
out.append(f'<text class="name {kind}" x="{plot - 10}" y="{y + BAR / 2 + 4.5}">{framework}</text>')
|
|
62
|
+
out.append(
|
|
63
|
+
f'<path class="bar {kind}" d="{bar(plot, y, width)}"><title>{framework}: {rps:,.0f} req/s</title></path>'
|
|
64
|
+
)
|
|
65
|
+
out.append(f'<text class="value {kind}" x="{plot + width + 6:.1f}" y="{y + BAR / 2 + 4.5}">{rps:,.0f}</text>')
|
|
66
|
+
x = plot + inner * ceiling / SCALE
|
|
67
|
+
out.append(f'<line class="ceiling" x1="{x:.1f}" x2="{x:.1f}" y1="{TOP - 12}" y2="{TOP + height}"/>')
|
|
68
|
+
out.append(f'<text class="mark" x="{x:.1f}" y="{TOP - 16}">bare RSGI {ceiling:,.0f}</text>')
|
|
69
|
+
out.append(f'<line class="axis" x1="{plot}" x2="{plot}" y1="{TOP - 6}" y2="{TOP + height}"/>')
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main() -> None:
|
|
74
|
+
data = table()
|
|
75
|
+
height = TOP + len(FRAMEWORKS) * ROW + 62
|
|
76
|
+
body = [line for i, request in enumerate(PANELS) for line in panel(data, request, i * (PANEL + GAP))]
|
|
77
|
+
get, post = data["matchapi", "GET /users/42"], data["matchapi", "POST /users"]
|
|
78
|
+
svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{height}"
|
|
79
|
+
viewBox="-10 0 {WIDTH} {height}" role="img" aria-labelledby="t d" font-family="{FONT}">
|
|
80
|
+
<title id="t">Requests per second over HTTP/1.1, one granian worker</title>
|
|
81
|
+
<desc id="d">matchapi serves {get:,.0f} GET and {post:,.0f} POST requests per second, level with a bare RSGI
|
|
82
|
+
application and ahead of Emmett, BlackSheep, Starlette, Litestar and FastAPI under the same granian.</desc>
|
|
83
|
+
<style>
|
|
84
|
+
svg {{ --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781; --grid: #e1e0d9;
|
|
85
|
+
--axis: #c3c2b7; --accent: #2a78d6; --other: #b5b3ab; }}
|
|
86
|
+
@media (prefers-color-scheme: dark) {{
|
|
87
|
+
svg {{ --surface: #1a1a19; --ink: #f0efec; --ink-2: #c3c2b7; --muted: #898781; --grid: #2c2c2a;
|
|
88
|
+
--axis: #383835; --accent: #3987e5; --other: #5e5d58; }}
|
|
89
|
+
}}
|
|
90
|
+
.bg {{ fill: var(--surface); }}
|
|
91
|
+
.title {{ fill: var(--ink); font-size: 15px; font-weight: 600; }}
|
|
92
|
+
.sub {{ fill: var(--muted); font-size: 12px; }}
|
|
93
|
+
.head {{ fill: var(--ink); font-size: 13px; font-weight: 600; font-family: ui-monospace, Menlo, monospace; }}
|
|
94
|
+
.grid {{ stroke: var(--grid); stroke-width: 1; }}
|
|
95
|
+
.axis {{ stroke: var(--axis); stroke-width: 1; }}
|
|
96
|
+
.tick {{ fill: var(--muted); font-size: 11px; text-anchor: middle; }}
|
|
97
|
+
.name {{ fill: var(--ink-2); font-size: 12.5px; text-anchor: end; }}
|
|
98
|
+
.name.accent, .value.accent {{ fill: var(--ink); font-weight: 600; }}
|
|
99
|
+
.value {{ fill: var(--ink-2); font-size: 12px; }}
|
|
100
|
+
.bar.accent {{ fill: var(--accent); }}
|
|
101
|
+
.bar.other {{ fill: var(--other); }}
|
|
102
|
+
.ceiling {{ stroke: var(--ink-2); stroke-width: 1.5; }}
|
|
103
|
+
.mark {{ fill: var(--ink-2); font-size: 11px; text-anchor: end; }}
|
|
104
|
+
</style>
|
|
105
|
+
<rect class="bg" x="-10" width="{WIDTH}" height="{height}" rx="8"/>
|
|
106
|
+
<text class="title" x="0" y="22">Requests per second, HTTP/1.1, one granian worker</text>
|
|
107
|
+
<g>
|
|
108
|
+
{chr(10).join(body)}
|
|
109
|
+
</g>
|
|
110
|
+
<text class="sub" x="0" y="{height - 12}">{SOURCE}</text>
|
|
111
|
+
</svg>
|
|
112
|
+
"""
|
|
113
|
+
(ROOT / "docs" / "bench.svg").write_text(svg)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|