Skip to content

Commit

Permalink
chore: misc
Browse files Browse the repository at this point in the history
  • Loading branch information
nickelpro committed Oct 26, 2024
1 parent 2e4d798 commit f41ee28
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 25 deletions.
181 changes: 174 additions & 7 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,179 @@

# nanoroute

A small Python HTTP URL routing facility, capable of sub-microsecond routing.
This is mostly pedagogical, demonstrating what is _possible_ to accomplish, but
a little too spartan for most projects.
A small Python HTTP URL routing facility capable of sub-microsecond routing,
typically <200ns.

That said, when combined with a very fast application server, nanoroute is
capable of rivaling the best routers and dispatchers in performance-oriented
languages; just don't expect it to do session management or anything like that.
Nanoroute is a C++ implementation of a modified version of the priority
radix-tree algorithm pioneered by Julien Schmidt's
[httprouter](https://github.com/julienschmidt/httprouter). Like httprouter,
nanorouter's underlying implementation performs no allocations in the common
case, although some allocations are necessary to create the PyObjects necessary
for the CPython bindings.

Nanoroute is ~4400x faster than Flask / Werkzeug.
The nanoroute package is suitable as a building block for more fully featured
HTTP frameworks. It also provides a simple WSGI interface for integration
directly into WSGI application stacks.

The intended use case is high-performance message brokers, dispatchers, and
ingestion endpoints. The performance improvements of a high-speed router are
unlikely to be very significant in a typical database-backed Python REST API.

## Quickstart

For complete documentation, see the [docs]().

#### Installation

Nanoroute only supports Python 3.13+. It is available via PyPI, any mechanism
of installing Python packages from PyPI will work:

```
pip install nanoroute
```

#### Registering Routes

Nanoroute provides a single class, the `router`, which can be used to register
handlers for common HTTP verbs.

```python
import nanoroute

app = nanoroute.router()

@app.get('/')
def root(*args, **kwargs):
...

@app.post('/endpoint')
def endpoint(*args, **kwargs):
...
```

The verbs supported via these convenience methods are `GET`, `POST`, `PUT`, and
`DELETE`. Arbitrary sets of any ***valid*** HTTP verbs can be registered
using `router.route()`.

```python
# Register for a single HTTP verb
@app.route('PATCH', '/object')
def handle_patch(*args, **kwargs):
...

# Register for a multiple HTTP verbs
@app.route(['POST', 'PUT'], '/multi-meth')
def handle_multi(*args, **kwargs):
...
```

Finally, any arbitrary object can be registered with nanoroute. The decorator
syntax is merely convenient for typical usage.

```python
# Register arbitrary object for GET '/'
app.get('/')(object())
```

#### Capturing Parameters

Two forms of parameter capture are available, segment capture and catchall.
Segment captures are delimited by `:` and capture from the appearance of the
delimiter until the following `/` or the end of the URL. Catchalls are delimited
by `*` and capture the entire URL following their appearance.

Both types of parameter capture may be followed by a name, which will used as
the key associated with the parameter during route lookup. Anonymous parameters
act as wildcards, they have the same behavior as named parameters but the
captured data is not reported during lookup.

```python
# Captures the middle segment with the name "id"
@app.get('/user/:id/profile')
def get_profile(*args, **kwargs):
...

# Captures the article ID into "id", and then everything after the final "/"
# is captured as "slug" which might contain multiple path segments
@app.get('/article/:id/*slug')
def article(*args, **kwargs):
...

# The first path segment is a wildcard, anything may appear, but nothing is
# captured during route lookup
@app.get('/:/anonymous')
def anon(*args, **kwargs):
...
```

Captures are allowed to appear at arbtirary points in a given segment, so
long as multiple captures do not appear in the same segment.

```python
# Captures an "id" in the middle of a segment
@app.get('/user_:id/profile')
def get_profile(*args, **kwargs):
...

# Error: Invalid wildcard construction. Only one capture is allowed to appear
# in a given path segment
@app.get('/user_:id_:name')
def this_is_an_error(*args, **kwargs):
...
```

Captures that appear in the same place for different paths may have different
names, which will be recorded appropriately.

```python
# Captures the first segment as "foo"
@app.get('/:foo/alpha')
def alpha(*args, **kwargs):
...

# Captures the first segment as "bar"
@app.get('/:bar/beta')
def beta(*args, **kwargs):
...
```

#### Lookup

The base lookup facility is `router.lookup()`, which is intended for other
frameworks to use as a building block. It takes a method and path as arguments
and returns the registered handler and a parameter capture dictionary.

```python
@app.get('/user/:name')
def say_hello(params):
print(f'Hello {params['name']}!')

handler, params = app.lookup('GET', '/user/Jane')
handler(params)

# >>> Hello Jane!
```

As a convenience, a WSGI application is also provided. It is directly equivalent
to the following code:
```python
def wsgi_app(environ, start_response):
handler, params = app.lookup(environ['REQUEST_METHOD'], environ['PATH_INFO'])
environ['nanoroute.params'] = params
return handler(environ, start_response)

app.wsgi_app = wsgi_app
```

## Implementation Details

Nanoroute's underlying data structure is conceptually
[the same priority radix-tree used by httprouter](https://github.com/julienschmidt/httprouter?tab=readme-ov-file#how-does-it-work).

The only divergence in structure is with named parameters. Httproute stores
parameter names inline in the tree structure, which means all routes
must provide the same name for a given parameter and parameters _must_ be named.

Nanoroute stores parameter names alongside the route handler, and maps them
positionally to parameter values captured from the route. Empty parameter
names and their associated values are skipped.
24 changes: 17 additions & 7 deletions bench/bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@

router = nanoroute.router()

router.get('/')(None)
router.get('/hello')(None)
router.get('/hello/world')(None)
router.get('/goodbye')(None)
router.get('/goodbyte/world')(None)

router.get("hello")
router.get('/')(1)
router.get('/hello')(2)
router.get('/hello/world')(3)
router.get('/goodbye')(4)
router.get('/goodbyte/world')(5)

print(timeit.timeit("router.lookup('GET', '/hello/world')", globals=locals()))

from werkzeug.routing import Map, Rule

urls = Map([
Rule('/', endpoint=1),
Rule('/hello', endpoint=2),
Rule('/hello/world', endpoint=3),
Rule('/goodbye', endpoint=4),
Rule('/goodbyte/world', endpoint=5),
]).bind('', '')

print(timeit.timeit("urls.match('/hello/world', 'GET')", globals=locals()))
19 changes: 10 additions & 9 deletions src/ModNanoroute.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,25 @@ int nrmod_exec(PyObject* mod) {
if(!state->path_info_string)
return -1;

state->wsgi_key_string = PyUnicode_InternFromString("nanoroute.captures");
state->wsgi_key_string = PyUnicode_InternFromString("nanoroute.params");
if(!state->wsgi_key_string)
return -1;

return 0;
}

#define slot(s, v) \
PyModuleDef_Slot { \
s, reinterpret_cast<void*>(v) \
}

std::array nrmod_slots {
PyModuleDef_Slot {
.slot = Py_mod_exec,
.value = reinterpret_cast<void*>(nrmod_exec),
},
PyModuleDef_Slot {
.slot = Py_mod_multiple_interpreters,
.value = Py_MOD_PER_INTERPRETER_GIL_SUPPORTED,
},
slot(Py_mod_exec, nrmod_exec),
slot(Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED),
slot(Py_mod_gil, Py_MOD_GIL_NOT_USED),
PyModuleDef_Slot {},
};
#undef slot

} // namespace

Expand Down
2 changes: 1 addition & 1 deletion src/PyRouter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ struct RegisterClosure {
static PyObject* alloc(PyRouter* router, PyObject* route,
std::vector<HTTPMethod> meths) {
return capsulize(new RegisterClosure {router, route, std::move(meths)});
};
}

private:
static PyObject* capsulize(RegisterClosure* rg) {
Expand Down
5 changes: 4 additions & 1 deletion src/nanoroute.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ T = TypeVar('handle')


class router:
def __init__() -> None:
...

def get(route: str, /) -> Callable[[T], T]:
...

Expand All @@ -19,7 +22,7 @@ class router:
def route(route: str | Iterable[str], /) -> Callable[[T], T]:
...

def lookup(path: str, /) -> tuple[Any, dict[str, str]]:
def lookup(method: str, path: str, /) -> tuple[Any, dict[str, str]]:
...

def wsgi_app(
Expand Down

0 comments on commit f41ee28

Please sign in to comment.