Skip to content

Commit 54d6776

Browse files
committed
rework route compiler interface
This allows compilers to work in a wider variety of ways.
1 parent 6d7254f commit 54d6776

File tree

7 files changed

+186
-97
lines changed

7 files changed

+186
-97
lines changed

idom_router/__init__.py

+6-9
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
# the version is statically loaded by setup.py
22
__version__ = "0.0.1"
33

4-
from .router import (
5-
Route,
6-
link,
7-
router,
8-
use_location,
9-
use_params,
10-
use_query,
11-
)
4+
from idom_router.types import Route, RouteCompiler, RoutePattern
5+
6+
from .router import link, router, use_params, use_query
127

138
__all__ = [
14-
"Route",
159
"link",
10+
"Route",
11+
"RouteCompiler",
12+
"RoutePattern",
1613
"router",
1714
"use_location",
1815
"use_params",

idom_router/compilers.py

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from typing import Any
5+
6+
from starlette.convertors import Convertor
7+
from starlette.routing import compile_path as _compile_starlette_path
8+
9+
from idom_router.types import Route
10+
11+
12+
def compile_starlette_route(route: Route) -> StarletteRoutePattern:
13+
pattern, _, converters = _compile_starlette_path(route.path)
14+
return StarletteRoutePattern(pattern, converters)
15+
16+
17+
class StarletteRoutePattern:
18+
def __init__(
19+
self,
20+
pattern: re.Pattern[str],
21+
converters: dict[str, Convertor],
22+
) -> None:
23+
self.pattern = pattern
24+
self.key = pattern.pattern
25+
self.converters = converters
26+
27+
def match(self, path: str) -> dict[str, Any] | None:
28+
match = self.pattern.match(path)
29+
if match:
30+
return {
31+
k: self.converters[k].convert(v) if k in self.converters else v
32+
for k, v in match.groupdict().items()
33+
}
34+
return None

idom_router/router.py

+54-48
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,58 @@
11
from __future__ import annotations
22

3-
from dataclasses import dataclass
3+
from dataclasses import dataclass, replace
44
from pathlib import Path
5-
from typing import Any, Callable, Iterator, Sequence
5+
from typing import Any, Callable, Iterator, Sequence, TypeVar
66
from urllib.parse import parse_qs
77

88
from idom import (
99
component,
1010
create_context,
11-
use_memo,
12-
use_state,
1311
use_context,
1412
use_location,
13+
use_memo,
14+
use_state,
1515
)
16-
from idom.core.types import VdomAttributesAndChildren, VdomDict
17-
from idom.core.vdom import coalesce_attributes_and_children
18-
from idom.types import ComponentType, Location, Context
19-
from idom.web.module import export, module_from_file
2016
from idom.backend.hooks import ConnectionContext, use_connection
2117
from idom.backend.types import Connection, Location
22-
from starlette.routing import compile_path as _compile_starlette_path
23-
24-
from idom_router.types import RoutePattern, RouteCompiler, Route
18+
from idom.core.types import VdomChild, VdomDict
19+
from idom.types import ComponentType, Context, Location
20+
from idom.web.module import export, module_from_file
2521

22+
from idom_router.compilers import compile_starlette_route
23+
from idom_router.types import Route, RouteCompiler, RoutePattern
2624

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

3127

3228
@component
3329
def router(
34-
*routes: Route,
35-
compiler: RouteCompiler = compile_starlette_route,
30+
*routes: R,
31+
compiler: RouteCompiler[R] = compile_starlette_route,
3632
) -> ComponentType | None:
3733
old_conn = use_connection()
3834
location, set_location = use_state(old_conn.location)
3935

40-
compiled_routes = use_memo(
41-
lambda: [(compiler(r), e) for r, e in _iter_routes(routes)],
42-
dependencies=routes,
43-
)
44-
for compiled_route, element in compiled_routes:
45-
match = compiled_route.pattern.match(location.pathname)
46-
if match:
47-
convs = compiled_route.converters
48-
return ConnectionContext(
49-
_route_state_context(
50-
element,
51-
value=_RouteState(
52-
set_location,
53-
{
54-
k: convs[k](v) if k in convs else v
55-
for k, v in match.groupdict().items()
56-
},
57-
),
58-
),
59-
value=Connection(old_conn.scope, location, old_conn.carrier),
60-
key=compiled_route.pattern.pattern,
61-
)
36+
# Memoize the compiled routes and the match separately so that we don't
37+
# recompile the routes on renders where only the location has changed
38+
compiled_routes = use_memo(lambda: _compile_routes(routes, compiler))
39+
match = use_memo(lambda: _match_route(compiled_routes, location))
40+
41+
if match is not None:
42+
route, params = match
43+
return ConnectionContext(
44+
_route_state_context(
45+
route.element, value=_RouteState(set_location, params)
46+
),
47+
value=Connection(old_conn.scope, location, old_conn.carrier),
48+
key=route.path,
49+
)
50+
6251
return None
6352

6453

6554
@component
66-
def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDict:
67-
attributes, children = coalesce_attributes_and_children(attributes_or_children)
55+
def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict:
6856
set_location = _use_route_state().set_location
6957
attrs = {
7058
**attributes,
@@ -76,7 +64,7 @@ def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDic
7664

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

8169

8270
def use_query(
@@ -97,15 +85,27 @@ def use_query(
9785
)
9886

9987

100-
def _use_route_state() -> _RouteState:
101-
return use_context(_route_state_context)
88+
def _compile_routes(
89+
routes: Sequence[R], compiler: RouteCompiler[R]
90+
) -> list[tuple[Any, RoutePattern]]:
91+
return [(r, compiler(r)) for r in _iter_routes(routes)]
92+
93+
94+
def _iter_routes(routes: Sequence[R]) -> Iterator[R]:
95+
for parent in routes:
96+
for child in _iter_routes(parent.routes):
97+
yield replace(child, path=parent.path + child.path)
98+
yield parent
10299

103100

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

110110

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

122122

123+
def _use_route_state() -> _RouteState:
124+
route_state = use_context(_route_state_context)
125+
assert route_state is not None
126+
return route_state
127+
128+
123129
_route_state_context: Context[_RouteState | None] = create_context(None)

idom_router/types.py

+28-13
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,43 @@
11
from __future__ import annotations
22

3-
import re
43
from dataclasses import dataclass
5-
from typing import Callable, Any, Protocol, Sequence
4+
from typing import Any, Sequence, TypeVar
5+
6+
from idom.types import Key
7+
from typing_extensions import Protocol, Self
68

79

810
@dataclass
911
class Route:
1012
path: str
1113
element: Any
12-
routes: Sequence[Route]
13-
14-
def __init__(self, path: str, element: Any | None, *routes: Route) -> None:
14+
routes: Sequence[Self]
15+
16+
def __init__(
17+
self,
18+
path: str,
19+
element: Any | None,
20+
*routes_: Self,
21+
# we need kwarg in order to play nice with the expected dataclass interface
22+
routes: Sequence[Self] = (),
23+
) -> None:
1524
self.path = path
1625
self.element = element
17-
self.routes = routes
26+
self.routes = (*routes_, *routes)
1827

1928

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

2431

25-
@dataclass
26-
class RoutePattern:
27-
pattern: re.Pattern[str]
28-
converters: dict[str, Callable[[Any], Any]]
32+
class RouteCompiler(Protocol[R]):
33+
def __call__(self, route: R) -> RoutePattern:
34+
"""Compile a route into a pattern that can be matched against a path"""
35+
36+
37+
class RoutePattern(Protocol):
38+
@property
39+
def key(self) -> Key:
40+
"""Uniquely identified this pattern"""
41+
42+
def match(self, path: str) -> dict[str, Any] | None:
43+
"""Returns otherwise a dict of path parameters if the path matches, else None"""

requirements/pkg-deps.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
idom >=0.40.2,<0.41
1+
idom >=1
22
typing_extensions
33
starlette

tests/test_router.py

+14-26
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
1-
import re
2-
31
from idom import Ref, component, html, use_location
42
from idom.testing import DisplayFixture
53

6-
from idom_router import (
7-
Route,
8-
router,
9-
link,
10-
use_params,
11-
use_query,
12-
)
13-
from idom_router.types import RoutePattern
4+
from idom_router import Route, link, router, use_params, use_query
5+
from tests.utils import compile_simple_regex_route
146

157

168
async def test_simple_router(display: DisplayFixture):
@@ -87,10 +79,10 @@ async def test_navigate_with_link(display: DisplayFixture):
8779
def sample():
8880
render_count.current += 1
8981
return router(
90-
Route("/", link({"id": "root"}, "Root", to="/a")),
91-
Route("/a", link({"id": "a"}, "A", to="/b")),
92-
Route("/b", link({"id": "b"}, "B", to="/c")),
93-
Route("/c", link({"id": "c"}, "C", to="/default")),
82+
Route("/", link("Root", to="/a", id="root")),
83+
Route("/a", link("A", to="/b", id="a")),
84+
Route("/b", link("B", to="/c", id="b")),
85+
Route("/c", link("C", to="/default", id="c")),
9486
Route("/{path:path}", html.h1({"id": "default"}, "Default")),
9587
)
9688

@@ -174,33 +166,29 @@ def check_params():
174166
def sample():
175167
return router(
176168
Route(
177-
r"/first/(?P<first>\d+)",
169+
r"/first/(?P<first__int>\d+)",
178170
check_params(),
179171
Route(
180-
r"/second/(?P<second>[\d\.]+)",
172+
r"/second/(?P<second__float>[\d\.]+)",
181173
check_params(),
182174
Route(
183-
r"/third/(?P<third>[\d,]+)",
175+
r"/third/(?P<third__list>[\d,]+)",
184176
check_params(),
185177
),
186178
),
187179
),
188-
compiler=lambda path: RoutePattern(
189-
re.compile(rf"^{path}$"),
190-
{
191-
"first": int,
192-
"second": float,
193-
"third": lambda s: list(map(int, s.split(","))),
194-
},
195-
),
180+
compiler=compile_simple_regex_route,
196181
)
197182

198183
await display.show(sample)
199184

200185
for path, expected_params in [
201186
("/first/1", {"first": 1}),
202187
("/first/1/second/2.1", {"first": 1, "second": 2.1}),
203-
("/first/1/second/2.1/third/3,3", {"first": 1, "second": 2.1, "third": [3, 3]}),
188+
(
189+
"/first/1/second/2.1/third/3,3",
190+
{"first": 1, "second": 2.1, "third": ["3", "3"]},
191+
),
204192
]:
205193
await display.goto(path)
206194
await display.page.wait_for_selector("#success")

0 commit comments

Comments
 (0)