Skip to content

Commit 52c37cc

Browse files
committed
improve coverage
1 parent 87e7e75 commit 52c37cc

File tree

3 files changed

+121
-35
lines changed

3 files changed

+121
-35
lines changed

idom_router/router.py

Lines changed: 72 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,20 @@
22

33
import re
44
from dataclasses import dataclass
5-
from fnmatch import translate as fnmatch_translate
65
from pathlib import Path
7-
from typing import Any, Callable, Iterator, Sequence
6+
from urllib.parse import parse_qs
7+
from typing import Any, Callable, Iterator, Sequence, TypeVar, overload
88

9-
from idom import component, create_context, use_context, use_state
9+
from idom import component, create_context, use_context, use_state, use_memo
1010
from idom.core.types import VdomAttributesAndChildren, VdomDict
1111
from idom.core.vdom import coalesce_attributes_and_children
1212
from idom.types import BackendImplementation, ComponentType, Context, Location
1313
from idom.web.module import export, module_from_file
14+
from starlette.routing import compile_path
1415

1516
try:
1617
from typing import Protocol
17-
except ImportError:
18+
except ImportError: # pragma: no cover
1819
from typing_extensions import Protocol
1920

2021

@@ -32,39 +33,42 @@ def configure(
3233
use_location = implementation
3334
else:
3435
raise TypeError(
35-
"Expected a BackendImplementation or "
36-
f"`use_location` hook, not {implementation}"
36+
"Expected a 'BackendImplementation' or "
37+
f"'use_location' hook, not {implementation}"
3738
)
3839

3940
@component
40-
def Router(*routes: Route) -> ComponentType | None:
41+
def Router(*routes: Route | Sequence[Route]) -> ComponentType | None:
4142
initial_location = use_location()
4243
location, set_location = use_state(initial_location)
43-
for p, r in _compile_routes(routes):
44-
match = p.match(location.pathname)
44+
compiled_routes = use_memo(lambda: _compile_routes(routes), dependencies=routes)
45+
for r in compiled_routes:
46+
match = r.pattern.match(location.pathname)
4547
if match:
4648
return _LocationStateContext(
4749
r.element,
48-
value=_LocationState(location, set_location, match),
49-
key=p.pattern,
50+
value=_LocationState(
51+
location,
52+
set_location,
53+
{r.converters[k](v) for k, v in match.groupdict().items()},
54+
),
55+
key=r.pattern.pattern,
5056
)
5157
return None
5258

5359
return Router
5460

5561

56-
def use_location() -> Location:
57-
return _use_location_state().location
58-
59-
60-
def use_match() -> re.Match[str]:
61-
return _use_location_state().match
62-
63-
6462
@dataclass
6563
class Route:
66-
path: str | re.Pattern[str]
64+
path: str
6765
element: Any
66+
routes: Sequence[Route]
67+
68+
def __init__(self, path: str, element: Any | None, *routes: Route) -> None:
69+
self.path = path
70+
self.element = element
71+
self.routes = routes
6872

6973

7074
@component
@@ -79,15 +83,54 @@ def Link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDic
7983
return _Link(attrs, *children)
8084

8185

82-
def _compile_routes(routes: Sequence[Route]) -> Iterator[tuple[re.Pattern[str], Route]]:
86+
def use_location() -> Location:
87+
"""Get the current route location"""
88+
return _use_location_state().location
89+
90+
91+
def use_params() -> dict[str, Any]:
92+
"""Get parameters from the currently matching route pattern"""
93+
return _use_location_state().params
94+
95+
96+
def use_query(
97+
keep_blank_values: bool = False,
98+
strict_parsing: bool = False,
99+
errors: str = "replace",
100+
max_num_fields: int | None = None,
101+
separator: str = "&",
102+
) -> dict[str, list[str]]:
103+
"""See :func:`urllib.parse.parse_qs` for parameter info."""
104+
return parse_qs(
105+
use_location().search,
106+
keep_blank_values=keep_blank_values,
107+
strict_parsing=strict_parsing,
108+
errors=errors,
109+
max_num_fields=max_num_fields,
110+
separator=separator,
111+
)
112+
113+
114+
def _compile_routes(routes: Sequence[Route]) -> list[_CompiledRoute]:
115+
for path, element in _iter_routes(routes):
116+
pattern, _, converters = compile_path(path)
117+
yield _CompiledRoute(
118+
pattern, {k: v.convert for k, v in converters.items()}, element
119+
)
120+
121+
122+
def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]:
83123
for r in routes:
84-
if isinstance(r.path, re.Pattern):
85-
yield r.path, r
86-
continue
87-
if not r.path.startswith("/"):
88-
raise ValueError("Path pattern must begin with '/'")
89-
pattern = re.compile(fnmatch_translate(r.path))
90-
yield pattern, r
124+
for path, element in _iter_routes(r.routes):
125+
yield r.path + path, element
126+
yield r.path, r.element
127+
128+
129+
@dataclass
130+
class _CompiledRoute:
131+
pattern: re.Pattern[str]
132+
converters: dict[str, Callable[[Any], Any]]
133+
element: Any
91134

92135

93136
def _use_location_state() -> _LocationState:
@@ -100,7 +143,7 @@ def _use_location_state() -> _LocationState:
100143
class _LocationState:
101144
location: Location
102145
set_location: Callable[[Location], None]
103-
match: re.Match[str]
146+
params: dict[str, Any]
104147

105148

106149
_LocationStateContext: Context[_LocationState | None] = create_context(None)

requirements/pkg-deps.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
idom >=0.40.2,<0.41
22
typing_extensions
3+
starlette

tests/test_router.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,30 @@
22
from idom import Ref, component, html
33
from idom.testing import BackendFixture, DisplayFixture
44

5-
from idom_router import Link, Route, configure
5+
from idom_router import Link, Route, configure, use_location
66

77

88
@pytest.fixture
99
def Routes(backend: BackendFixture):
1010
return configure(backend.implementation)
1111

1212

13+
def test_configure(backend):
14+
configure(backend.implementation)
15+
configure(backend.implementation.use_location)
16+
with pytest.raises(
17+
TypeError, match="Expected a 'BackendImplementation' or 'use_location' hook"
18+
):
19+
configure(None)
20+
21+
1322
async def test_simple_router(display: DisplayFixture, Routes: Routes):
1423
@component
1524
def Sample():
1625
return Routes(
1726
Route("/a", html.h1({"id": "a"}, "A")),
1827
Route("/b", html.h1({"id": "b"}, "B")),
1928
Route("/c", html.h1({"id": "c"}, "C")),
20-
Route("/c", html.h1({"id": "c"}, "C")),
21-
Route("/*", html.h1({"id": "default"}, "Default")),
2229
)
2330

2431
await display.show(Sample)
@@ -27,8 +34,43 @@ def Sample():
2734
("/a", "#a"),
2835
("/b", "#b"),
2936
("/c", "#c"),
30-
("/default", "#default"),
31-
("/anything", "#default"),
37+
]:
38+
await display.goto(path)
39+
await display.page.wait_for_selector(selector)
40+
41+
await display.goto("/missing")
42+
43+
try:
44+
root_element = display.root_element()
45+
except AttributeError:
46+
root_element = await display.page.wait_for_selector(
47+
f"#display-{display._next_view_id}", state="attached"
48+
)
49+
50+
assert not await root_element.inner_html()
51+
52+
53+
async def test_nested_routes(display: DisplayFixture, Routes: Routes):
54+
@component
55+
def Sample():
56+
return Routes(
57+
Route(
58+
"/a",
59+
html.h1({"id": "a"}, "A"),
60+
Route(
61+
"/b",
62+
html.h1({"id": "b"}, "B"),
63+
Route("/c", html.h1({"id": "c"}, "C")),
64+
),
65+
),
66+
)
67+
68+
await display.show(Sample)
69+
70+
for path, selector in [
71+
("/a", "#a"),
72+
("/a/b", "#b"),
73+
("/a/b/c", "#c"),
3274
]:
3375
await display.goto(path)
3476
await display.page.wait_for_selector(selector)
@@ -45,7 +87,7 @@ def Sample():
4587
Route("/a", Link({"id": "a"}, "A", to="/b")),
4688
Route("/b", Link({"id": "b"}, "B", to="/c")),
4789
Route("/c", Link({"id": "c"}, "C", to="/default")),
48-
Route("/*", html.h1({"id": "default"}, "Default")),
90+
Route("/{path:path}", html.h1({"id": "default"}, "Default")),
4991
)
5092

5193
await display.show(Sample)

0 commit comments

Comments
 (0)