Skip to main content

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), and allow ready to be joined into an Allow header.

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:

  1. Static segments beat parameters. /users/me wins over /users/<id>.
  2. Narrower converters beat wider ones, in the fixed order int → float → uuid → str → path. /users/42 reaches /users/<int:id> even if /users/<str:name> was registered first.
  3. Converter width beats registration order, always.
  4. 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)

Source distribution for gromon-backend 0.4.0
File Size Uploaded
gromon_backend-0.4.0.tar.gz 57.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for gromon-backend 0.4.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page