gromon-backend 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.
- gromon_backend-0.3.0/LICENSE +21 -0
- gromon_backend-0.3.0/MANIFEST.in +14 -0
- gromon_backend-0.3.0/PKG-INFO +432 -0
- gromon_backend-0.3.0/README.md +404 -0
- gromon_backend-0.3.0/pyproject.toml +120 -0
- gromon_backend-0.3.0/setup.cfg +4 -0
- gromon_backend-0.3.0/src/gromon_backend/__init__.py +30 -0
- gromon_backend-0.3.0/src/gromon_backend/_converters.py +88 -0
- gromon_backend-0.3.0/src/gromon_backend/_group.py +119 -0
- gromon_backend-0.3.0/src/gromon_backend/_handler.py +117 -0
- gromon_backend-0.3.0/src/gromon_backend/_methods.py +42 -0
- gromon_backend-0.3.0/src/gromon_backend/_patterns.py +193 -0
- gromon_backend-0.3.0/src/gromon_backend/_result.py +81 -0
- gromon_backend-0.3.0/src/gromon_backend/_route.py +57 -0
- gromon_backend-0.3.0/src/gromon_backend/_router.py +450 -0
- gromon_backend-0.3.0/src/gromon_backend/_tree.py +318 -0
- gromon_backend-0.3.0/src/gromon_backend/errors.py +100 -0
- gromon_backend-0.3.0/src/gromon_backend/py.typed +0 -0
- gromon_backend-0.3.0/src/gromon_backend.egg-info/PKG-INFO +432 -0
- gromon_backend-0.3.0/src/gromon_backend.egg-info/SOURCES.txt +35 -0
- gromon_backend-0.3.0/src/gromon_backend.egg-info/dependency_links.txt +1 -0
- gromon_backend-0.3.0/src/gromon_backend.egg-info/requires.txt +7 -0
- gromon_backend-0.3.0/src/gromon_backend.egg-info/top_level.txt +1 -0
- gromon_backend-0.3.0/tests/__init__.py +5 -0
- gromon_backend-0.3.0/tests/benchmarks/__init__.py +1 -0
- gromon_backend-0.3.0/tests/benchmarks/test_lookup_benchmarks.py +292 -0
- gromon_backend-0.3.0/tests/helpers.py +28 -0
- gromon_backend-0.3.0/tests/test_converters.py +248 -0
- gromon_backend-0.3.0/tests/test_errors.py +166 -0
- gromon_backend-0.3.0/tests/test_groups.py +313 -0
- gromon_backend-0.3.0/tests/test_matching.py +298 -0
- gromon_backend-0.3.0/tests/test_mounting.py +384 -0
- gromon_backend-0.3.0/tests/test_names.py +364 -0
- gromon_backend-0.3.0/tests/test_package.py +53 -0
- gromon_backend-0.3.0/tests/test_precedence.py +233 -0
- gromon_backend-0.3.0/tests/test_registration.py +148 -0
- gromon_backend-0.3.0/tests/test_scale.py +139 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gromon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# The sdist has to be able to build and test itself, so the whole test tree
|
|
2
|
+
# ships: setuptools' default discovery picks up tests/test_*.py and silently
|
|
3
|
+
# leaves behind the package marker, the shared helpers, and the subpackage.
|
|
4
|
+
include LICENSE
|
|
5
|
+
include README.md
|
|
6
|
+
include MANIFEST.in
|
|
7
|
+
include pyproject.toml
|
|
8
|
+
recursive-include tests *.py
|
|
9
|
+
|
|
10
|
+
# Tooling caches never belong in a distribution.
|
|
11
|
+
prune .mypy_cache
|
|
12
|
+
prune .pytest_cache
|
|
13
|
+
prune .ruff_cache
|
|
14
|
+
global-exclude *.py[cod] __pycache__
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gromon-backend
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A small, predictable HTTP routing engine for Python.
|
|
5
|
+
Author: Gromon
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: http,routing,router,url,routing-engine
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
23
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
24
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
25
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
26
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# Gromon
|
|
30
|
+
|
|
31
|
+
**Learn the fundamentals of Python, then build serious software.**
|
|
32
|
+
|
|
33
|
+
Gromon is a routing engine. One job, done properly:
|
|
34
|
+
|
|
35
|
+
> Map an incoming HTTP request — a method and a path — to the correct handler,
|
|
36
|
+
> and extract the parameters that handler needs.
|
|
37
|
+
|
|
38
|
+
It is not a web framework. There is no server, no template engine, no ORM, no
|
|
39
|
+
auth. Those belong to other Gromon projects. This repository is the foundation
|
|
40
|
+
they will all sit on, so it has to be small enough to understand in an
|
|
41
|
+
afternoon and strong enough to route traffic for years.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from gromon_backend import Router
|
|
45
|
+
|
|
46
|
+
router = Router()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def home():
|
|
50
|
+
return "Hello Gromon"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
router.get("/", home)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That is the whole idea. The rest of this document is detail.
|
|
57
|
+
|
|
58
|
+
## Design principles
|
|
59
|
+
|
|
60
|
+
| Principle | What it means here |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| Simple on the surface | `router.get(path, handler)` is the whole tutorial |
|
|
63
|
+
| Explicit structure | No nested callbacks, no decorators, no magic |
|
|
64
|
+
| No silent shadowing | A duplicate route raises. A name collision raises. Nothing is quietly overwritten. |
|
|
65
|
+
| Own the fundamentals | The trie, the converters and the precedence rules are ours, written to be read |
|
|
66
|
+
| Zero dependencies | Python standard library only |
|
|
67
|
+
|
|
68
|
+
## Registering routes
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
router.get("/", home)
|
|
72
|
+
router.get("/users", list_users)
|
|
73
|
+
router.post("/users", create_user)
|
|
74
|
+
router.put("/users/<int:id>", replace_user)
|
|
75
|
+
router.patch("/users/<int:id>", update_user)
|
|
76
|
+
router.delete("/users/<int:id>", delete_user)
|
|
77
|
+
router.head("/users", head_users)
|
|
78
|
+
router.options("/users", options_users)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Several methods on one path:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
router.route("/users", methods=["GET", "POST"], handler=users)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`route()` returns a single `Route` when you give it one method, and a tuple of
|
|
88
|
+
routes in canonical order when you give it several.
|
|
89
|
+
|
|
90
|
+
A trailing slash is insignificant: `/users` and `/users/` are one route, and
|
|
91
|
+
registering both is a `RouteConflictError`. The root path `/` is its own route.
|
|
92
|
+
An empty *interior* segment is not collapsed — `/users//5` matches nothing,
|
|
93
|
+
because a client's double slash is a bug worth surfacing, not hide.
|
|
94
|
+
|
|
95
|
+
## Groups
|
|
96
|
+
|
|
97
|
+
A group is a prefix you register through. It is a way of *writing* a path, not a
|
|
98
|
+
second kind of route, so grouping cannot change how anything matches:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
api = router.group("/api")
|
|
102
|
+
v1 = api.group("/v1")
|
|
103
|
+
|
|
104
|
+
v1.get("/users", list_users) # -> /api/v1/users
|
|
105
|
+
v1.get("/users/<int:id>", show_user) # -> /api/v1/users/<int:id>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Nesting is unlimited and there is no per-group lookup cost. A group offers the
|
|
109
|
+
same verbs as a router, plus `routes()` for the routes registered through it and
|
|
110
|
+
`url()` for building one of them.
|
|
111
|
+
|
|
112
|
+
A prefix may itself carry parameters. Every route in the group then declares
|
|
113
|
+
them, and a handler that does not accept them is refused at registration:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
org = router.group("/orgs/<int:org_id>")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def show_user(org_id: int, user_id: int) -> str: ...
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
org.get("/users/<int:user_id>", show_user) # /orgs/<org_id>/users/<user_id>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Mounting
|
|
126
|
+
|
|
127
|
+
`mount()` copies another router's routes into this one, under a prefix:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
users = Router()
|
|
131
|
+
users.get("/", list_users)
|
|
132
|
+
users.get("/<int:id>", show_user)
|
|
133
|
+
|
|
134
|
+
router.mount("/users", users) # -> /users and /users/<int:id>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Routes are copied at the moment `mount()` is called, which keeps one flat trie:
|
|
138
|
+
a mounted URL costs exactly what an ordinary one costs, and 404 and 405 answers
|
|
139
|
+
stay uniform. The trade is snapshot semantics — routes added to `users`
|
|
140
|
+
afterwards are not picked up, so build the child router first and mount it
|
|
141
|
+
last. Both routers keep working independently afterwards.
|
|
142
|
+
|
|
143
|
+
Mounting is validated as a whole before anything is committed, so a conflict on
|
|
144
|
+
the last route leaves the parent untouched.
|
|
145
|
+
|
|
146
|
+
## Parameters
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
router.get("/users/<name>", show) # str, the default
|
|
150
|
+
router.get("/users/<int:id>", show) # 42
|
|
151
|
+
router.get("/ratio/<float:value>", show) # 4.5
|
|
152
|
+
router.get("/jobs/<uuid:id>", show) # UUID(...)
|
|
153
|
+
router.get("/files/<path:name>", show) # "a/b/report.pdf"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Converter | Accepts | Produces |
|
|
157
|
+
| --- | --- | --- |
|
|
158
|
+
| `str` *(default)* | any non-empty segment | `str` |
|
|
159
|
+
| `int` | `-?[0-9]+` | `int` |
|
|
160
|
+
| `float` | `-?[0-9]+(\.[0-9]+)?` | `float` |
|
|
161
|
+
| `uuid` | canonical 8-4-4-4-12 hex form | `uuid.UUID` |
|
|
162
|
+
| `path` | the rest of the path, slashes included | `str` |
|
|
163
|
+
|
|
164
|
+
Parameters are passed to the handler by name, and the extracted values are
|
|
165
|
+
available separately from the route itself:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
result = router.resolve("GET", "/users/42")
|
|
169
|
+
|
|
170
|
+
result.route.path # '/users/<int:id>'
|
|
171
|
+
result.method # 'GET'
|
|
172
|
+
result.handler # the function you registered
|
|
173
|
+
result.params # {'id': 42} (mappingproxy, read-only)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Converters validate with explicit patterns rather than calling `int()` or
|
|
177
|
+
`float()` directly, because those accept `" 5 "` and `"+5"`. A URL that does not
|
|
178
|
+
match its converter is a 404, not a surprise. `path` is greedy, may contain
|
|
179
|
+
slashes, and must therefore be the last segment of a pattern.
|
|
180
|
+
|
|
181
|
+
At registration Gromon checks that your handler can actually receive the
|
|
182
|
+
parameters the route declares:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
router.get("/users/<id>", home) # HandlerSignatureError: home() cannot accept 'id'
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Matching
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
result = router.resolve("GET", "/users/42")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`resolve()` returns one of two things and never raises for a request that finds
|
|
195
|
+
nothing:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from gromon_backend import Match, NoMatch
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- **`Match`** — `route`, `method`, `handler`, `params`.
|
|
202
|
+
- **`NoMatch`** — `status_code` (404 or 405), `allowed` (the methods this path
|
|
203
|
+
*does* support), and `allow` ready to be joined into an `Allow` header.
|
|
204
|
+
|
|
205
|
+
The router **never calls your handler.** It answers "which handler, with which
|
|
206
|
+
parameters", which is what keeps it usable by both a synchronous and an
|
|
207
|
+
asynchronous runtime.
|
|
208
|
+
|
|
209
|
+
### 404 versus 405
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
router.resolve("GET", "/users") # Match
|
|
213
|
+
router.resolve("DELETE", "/nope") # NoMatch, status_code == 404
|
|
214
|
+
router.resolve("DELETE", "/users") # NoMatch, status_code == 405, allowed == ('GET',)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
A path that exists but not for that method is 405, and the router hands you the
|
|
218
|
+
allowed set because it already knows it. Building the response is the future
|
|
219
|
+
runtime's job, not this library's.
|
|
220
|
+
|
|
221
|
+
### Precedence
|
|
222
|
+
|
|
223
|
+
When more than one route could match, the winner is always decided the same way:
|
|
224
|
+
|
|
225
|
+
1. **Static segments beat parameters.** `/users/me` wins over `/users/<id>`.
|
|
226
|
+
2. **Narrower converters beat wider ones**, in the fixed order
|
|
227
|
+
`int` → `float` → `uuid` → `str` → `path`. `/users/42` reaches
|
|
228
|
+
`/users/<int:id>` even if `/users/<str:name>` was registered first.
|
|
229
|
+
3. **Converter width beats registration order**, always.
|
|
230
|
+
4. The first *complete* path match owns the URL, including its methods — so a
|
|
231
|
+
405 reports the methods of the route that won the path.
|
|
232
|
+
|
|
233
|
+
When a narrow branch matches a segment but dead-ends on the rest of the path,
|
|
234
|
+
the search unwinds and tries the next branch. That is why both of these can
|
|
235
|
+
coexist and both work:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
router.get("/items/<int:id>", show_item) # /items/42
|
|
239
|
+
router.get("/items/<str:slug>/comments", c) # /items/42/comments <- unwinds
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Two parameters of the *same* width at the same position are refused at compile
|
|
243
|
+
time, because which one should win would be arbitrary:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
router.get("/x/<int:id>", a)
|
|
247
|
+
router.get("/x/<int:other>", b) # RouteConflictError; reuse one name to share the branch
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Inspecting routes
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
for route in router.routes():
|
|
254
|
+
print(route.method, route.path, route.endpoint_name)
|
|
255
|
+
|
|
256
|
+
len(router) # how many routes are registered
|
|
257
|
+
router.compile() # build the routing table now, surfacing conflicts at startup
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Performance
|
|
261
|
+
|
|
262
|
+
Lookup cost follows the *depth of the path*, not the number of routes. Routes
|
|
263
|
+
are compiled once into a segment trie, so a four-segment request does about
|
|
264
|
+
four hash lookups whether the application has ten routes or fifty thousand.
|
|
265
|
+
100x the routes costs essentially nothing extra per request.
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
python -m pytest tests/benchmarks -q -s # prints the table
|
|
269
|
+
python -m pytest -m "not slow" # skip the timing tests
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Named routes
|
|
273
|
+
|
|
274
|
+
A name is what lets a route be referenced from code instead of from a string
|
|
275
|
+
typed twice. It is validated when it is registered — non-empty, no surrounding
|
|
276
|
+
whitespace, and never reused — so a typo is a `RouteConflictError` at startup
|
|
277
|
+
rather than a 404 in production.
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
router.get("/users/<int:id>", show_user, name="users.show")
|
|
281
|
+
|
|
282
|
+
router.url("users.show", id=42) # '/users/42'
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`url()` converts each value back to text through the route's own converter, so a
|
|
286
|
+
value the route could never match is reported here instead of being handed to
|
|
287
|
+
you as a URL that 404s. `str` and `path` values are percent-encoded; a `path`
|
|
288
|
+
value keeps its slashes, because that is what makes it a path. A missing,
|
|
289
|
+
unexpected, or badly typed parameter is a `UrlBuildError`.
|
|
290
|
+
|
|
291
|
+
The route name is positional-only, so a route parameter called `name` can still
|
|
292
|
+
be passed by keyword:
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
router.get("/files/<path:name>", show_file, name="files.show")
|
|
296
|
+
router.url("files.show", name="a/b/report.pdf") # '/files/a/b/report.pdf'
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
A name describes a *path*, not a method, so a multi-method registration carries
|
|
300
|
+
one name:
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
router.route("/users", ["GET", "POST"], users, name="users.index")
|
|
304
|
+
|
|
305
|
+
router.url("users.index") # '/users'
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Names are per router, not per group. A name registered through a group or a
|
|
309
|
+
mount is reachable from the router that owns it, and the URL it builds is the
|
|
310
|
+
full effective path including every prefix:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
v1 = router.group("/api/v1")
|
|
314
|
+
v1.get("/users/<int:id>", show_user, name="users.show")
|
|
315
|
+
|
|
316
|
+
router.url("users.show", id=42) # '/api/v1/users/42'
|
|
317
|
+
v1.url("users.show", id=42) # the same string
|
|
318
|
+
router.names() # ('users.show',)
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
## Installation
|
|
322
|
+
```bash
|
|
323
|
+
pip install gromon-backend # from a package index
|
|
324
|
+
pip install -e ".[dev]" # from a checkout, with the dev tools
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
There is nothing else to configure. The routing engine has no runtime
|
|
328
|
+
dependencies and does not read the environment, so the same table behaves the
|
|
329
|
+
same in every process that builds it.
|
|
330
|
+
|
|
331
|
+
## Using it
|
|
332
|
+
|
|
333
|
+
The engine answers one question — *which handler, with which parameters* — and
|
|
334
|
+
never calls the handler itself. Wiring it to a server is the runtime's job, and
|
|
335
|
+
that is deliberately not this library's:
|
|
336
|
+
|
|
337
|
+
```python
|
|
338
|
+
from gromon_backend import Match, NoMatch, Router
|
|
339
|
+
|
|
340
|
+
router = Router()
|
|
341
|
+
router.get("/users/<int:id>", get_user, name="users.show")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def dispatch(method: str, raw_path: str):
|
|
345
|
+
"""The whole request-to-handler contract, in one function."""
|
|
346
|
+
result = router.resolve(method, raw_path)
|
|
347
|
+
|
|
348
|
+
if isinstance(result, Match):
|
|
349
|
+
return result.handler(**result.params) # await it if it is async
|
|
350
|
+
|
|
351
|
+
if result.status_code == 405:
|
|
352
|
+
# The path exists, just not for this method.
|
|
353
|
+
return error(405, allow=result.allow)
|
|
354
|
+
return error(404)
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`Match.params` is a read-only mapping, so a handler cannot corrupt the state of
|
|
358
|
+
the match it was given. `NoMatch.allowed` is already ordered and ready to be
|
|
359
|
+
joined into an `Allow` header.
|
|
360
|
+
|
|
361
|
+
### Threads and async
|
|
362
|
+
|
|
363
|
+
Registration and resolution never share mutable state, so a module-level
|
|
364
|
+
`Router` is safe to build before the server starts and read from every worker
|
|
365
|
+
thread afterwards with no lock. The compiled table is rebuilt into a fresh
|
|
366
|
+
object and rebound in one step, so a concurrent reader always sees a complete
|
|
367
|
+
table, never a half-built one. Because the router never awaits anything, the
|
|
368
|
+
same table serves a synchronous and an asynchronous runtime unchanged.
|
|
369
|
+
|
|
370
|
+
### Failing at startup instead of at request time
|
|
371
|
+
|
|
372
|
+
Two classes of mistake are caught while routes are being registered, not on the
|
|
373
|
+
first request that reaches them: a handler that cannot receive the parameters
|
|
374
|
+
its route declares, and a registration that would be ambiguous. Call
|
|
375
|
+
`router.compile()` once during startup to surface the second kind immediately.
|
|
376
|
+
|
|
377
|
+
## Errors
|
|
378
|
+
|
|
379
|
+
Every error raised by the library derives from `GromonError`, and routing
|
|
380
|
+
errors derive further from `RouteError`:
|
|
381
|
+
|
|
382
|
+
```python
|
|
383
|
+
from gromon_backend.errors import (
|
|
384
|
+
HandlerSignatureError, # handler cannot receive the route's parameters
|
|
385
|
+
InvalidConverterError, # unknown converter, or <path:...> used wrongly
|
|
386
|
+
InvalidMethodError, # unknown HTTP method
|
|
387
|
+
InvalidNameError, # malformed route name
|
|
388
|
+
InvalidPathError, # malformed path or route pattern
|
|
389
|
+
MountError, # a router cannot be mounted as asked
|
|
390
|
+
RouteConflictError, # duplicate or ambiguous registration
|
|
391
|
+
UrlBuildError, # a name cannot be turned back into a URL
|
|
392
|
+
)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
A request that matches nothing is *not* an error. It is reported as data,
|
|
396
|
+
through `NoMatch`, so the runtime can decide what to send.
|
|
397
|
+
|
|
398
|
+
## Project status
|
|
399
|
+
|
|
400
|
+
| Milestone | Scope | State |
|
|
401
|
+
| --- | --- | --- |
|
|
402
|
+
| 1 | Architecture + package foundation | done |
|
|
403
|
+
| 2 | Registration and matching core | done |
|
|
404
|
+
| 3 | HTTP methods, 404 / 405 behaviour | done in Milestone 2 |
|
|
405
|
+
| 4 | Path parameters and converters | done in Milestone 2 |
|
|
406
|
+
| 5 | Groups and nested groups | done |
|
|
407
|
+
| 6 | Router mounting | done |
|
|
408
|
+
| 7 | Named routes and reverse URL generation | done |
|
|
409
|
+
| 8 | Deeper conflict detection and inspection polish | pending |
|
|
410
|
+
| 9 | Extended benchmarks and optimisation | pending |
|
|
411
|
+
| 10 | Documentation and production hardening | pending |
|
|
412
|
+
|
|
413
|
+
`updates.md` (local only, never committed) holds the running design log for
|
|
414
|
+
every milestone, including the decisions and the measurements behind them.
|
|
415
|
+
|
|
416
|
+
## Development
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
pip install -e ".[dev]"
|
|
420
|
+
|
|
421
|
+
python -m pytest # tests
|
|
422
|
+
python -m ruff check . # lint
|
|
423
|
+
python -m ruff format . # format
|
|
424
|
+
python -m mypy # types (strict, targeting the oldest supported Python)
|
|
425
|
+
python -m build # sdist + wheel
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Requires Python 3.10 or newer. The routing engine has no runtime dependencies.
|
|
429
|
+
|
|
430
|
+
## License
|
|
431
|
+
|
|
432
|
+
MIT — see [LICENSE](LICENSE).
|