Gromon
Learn the fundamentals of Python, then build serious software.
Gromon is a routing engine. One job, done properly:
Map an incoming HTTP request — a method and a path — to the correct handler, and extract the parameters that handler needs.
It is not a web framework. There is no template engine, no ORM, no auth. Those belong to other Gromon projects. This repository is the foundation they will all sit on, so it has to be small enough to understand in an afternoon and strong enough to route traffic for years.
The server it ships with is a thin adapter over the standard library, so a beginner can run something real in seven lines while the engine underneath stays pure and portable.
from gromon_backend import Router, serve
router = Router()
router.get("/", "Hello Gromon")
serve(router)
That is the whole idea. The rest of this document is detail.
Design principles
| Principle | What it means here |
|---|---|
| Simple on the surface | router.get(path, handler) is the whole tutorial |
| Explicit structure | No nested callbacks, no decorators, no magic |
| No silent shadowing | A duplicate route raises. A name collision raises. Nothing is quietly overwritten. |
| Own the fundamentals | The trie, the converters and the precedence rules are ours, written to be read |
| Zero dependencies | Python standard library only |
Registering routes
router.get("/", home)
router.get("/users", list_users)
router.post("/users", create_user)
router.put("/users/<int:id>", replace_user)
router.patch("/users/<int:id>", update_user)
router.delete("/users/<int:id>", delete_user)
router.head("/users", head_users)
router.options("/users", options_users)
Several methods on one path:
router.route("/users", methods=["GET", "POST"], handler=users)
route() returns a single Route when you give it one method, and a tuple of
routes in canonical order when you give it several.
A trailing slash is insignificant: /users and /users/ are one route, and
registering both is a RouteConflictError. The root path / is its own route.
An empty interior segment is not collapsed — /users//5 matches nothing,
because a client's double slash is a bug worth surfacing, not hide.
Groups
A group is a prefix you register through. It is a way of writing a path, not a second kind of route, so grouping cannot change how anything matches:
api = router.group("/api")
v1 = api.group("/v1")
v1.get("/users", list_users) # -> /api/v1/users
v1.get("/users/<int:id>", show_user) # -> /api/v1/users/<int:id>
Nesting is unlimited and there is no per-group lookup cost. A group offers the
same verbs as a router, plus routes() for the routes registered through it and
url() for building one of them.
A prefix may itself carry parameters. Every route in the group then declares them, and a handler that does not accept them is refused at registration:
org = router.group("/orgs/<int:org_id>")
def show_user(org_id: int, user_id: int) -> str: ...
org.get("/users/<int:user_id>", show_user) # /orgs/<org_id>/users/<user_id>
Mounting
mount() copies another router's routes into this one, under a prefix:
users = Router()
users.get("/", list_users)
users.get("/<int:id>", show_user)
router.mount("/users", users) # -> /users and /users/<int:id>
Routes are copied at the moment mount() is called, which keeps one flat trie:
a mounted URL costs exactly what an ordinary one costs, and 404 and 405 answers
stay uniform. The trade is snapshot semantics — routes added to users
afterwards are not picked up, so build the child router first and mount it
last. Both routers keep working independently afterwards.
Mounting is validated as a whole before anything is committed, so a conflict on the last route leaves the parent untouched.
Parameters
router.get("/users/<name>", show) # str, the default
router.get("/users/<int:id>", show) # 42
router.get("/ratio/<float:value>", show) # 4.5
router.get("/jobs/<uuid:id>", show) # UUID(...)
router.get("/files/<path:name>", show) # "a/b/report.pdf"
| Converter | Accepts | Produces |
|---|---|---|
str (default) |
any non-empty segment | str |
int |
-?[0-9]+ |
int |
float |
-?[0-9]+(\.[0-9]+)? |
float |
uuid |
canonical 8-4-4-4-12 hex form | uuid.UUID |
path |
the rest of the path, slashes included | str |
Parameters are passed to the handler by name, and the extracted values are available separately from the route itself:
result = router.resolve("GET", "/users/42")
result.route.path # '/users/<int:id>'
result.method # 'GET'
result.handler # the function you registered
result.params # {'id': 42} (mappingproxy, read-only)
Converters validate with explicit patterns rather than calling int() or
float() directly, because those accept " 5 " and "+5". A URL that does not
match its converter is a 404, not a surprise. path is greedy, may contain
slashes, and must therefore be the last segment of a pattern.
At registration Gromon checks that your handler can actually receive the parameters the route declares:
router.get("/users/<id>", home) # HandlerSignatureError: home() cannot accept 'id'
Matching
result = router.resolve("GET", "/users/42")
resolve() returns one of two things and never raises for a request that finds
nothing:
from gromon_backend import Match, NoMatch
Match—route,method,handler,params.NoMatch—status_code(404 or 405),allowed(the methods this path does support), andallowready to be joined into anAllowheader.
The router never calls your handler. It answers "which handler, with which parameters", which is what keeps it usable by both a synchronous and an asynchronous runtime.
404 versus 405
router.resolve("GET", "/users") # Match
router.resolve("DELETE", "/nope") # NoMatch, status_code == 404
router.resolve("DELETE", "/users") # NoMatch, status_code == 405, allowed == ('GET',)
A path that exists but not for that method is 405, and the router hands you the allowed set because it already knows it. Building the response is the future runtime's job, not this library's.
Precedence
When more than one route could match, the winner is always decided the same way:
- Static segments beat parameters.
/users/mewins over/users/<id>. - Narrower converters beat wider ones, in the fixed order
int→float→uuid→str→path./users/42reaches/users/<int:id>even if/users/<str:name>was registered first. - Converter width beats registration order, always.
- The first complete path match owns the URL, including its methods — so a 405 reports the methods of the route that won the path.
When a narrow branch matches a segment but dead-ends on the rest of the path, the search unwinds and tries the next branch. That is why both of these can coexist and both work:
router.get("/items/<int:id>", show_item) # /items/42
router.get("/items/<str:slug>/comments", c) # /items/42/comments <- unwinds
Two parameters of the same width at the same position are refused at compile time, because which one should win would be arbitrary:
router.get("/x/<int:id>", a)
router.get("/x/<int:other>", b) # RouteConflictError; reuse one name to share the branch
Inspecting routes
for route in router.routes():
print(route.method, route.path, route.endpoint_name)
len(router) # how many routes are registered
router.compile() # build the routing table now, surfacing conflicts at startup
Performance
Lookup cost follows the depth of the path, not the number of routes. Routes are compiled once into a segment trie, so a four-segment request does about four hash lookups whether the application has ten routes or fifty thousand. 100x the routes costs essentially nothing extra per request.
python -m pytest tests/benchmarks -q -s # prints the table
python -m pytest -m "not slow" # skip the timing tests
Named routes
A name is what lets a route be referenced from code instead of from a string
typed twice. It is validated when it is registered — non-empty, no surrounding
whitespace, and never reused — so a typo is a RouteConflictError at startup
rather than a 404 in production.
router.get("/users/<int:id>", show_user, name="users.show")
router.url("users.show", id=42) # '/users/42'
url() converts each value back to text through the route's own converter, so a
value the route could never match is reported here instead of being handed to
you as a URL that 404s. str and path values are percent-encoded; a path
value keeps its slashes, because that is what makes it a path. A missing,
unexpected, or badly typed parameter is a UrlBuildError.
The route name is positional-only, so a route parameter called name can still
be passed by keyword:
router.get("/files/<path:name>", show_file, name="files.show")
router.url("files.show", name="a/b/report.pdf") # '/files/a/b/report.pdf'
A name describes a path, not a method, so a multi-method registration carries one name:
router.route("/users", ["GET", "POST"], users, name="users.index")
router.url("users.index") # '/users'
Names are per router, not per group. A name registered through a group or a mount is reachable from the router that owns it, and the URL it builds is the full effective path including every prefix:
v1 = router.group("/api/v1")
v1.get("/users/<int:id>", show_user, name="users.show")
router.url("users.show", id=42) # '/api/v1/users/42'
v1.url("users.show", id=42) # the same string
router.names() # ('users.show',)
Installation
pip install gromon-backend # from a package index
pip install -e ".[dev]" # from a checkout, with the dev tools
There is nothing else to configure. The package has no runtime dependencies and does not read the environment, so the same table behaves the same in every process that builds it.
Serving it
A complete server, in seven lines:
from gromon_backend import Router, serve
router = Router()
router.get("/", "Hello Gromon!")
router.get("/health", {"status": "ok"})
serve(router) # http://localhost:8000
A bare value is shorthand for a route that always answers with it, so trivial
endpoints need no def. What a handler returns decides the content type:
| Returned | Sent as |
|---|---|
"text" |
200 text/plain |
b"bytes" |
200 application/octet-stream |
{"ok": True} |
200 application/json |
None |
204, no body |
("moved", 301) |
301 with that body |
When a return value is not enough, ask for what you mean:
from gromon_backend import Request, Response, html, json_response, static_file
router.get("/page", static_file("index.html")) # read per request
router.get("/about", lambda: html("<h1>About</h1>"))
router.get("/api", lambda: json_response({"ok": True}, status=201))
def search(request: Request) -> Response:
return json_response({"results": find(request.args.get("q"))})
A handler asks for the Request by annotating it. Route parameters arrive by
name as always, so one handler can take both.
serve() uses the standard library's ThreadingHTTPServer and adds no
dependencies. To mount the same routes on something else, build_server(router)
returns the server without starting it, and router.resolve() remains the whole
contract for writing a runtime against another server:
from gromon_backend import Match, Router
router = Router()
router.get("/users/<int:id>", get_user)
def dispatch(method: str, raw_path: str):
result = router.resolve(method, raw_path)
if isinstance(result, Match):
return result.handler(**result.params)
return error(result.status_code, allow=result.allow)
Match.params is a read-only mapping, so a handler cannot corrupt the state of
the match it was given. NoMatch.allowed is already ordered and ready to be
joined into an Allow header.
Threads and async
Registration and resolution never share mutable state, so a module-level
Router is safe to build before the server starts and read from every worker
thread afterwards with no lock. The compiled table is rebuilt into a fresh
object and rebound in one step, so a concurrent reader always sees a complete
table, never a half-built one. Because the router never awaits anything, the
same table serves a synchronous and an asynchronous runtime unchanged.
Failing at startup instead of at request time
Two classes of mistake are caught while routes are being registered, not on the
first request that reaches them: a handler that cannot receive the parameters
its route declares, and a registration that would be ambiguous. Call
router.compile() once during startup to surface the second kind immediately.
Errors
Every error raised by the library derives from GromonError, and routing
errors derive further from RouteError:
from gromon_backend.errors import (
HandlerSignatureError, # handler cannot receive the route's parameters
InvalidConverterError, # unknown converter, or <path:...> used wrongly
InvalidMethodError, # unknown HTTP method
InvalidNameError, # malformed route name
InvalidPathError, # malformed path or route pattern
MountError, # a router cannot be mounted as asked
RouteConflictError, # duplicate or ambiguous registration
UrlBuildError, # a name cannot be turned back into a URL
)
A request that matches nothing is not an error. It is reported as data,
through NoMatch, so the runtime can decide what to send.
Project status
| Milestone | Scope | State |
|---|---|---|
| 1 | Architecture + package foundation | done |
| 2 | Registration and matching core | done |
| 3 | HTTP methods, 404 / 405 behaviour | done in Milestone 2 |
| 4 | Path parameters and converters | done in Milestone 2 |
| 5 | Groups and nested groups | done |
| 6 | Router mounting | done |
| 7 | Named routes and reverse URL generation | done |
| 8 | Deeper conflict detection and inspection polish | pending |
| 9 | Extended benchmarks and optimisation | pending |
| 10 | Documentation and production hardening | pending |
updates.md (local only, never committed) holds the running design log for
every milestone, including the decisions and the measurements behind them.
Development
pip install -e ".[dev]"
python -m pytest # tests
python -m ruff check . # lint
python -m ruff format . # format
python -m mypy # types (strict, targeting the oldest supported Python)
python -m build # sdist + wheel
Requires Python 3.10 or newer. The routing engine has no runtime dependencies.
License
MIT — see LICENSE.
Metadata
Release files for gromon-backend 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| gromon_backend-0.4.0.tar.gz | 57.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| gromon_backend-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 93.2 kB
Release files / gromon_backend-0.4.0.tar.gz
| Download URL | gromon_backend-0.4.0.tar.gz |
|---|---|
| Size | 57.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9bbea8d223d92fe111c72505dc6d8418aebce6f291a5889e100a960470aba5b8
|
|
BLAKE2b-256 checksum How to use checksums |
9d6b32167d8478873e13635f089e4324da43079dc2eb260b399397d22aec3f68
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency logRelease files / gromon_backend-0.4.0-py3-none-any.whl
| Download URL | gromon_backend-0.4.0-py3-none-any.whl |
|---|---|
| Size | 35.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f6e9009b827839e93658b4d606ab01fda5e4cbe39e48d9b9aa8b232f742798c5
|
|
BLAKE2b-256 checksum How to use checksums |
95725a75bdb70fef01f472ed85e5298f81a91b322d96198d4030e7dbe50bfd5c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency log