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 server, 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.

from gromon_backend import Router

router = Router()


def home():
    return "Hello Gromon"


router.get("/", home)

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 routing engine has no runtime dependencies and does not read the environment, so the same table behaves the same in every process that builds it.

Using it

The engine answers one question — which handler, with which parameters — and never calls the handler itself. Wiring it to a server is the runtime's job, and that is deliberately not this library's:

from gromon_backend import Match, NoMatch, Router

router = Router()
router.get("/users/<int:id>", get_user, name="users.show")


def dispatch(method: str, raw_path: str):
    """The whole request-to-handler contract, in one function."""
    result = router.resolve(method, raw_path)

    if isinstance(result, Match):
        return result.handler(**result.params)  # await it if it is async

    if result.status_code == 405:
        # The path exists, just not for this method.
        return error(405, allow=result.allow)
    return error(404)

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.3.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.3.0
File Size Uploaded
gromon_backend-0.3.0.tar.gz 48.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for gromon-backend 0.3.0
File Interpreter ABI Platform
gromon_backend-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 78.3 kB

Release files / gromon_backend-0.3.0.tar.gz

Download URL gromon_backend-0.3.0.tar.gz
Size 48.4 kB
Tags Source
SHA-256 checksum
How to use checksums
03859cf56bed90965f6e4c469407b9e7e5492a784e9fff2c04bb6c9265da341b
BLAKE2b-256 checksum
How to use checksums
1942eb15f65a4106f6adc95e2e137f4207cb175e1894d69515a54c8e01f8f2bd
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 Sep 30, 2026.

Transparency log

Release files / gromon_backend-0.3.0-py3-none-any.whl

Download URL gromon_backend-0.3.0-py3-none-any.whl
Size 29.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9624c4e7696a14c62011f454223cd65391539c3916669de8ae2af87dd47159ca
BLAKE2b-256 checksum
How to use checksums
26d829466f8e0fcda641a39658e44ad3fd000edc62e745c76fa51266d6c7e090
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 Sep 30, 2026.

Transparency log

Release history Release notifications | RSS feed

0.4.0

2 release files

This release

0.3.0 This release

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