Skip to content

Commit 5cd886b

Browse files
committed
initial work on router compiler
1 parent d014434 commit 5cd886b

File tree

4 files changed

+115
-100
lines changed

4 files changed

+115
-100
lines changed

Diff for: idom_router/__init__.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,17 @@
33

44
from .router import (
55
Route,
6-
RouterConstructor,
7-
create_router,
86
link,
7+
router,
98
use_location,
109
use_params,
1110
use_query,
1211
)
1312

1413
__all__ = [
15-
"create_router",
16-
"link",
1714
"Route",
18-
"RouterConstructor",
15+
"link",
16+
"router",
1917
"use_location",
2018
"use_params",
2119
"use_query",

Diff for: idom_router/router.py

+35-73
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import re
43
from dataclasses import dataclass
54
from pathlib import Path
65
from typing import Any, Callable, Iterator, Sequence
@@ -9,68 +8,50 @@
98
from idom import component, create_context, use_context, use_memo, use_state
109
from idom.core.types import VdomAttributesAndChildren, VdomDict
1110
from idom.core.vdom import coalesce_attributes_and_children
12-
from idom.types import BackendImplementation, ComponentType, Context, Location
11+
from idom.types import ComponentType, Context, Location
1312
from idom.web.module import export, module_from_file
14-
from starlette.routing import compile_path
13+
from starlette.routing import compile_path as _compile_starlette_path
14+
15+
from idom_router.types import RoutePattern, RouteCompiler, Route
1516

1617
try:
1718
from typing import Protocol
1819
except ImportError: # pragma: no cover
1920
from typing_extensions import Protocol # type: ignore
2021

2122

22-
class RouterConstructor(Protocol):
23-
def __call__(self, *routes: Route) -> ComponentType:
24-
...
25-
26-
27-
def create_router(
28-
implementation: BackendImplementation[Any] | Callable[[], Location]
29-
) -> RouterConstructor:
30-
if isinstance(implementation, BackendImplementation):
31-
use_location = implementation.use_location
32-
elif callable(implementation):
33-
use_location = implementation
34-
else:
35-
raise TypeError(
36-
"Expected a 'BackendImplementation' or "
37-
f"'use_location' hook, not {implementation}"
38-
)
39-
40-
@component
41-
def router(*routes: Route) -> ComponentType | None:
42-
initial_location = use_location()
43-
location, set_location = use_state(initial_location)
44-
compiled_routes = use_memo(
45-
lambda: _iter_compile_routes(routes), dependencies=routes
46-
)
47-
for r in compiled_routes:
48-
match = r.pattern.match(location.pathname)
49-
if match:
50-
return _LocationStateContext(
51-
r.element,
52-
value=_LocationState(
53-
location,
54-
set_location,
55-
{k: r.converters[k](v) for k, v in match.groupdict().items()},
56-
),
57-
key=r.pattern.pattern,
58-
)
59-
return None
60-
61-
return router
62-
23+
def compile_starlette_route(path: str) -> RoutePattern:
24+
pattern, _, converters = _compile_starlette_path(path)
25+
return RoutePattern(pattern, {k: v.convert for k, v in converters.items()})
6326

64-
@dataclass
65-
class Route:
66-
path: str
67-
element: Any
68-
routes: Sequence[Route]
6927

70-
def __init__(self, path: str, element: Any | None, *routes: Route) -> None:
71-
self.path = path
72-
self.element = element
73-
self.routes = routes
28+
@component
29+
def router(
30+
*routes: Route,
31+
compiler: RouteCompiler = compile_starlette_route,
32+
) -> ComponentType | None:
33+
initial_location = use_location()
34+
location, set_location = use_state(initial_location)
35+
compiled_routes = use_memo(
36+
lambda: [(compiler(path), element) for path, element in _iter_routes(routes)],
37+
dependencies=routes,
38+
)
39+
for compiled_route, element in compiled_routes:
40+
match = compiled_route.pattern.match(location.pathname)
41+
if match:
42+
return _LocationStateContext(
43+
element,
44+
value=_LocationState(
45+
location,
46+
set_location,
47+
{
48+
k: compiled_route.converters[k](v)
49+
for k, v in match.groupdict().items()
50+
},
51+
),
52+
key=compiled_route.pattern.pattern,
53+
)
54+
return None
7455

7556

7657
@component
@@ -113,28 +94,13 @@ def use_query(
11394
)
11495

11596

116-
def _iter_compile_routes(routes: Sequence[Route]) -> Iterator[_CompiledRoute]:
117-
for path, element in _iter_routes(routes):
118-
pattern, _, converters = compile_path(path)
119-
yield _CompiledRoute(
120-
pattern, {k: v.convert for k, v in converters.items()}, element
121-
)
122-
123-
12497
def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]:
12598
for r in routes:
12699
for path, element in _iter_routes(r.routes):
127100
yield r.path + path, element
128101
yield r.path, r.element
129102

130103

131-
@dataclass
132-
class _CompiledRoute:
133-
pattern: re.Pattern[str]
134-
converters: dict[str, Callable[[Any], Any]]
135-
element: Any
136-
137-
138104
def _use_location_state() -> _LocationState:
139105
location_state = use_context(_LocationStateContext)
140106
assert location_state is not None, "No location state. Did you use a Router?"
@@ -151,10 +117,6 @@ class _LocationState:
151117
_LocationStateContext: Context[_LocationState | None] = create_context(None)
152118

153119
_Link = export(
154-
module_from_file(
155-
"idom-router",
156-
file=Path(__file__).parent / "bundle.js",
157-
fallback="⏳",
158-
),
120+
module_from_file("idom-router", file=Path(__file__).parent / "bundle.js"),
159121
"Link",
160122
)

Diff for: idom_router/types.py

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from dataclasses import dataclass
5+
from typing import Callable, Any, Protocol, Sequence
6+
7+
8+
@dataclass
9+
class Route:
10+
path: str
11+
element: Any
12+
routes: Sequence[Route]
13+
14+
def __init__(self, path: str, element: Any | None, *routes: Route) -> None:
15+
self.path = path
16+
self.element = element
17+
self.routes = routes
18+
19+
20+
class RouteCompiler(Protocol):
21+
def __call__(self, path: str) -> RoutePattern:
22+
...
23+
24+
25+
@dataclass
26+
class RoutePattern:
27+
pattern: re.Pattern[str]
28+
converters: dict[str, Callable[[Any], Any]]

Diff for: tests/test_router.py

+49-22
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,19 @@
11
import pytest
22
from idom import Ref, component, html
3-
from idom.testing import BackendFixture, DisplayFixture
3+
from idom.testing import DisplayFixture
44

55
from idom_router import (
66
Route,
7-
RouterConstructor,
8-
create_router,
7+
router,
98
link,
109
use_location,
1110
use_params,
1211
use_query,
1312
)
13+
from idom_router.types import RoutePattern
1414

1515

16-
@pytest.fixture
17-
def router(backend: BackendFixture):
18-
return create_router(backend.implementation)
19-
20-
21-
def test_create_router(backend):
22-
create_router(backend.implementation)
23-
create_router(backend.implementation.use_location)
24-
with pytest.raises(
25-
TypeError, match="Expected a 'BackendImplementation' or 'use_location' hook"
26-
):
27-
create_router(None)
28-
29-
30-
async def test_simple_router(display: DisplayFixture, router: RouterConstructor):
16+
async def test_simple_router(display: DisplayFixture):
3117
def make_location_check(path, *routes):
3218
name = path.lstrip("/").replace("/", "-")
3319

@@ -68,7 +54,7 @@ def sample():
6854
assert not await root_element.inner_html()
6955

7056

71-
async def test_nested_routes(display: DisplayFixture, router: RouterConstructor):
57+
async def test_nested_routes(display: DisplayFixture):
7258
@component
7359
def sample():
7460
return router(
@@ -94,7 +80,7 @@ def sample():
9480
await display.page.wait_for_selector(selector)
9581

9682

97-
async def test_navigate_with_link(display: DisplayFixture, router: RouterConstructor):
83+
async def test_navigate_with_link(display: DisplayFixture):
9884
render_count = Ref(0)
9985

10086
@component
@@ -121,7 +107,7 @@ def sample():
121107
assert render_count.current == 1
122108

123109

124-
async def test_use_params(display: DisplayFixture, router: RouterConstructor):
110+
async def test_use_params(display: DisplayFixture):
125111
expected_params = {}
126112

127113
@component
@@ -157,7 +143,7 @@ def sample():
157143
await display.page.wait_for_selector("#success")
158144

159145

160-
async def test_use_query(display: DisplayFixture, router: RouterConstructor):
146+
async def test_use_query(display: DisplayFixture):
161147
expected_query = {}
162148

163149
@component
@@ -174,3 +160,44 @@ def sample():
174160
expected_query = {"hello": ["world"], "thing": ["1", "2"]}
175161
await display.goto("?hello=world&thing=1&thing=2")
176162
await display.page.wait_for_selector("#success")
163+
164+
165+
def custom_path_compiler(path):
166+
pattern = re.compile(path)
167+
168+
169+
async def test_custom_path_compiler(display: DisplayFixture):
170+
expected_params = {}
171+
172+
@component
173+
def check_params():
174+
assert use_params() == expected_params
175+
return html.h1({"id": "success"}, "success")
176+
177+
@component
178+
def sample():
179+
return router(
180+
Route(
181+
"/first/{first:str}",
182+
check_params(),
183+
Route(
184+
"/second/{second:str}",
185+
check_params(),
186+
Route(
187+
"/third/{third:str}",
188+
check_params(),
189+
),
190+
),
191+
),
192+
compiler=lambda path: RoutePattern(re.compile()),
193+
)
194+
195+
await display.show(sample)
196+
197+
for path, expected_params in [
198+
("/first/1", {"first": "1"}),
199+
("/first/1/second/2", {"first": "1", "second": "2"}),
200+
("/first/1/second/2/third/3", {"first": "1", "second": "2", "third": "3"}),
201+
]:
202+
await display.goto(path)
203+
await display.page.wait_for_selector("#success")

0 commit comments

Comments
 (0)