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.
Files changed (37) hide show
  1. gromon_backend-0.3.0/LICENSE +21 -0
  2. gromon_backend-0.3.0/MANIFEST.in +14 -0
  3. gromon_backend-0.3.0/PKG-INFO +432 -0
  4. gromon_backend-0.3.0/README.md +404 -0
  5. gromon_backend-0.3.0/pyproject.toml +120 -0
  6. gromon_backend-0.3.0/setup.cfg +4 -0
  7. gromon_backend-0.3.0/src/gromon_backend/__init__.py +30 -0
  8. gromon_backend-0.3.0/src/gromon_backend/_converters.py +88 -0
  9. gromon_backend-0.3.0/src/gromon_backend/_group.py +119 -0
  10. gromon_backend-0.3.0/src/gromon_backend/_handler.py +117 -0
  11. gromon_backend-0.3.0/src/gromon_backend/_methods.py +42 -0
  12. gromon_backend-0.3.0/src/gromon_backend/_patterns.py +193 -0
  13. gromon_backend-0.3.0/src/gromon_backend/_result.py +81 -0
  14. gromon_backend-0.3.0/src/gromon_backend/_route.py +57 -0
  15. gromon_backend-0.3.0/src/gromon_backend/_router.py +450 -0
  16. gromon_backend-0.3.0/src/gromon_backend/_tree.py +318 -0
  17. gromon_backend-0.3.0/src/gromon_backend/errors.py +100 -0
  18. gromon_backend-0.3.0/src/gromon_backend/py.typed +0 -0
  19. gromon_backend-0.3.0/src/gromon_backend.egg-info/PKG-INFO +432 -0
  20. gromon_backend-0.3.0/src/gromon_backend.egg-info/SOURCES.txt +35 -0
  21. gromon_backend-0.3.0/src/gromon_backend.egg-info/dependency_links.txt +1 -0
  22. gromon_backend-0.3.0/src/gromon_backend.egg-info/requires.txt +7 -0
  23. gromon_backend-0.3.0/src/gromon_backend.egg-info/top_level.txt +1 -0
  24. gromon_backend-0.3.0/tests/__init__.py +5 -0
  25. gromon_backend-0.3.0/tests/benchmarks/__init__.py +1 -0
  26. gromon_backend-0.3.0/tests/benchmarks/test_lookup_benchmarks.py +292 -0
  27. gromon_backend-0.3.0/tests/helpers.py +28 -0
  28. gromon_backend-0.3.0/tests/test_converters.py +248 -0
  29. gromon_backend-0.3.0/tests/test_errors.py +166 -0
  30. gromon_backend-0.3.0/tests/test_groups.py +313 -0
  31. gromon_backend-0.3.0/tests/test_matching.py +298 -0
  32. gromon_backend-0.3.0/tests/test_mounting.py +384 -0
  33. gromon_backend-0.3.0/tests/test_names.py +364 -0
  34. gromon_backend-0.3.0/tests/test_package.py +53 -0
  35. gromon_backend-0.3.0/tests/test_precedence.py +233 -0
  36. gromon_backend-0.3.0/tests/test_registration.py +148 -0
  37. 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).