Metadata-Version: 2.4
Name: gromon-backend
Version: 0.4.0
Summary: A small, predictable HTTP routing engine for Python.
Author: Gromon
License-Expression: MIT
Keywords: http,routing,router,url,routing-engine
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7.4; extra == "dev"
Requires-Dist: mypy>=1.8; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Dynamic: license-file

# 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.

```python
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

```python
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:

```python
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:

```python
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:

```python
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:

```python
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

```python
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:

```python
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:

```python
router.get("/users/<id>", home)  # HandlerSignatureError: home() cannot accept 'id'
```

## Matching

```python
result = router.resolve("GET", "/users/42")
```

`resolve()` returns one of two things and never raises for a request that finds
nothing:

```python
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

```python
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:

```python
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:

```python
router.get("/x/<int:id>", a)
router.get("/x/<int:other>", b)  # RouteConflictError; reuse one name to share the branch
```

## Inspecting routes

```python
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.

```bash
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.

```python
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:

```python
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:

```python
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:

```python
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
```bash
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:

```python
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:

```python
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:

```python
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`:

```python
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

```bash
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](LICENSE).
