Skip to content

Commit 1dece2b

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

File tree

4 files changed

+113
-100
lines changed

4 files changed

+113
-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

+32-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,47 @@
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 CompiledPath, PathCompiler, 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_path(path: str) -> CompiledPath:
24+
pattern, _, converters = _compile_starlette_path(path)
25+
return CompiledPath(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: PathCompiler = compile_starlette_path,
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 path, element in compiled_routes:
40+
match = path.pattern.match(location.pathname)
41+
if match:
42+
return _LocationStateContext(
43+
element,
44+
value=_LocationState(
45+
location,
46+
set_location,
47+
{k: path.converters[k](v) for k, v in match.groupdict().items()},
48+
),
49+
key=path.pattern.pattern,
50+
)
51+
return None
7452

7553

7654
@component
@@ -113,28 +91,13 @@ def use_query(
11391
)
11492

11593

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-
12494
def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]:
12595
for r in routes:
12696
for path, element in _iter_routes(r.routes):
12797
yield r.path + path, element
12898
yield r.path, r.element
12999

130100

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

153116
_Link = export(
154-
module_from_file(
155-
"idom-router",
156-
file=Path(__file__).parent / "bundle.js",
157-
fallback="⏳",
158-
),
117+
module_from_file("idom-router", file=Path(__file__).parent / "bundle.js"),
159118
"Link",
160119
)

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 PathCompiler(Protocol):
21+
def __call__(self, path: str) -> CompiledPath:
22+
...
23+
24+
25+
@dataclass
26+
class CompiledPath:
27+
pattern: re.Pattern[str]
28+
converters: dict[str, Callable[[Any], Any]]

Diff for: tests/test_router.py

+50-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 CompiledPath
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,45 @@ 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+
170+
async def test_custom_path_compiler(display: DisplayFixture):
171+
expected_params = {}
172+
173+
@component
174+
def check_params():
175+
assert use_params() == expected_params
176+
return html.h1({"id": "success"}, "success")
177+
178+
@component
179+
def sample():
180+
return router(
181+
Route(
182+
"/first/{first:str}",
183+
check_params(),
184+
Route(
185+
"/second/{second:str}",
186+
check_params(),
187+
Route(
188+
"/third/{third:str}",
189+
check_params(),
190+
),
191+
),
192+
),
193+
compiler=lambda path: CompiledPath(re.compile())
194+
)
195+
196+
await display.show(sample)
197+
198+
for path, expected_params in [
199+
("/first/1", {"first": "1"}),
200+
("/first/1/second/2", {"first": "1", "second": "2"}),
201+
("/first/1/second/2/third/3", {"first": "1", "second": "2", "third": "3"}),
202+
]:
203+
await display.goto(path)
204+
await display.page.wait_for_selector("#success")

0 commit comments

Comments
 (0)