Skip to content

Commit

Permalink
rework route compiler interface
Browse files Browse the repository at this point in the history
This allows compilers to work in a wider variety of ways.
  • Loading branch information
rmorshea committed Apr 29, 2023
1 parent 6d7254f commit 5d7170c
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 97 deletions.
15 changes: 6 additions & 9 deletions idom_router/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
# the version is statically loaded by setup.py
__version__ = "0.0.1"

from .router import (
Route,
link,
router,
use_location,
use_params,
use_query,
)
from idom_router.types import Route, RouteCompiler, RoutePattern

from .router import link, router, use_params, use_query

__all__ = [
"Route",
"link",
"Route",
"RouteCompiler",
"RoutePattern",
"router",
"use_location",
"use_params",
Expand Down
34 changes: 34 additions & 0 deletions idom_router/compilers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

import re
from typing import Any

from starlette.convertors import Convertor
from starlette.routing import compile_path as _compile_starlette_path

from idom_router.types import Route


def compile_starlette_route(route: Route) -> StarletteRoutePattern:
pattern, _, converters = _compile_starlette_path(route.path)
return StarletteRoutePattern(pattern, converters)


class StarletteRoutePattern:
def __init__(
self,
pattern: re.Pattern[str],
converters: dict[str, Convertor],
) -> None:
self.pattern = pattern
self.key = pattern.pattern
self.converters = converters

def match(self, path: str) -> dict[str, Any] | None:
match = self.pattern.match(path)
if match:
return {
k: self.converters[k].convert(v) if k in self.converters else v
for k, v in match.groupdict().items()
}
return None
102 changes: 54 additions & 48 deletions idom_router/router.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,58 @@
from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any, Callable, Iterator, Sequence
from typing import Any, Callable, Iterator, Sequence, TypeVar
from urllib.parse import parse_qs

from idom import (
component,
create_context,
use_memo,
use_state,
use_context,
use_location,
use_memo,
use_state,
)
from idom.core.types import VdomAttributesAndChildren, VdomDict
from idom.core.vdom import coalesce_attributes_and_children
from idom.types import ComponentType, Location, Context
from idom.web.module import export, module_from_file
from idom.backend.hooks import ConnectionContext, use_connection
from idom.backend.types import Connection, Location
from starlette.routing import compile_path as _compile_starlette_path

from idom_router.types import RoutePattern, RouteCompiler, Route
from idom.core.types import VdomChild, VdomDict
from idom.types import ComponentType, Context, Location
from idom.web.module import export, module_from_file

from idom_router.compilers import compile_starlette_route
from idom_router.types import Route, RouteCompiler, RoutePattern

def compile_starlette_route(route: str) -> RoutePattern:
pattern, _, converters = _compile_starlette_path(route)
return RoutePattern(pattern, {k: v.convert for k, v in converters.items()})
R = TypeVar("R", bound=Route)


@component
def router(
*routes: Route,
compiler: RouteCompiler = compile_starlette_route,
*routes: R,
compiler: RouteCompiler[R] = compile_starlette_route,
) -> ComponentType | None:
old_conn = use_connection()
location, set_location = use_state(old_conn.location)

compiled_routes = use_memo(
lambda: [(compiler(r), e) for r, e in _iter_routes(routes)],
dependencies=routes,
)
for compiled_route, element in compiled_routes:
match = compiled_route.pattern.match(location.pathname)
if match:
convs = compiled_route.converters
return ConnectionContext(
_route_state_context(
element,
value=_RouteState(
set_location,
{
k: convs[k](v) if k in convs else v
for k, v in match.groupdict().items()
},
),
),
value=Connection(old_conn.scope, location, old_conn.carrier),
key=compiled_route.pattern.pattern,
)
# Memoize the compiled routes and the match separately so that we don't
# recompile the routes on renders where only the location has changed
compiled_routes = use_memo(lambda: _compile_routes(routes, compiler))
match = use_memo(lambda: _match_route(compiled_routes, location))

if match is not None:
route, params = match
return ConnectionContext(
_route_state_context(
route.element, value=_RouteState(set_location, params)
),
value=Connection(old_conn.scope, location, old_conn.carrier),
key=route.path,
)

return None


@component
def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDict:
attributes, children = coalesce_attributes_and_children(attributes_or_children)
def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict:
set_location = _use_route_state().set_location
attrs = {
**attributes,
Expand All @@ -76,7 +64,7 @@ def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDic

def use_params() -> dict[str, Any]:
"""Get parameters from the currently matching route pattern"""
return use_context(_route_state_context).params
return _use_route_state().params


def use_query(
Expand All @@ -97,15 +85,27 @@ def use_query(
)


def _use_route_state() -> _RouteState:
return use_context(_route_state_context)
def _compile_routes(
routes: Sequence[R], compiler: RouteCompiler[R]
) -> list[tuple[Any, RoutePattern]]:
return [(r, compiler(r)) for r in _iter_routes(routes)]


def _iter_routes(routes: Sequence[R]) -> Iterator[R]:
for parent in routes:
for child in _iter_routes(parent.routes):
yield replace(child, path=parent.path + child.path)
yield parent


def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]:
for r in routes:
for path, element in _iter_routes(r.routes):
yield r.path + path, element
yield r.path, r.element
def _match_route(
compiled_routes: list[tuple[R, RoutePattern]], location: Location
) -> tuple[R, dict[str, Any]] | None:
for route, pattern in compiled_routes:
params = pattern.match(location.pathname)
if params is not None: # explicitely None check (could be empty dict)
return route, params
return None


_link = export(
Expand All @@ -120,4 +120,10 @@ class _RouteState:
params: dict[str, Any]


def _use_route_state() -> _RouteState:
route_state = use_context(_route_state_context)
assert route_state is not None
return route_state


_route_state_context: Context[_RouteState | None] = create_context(None)
41 changes: 28 additions & 13 deletions idom_router/types.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,43 @@
from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Callable, Any, Protocol, Sequence
from typing import Any, Protocol, Sequence, TypeVar

from idom.types import Key
from typing_extensions import Self


@dataclass
class Route:
path: str
element: Any
routes: Sequence[Route]

def __init__(self, path: str, element: Any | None, *routes: Route) -> None:
routes: Sequence[Self]

def __init__(
self,
path: str,
element: Any | None,
*route_args: Self,
# we need kwarg in order to play nice with the expected dataclass interface
routes: Sequence[Self] = (),
) -> None:
self.path = path
self.element = element
self.routes = routes
self.routes = (*route_args, *routes)


class RouteCompiler(Protocol):
def __call__(self, route: str) -> RoutePattern:
...
R = TypeVar("R", bound=Route, contravariant=True)


@dataclass
class RoutePattern:
pattern: re.Pattern[str]
converters: dict[str, Callable[[Any], Any]]
class RouteCompiler(Protocol[R]):
def __call__(self, route: R) -> RoutePattern:
"""Compile a route into a pattern that can be matched against a path"""


class RoutePattern(Protocol):
@property
def key(self) -> Key:
"""Uniquely identified this pattern"""

def match(self, path: str) -> dict[str, Any] | None:
"""Returns otherwise a dict of path parameters if the path matches, else None"""
2 changes: 1 addition & 1 deletion requirements/pkg-deps.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
idom >=0.40.2,<0.41
idom >=1
typing_extensions
starlette
40 changes: 14 additions & 26 deletions tests/test_router.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import re

from idom import Ref, component, html, use_location
from idom.testing import DisplayFixture

from idom_router import (
Route,
router,
link,
use_params,
use_query,
)
from idom_router.types import RoutePattern
from idom_router import Route, link, router, use_params, use_query
from tests.utils import compile_simple_regex_route


async def test_simple_router(display: DisplayFixture):
Expand Down Expand Up @@ -87,10 +79,10 @@ async def test_navigate_with_link(display: DisplayFixture):
def sample():
render_count.current += 1
return router(
Route("/", link({"id": "root"}, "Root", to="/a")),
Route("/a", link({"id": "a"}, "A", to="/b")),
Route("/b", link({"id": "b"}, "B", to="/c")),
Route("/c", link({"id": "c"}, "C", to="/default")),
Route("/", link("Root", to="/a", id="root")),
Route("/a", link("A", to="/b", id="a")),
Route("/b", link("B", to="/c", id="b")),
Route("/c", link("C", to="/default", id="c")),
Route("/{path:path}", html.h1({"id": "default"}, "Default")),
)

Expand Down Expand Up @@ -174,33 +166,29 @@ def check_params():
def sample():
return router(
Route(
r"/first/(?P<first>\d+)",
r"/first/(?P<first__int>\d+)",
check_params(),
Route(
r"/second/(?P<second>[\d\.]+)",
r"/second/(?P<second__float>[\d\.]+)",
check_params(),
Route(
r"/third/(?P<third>[\d,]+)",
r"/third/(?P<third__list>[\d,]+)",
check_params(),
),
),
),
compiler=lambda path: RoutePattern(
re.compile(rf"^{path}$"),
{
"first": int,
"second": float,
"third": lambda s: list(map(int, s.split(","))),
},
),
compiler=compile_simple_regex_route,
)

await display.show(sample)

for path, expected_params in [
("/first/1", {"first": 1}),
("/first/1/second/2.1", {"first": 1, "second": 2.1}),
("/first/1/second/2.1/third/3,3", {"first": 1, "second": 2.1, "third": [3, 3]}),
(
"/first/1/second/2.1/third/3,3",
{"first": 1, "second": 2.1, "third": ["3", "3"]},
),
]:
await display.goto(path)
await display.page.wait_for_selector("#success")
Loading

0 comments on commit 5d7170c

Please sign in to comment.