Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 2f8f853

Browse files
committedMay 10, 2023
remove starlette as dep
1 parent 540d0ce commit 2f8f853

File tree

10 files changed

+195
-201
lines changed

10 files changed

+195
-201
lines changed
 

‎idom_router/__init__.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
# the version is statically loaded by setup.py
22
__version__ = "0.0.1"
33

4-
from idom_router.types import Route, RouteCompiler, RouteResolver
4+
from . import simple
5+
from .core import create_router, link, route, router_component, use_params, use_query
6+
from .types import Route, RouteCompiler, RouteResolver
57

6-
from .core import link, create_router, router_component, use_params, use_query
7-
8-
__all__ = [
8+
__all__ = (
99
"create_router",
1010
"link",
11+
"route",
12+
"route",
1113
"Route",
1214
"RouteCompiler",
1315
"router_component",
1416
"RouteResolver",
15-
"use_location",
17+
"simple",
1618
"use_params",
1719
"use_query",
18-
]
20+
)

‎idom_router/core.py

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@
1919
from idom.types import ComponentType, Context, Location
2020
from idom.web.module import export, module_from_file
2121

22-
from idom_router.types import Route, RouteCompiler, RouteResolver, Router
22+
from idom_router.types import Route, RouteCompiler, Router, RouteResolver
2323

2424
R = TypeVar("R", bound=Route)
2525

2626

27+
def route(path: str, element: Any | None, *routes: Route) -> Route:
28+
return Route(path, element, routes)
29+
30+
2731
def create_router(compiler: RouteCompiler[R]) -> Router[R]:
2832
"""A decorator that turns a route compiler into a router"""
2933

@@ -40,25 +44,19 @@ def router_component(
4044
) -> ComponentType | None:
4145
old_conn = use_connection()
4246
location, set_location = use_state(old_conn.location)
43-
router_state = use_context(_route_state_context)
44-
4547

46-
if router_state is not None:
47-
raise RuntimeError("Another router is already active in this context")
48+
resolvers = use_memo(
49+
lambda: tuple(map(compiler, _iter_routes(routes))),
50+
dependencies=(compiler, hash(routes)),
51+
)
4852

49-
# Memoize the compiled routes and the match separately so that we don't
50-
# recompile the routes on renders where only the location has changed
51-
compiled_routes = use_memo(lambda: _compile_routes(routes, compiler))
52-
match = use_memo(lambda: _match_route(compiled_routes, location))
53+
match = use_memo(lambda: _match_route(resolvers, location))
5354

5455
if match is not None:
55-
route, params = match
56+
element, params = match
5657
return ConnectionContext(
57-
_route_state_context(
58-
route.element, value=_RouteState(set_location, params)
59-
),
58+
_route_state_context(element, value=_RouteState(set_location, params)),
6059
value=Connection(old_conn.scope, location, old_conn.carrier),
61-
key=route.path,
6260
)
6361

6462
return None
@@ -98,26 +96,20 @@ def use_query(
9896
)
9997

10098

101-
def _compile_routes(
102-
routes: Sequence[R], compiler: RouteCompiler[R]
103-
) -> list[tuple[Any, RouteResolver]]:
104-
return [(r, compiler(r)) for r in _iter_routes(routes)]
105-
106-
10799
def _iter_routes(routes: Sequence[R]) -> Iterator[R]:
108100
for parent in routes:
109101
for child in _iter_routes(parent.routes):
110-
yield replace(child, path=parent.path + child.path)
102+
yield replace(child, path=parent.path + child.path) # type: ignore[misc]
111103
yield parent
112104

113105

114106
def _match_route(
115-
compiled_routes: list[tuple[R, RouteResolver]], location: Location
116-
) -> tuple[R, dict[str, Any]] | None:
117-
for route, pattern in compiled_routes:
118-
params = pattern.match(location.pathname)
119-
if params is not None: # explicitely None check (could be empty dict)
120-
return route, params
107+
compiled_routes: Sequence[RouteResolver], location: Location
108+
) -> tuple[Any, dict[str, Any]] | None:
109+
for resolver in compiled_routes:
110+
match = resolver.resolve(location.pathname)
111+
if match is not None:
112+
return match
121113
return None
122114

123115

‎idom_router/routers/__init__.py

Whitespace-only changes.

‎idom_router/routers/regex.py

Lines changed: 0 additions & 50 deletions
This file was deleted.

‎idom_router/routers/starlette.py

Lines changed: 0 additions & 38 deletions
This file was deleted.

‎idom_router/simple.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import re
4+
import uuid
5+
from typing import Any, Callable
6+
7+
from typing_extensions import TypeAlias, TypedDict
8+
9+
from idom_router.core import create_router
10+
from idom_router.types import Route
11+
12+
__all__ = ["router"]
13+
14+
ConversionFunc: TypeAlias = "Callable[[str], Any]"
15+
ConverterMapping: TypeAlias = "dict[str, ConversionFunc]"
16+
17+
PARAM_REGEX = re.compile(r"{(?P<name>\w+)(?P<type>:\w+)?}")
18+
19+
20+
class SimpleResolver:
21+
def __init__(self, route: Route) -> None:
22+
self.element = route.element
23+
self.pattern, self.converters = parse_path(route.path)
24+
self.key = self.pattern.pattern
25+
26+
def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
27+
print(path)
28+
print(self.key)
29+
match = self.pattern.match(path)
30+
if match:
31+
return (
32+
self.element,
33+
{k: self.converters[k](v) for k, v in match.groupdict().items()},
34+
)
35+
return None
36+
37+
38+
def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]:
39+
pattern = "^"
40+
last_match_end = 0
41+
converters: ConverterMapping = {}
42+
for match in PARAM_REGEX.finditer(path):
43+
param_name = match.group("name")
44+
param_type = (match.group("type") or "str").lstrip(":")
45+
try:
46+
param_conv = CONVERSION_TYPES[param_type]
47+
except KeyError:
48+
raise ValueError(f"Unknown conversion type {param_type!r} in {path!r}")
49+
pattern += re.escape(path[last_match_end : match.start()])
50+
pattern += f"(?P<{param_name}>{param_conv['regex']})"
51+
converters[param_name] = param_conv["func"]
52+
last_match_end = match.end()
53+
pattern += re.escape(path[last_match_end:]) + "$"
54+
return re.compile(pattern), converters
55+
56+
57+
class ConversionInfo(TypedDict):
58+
regex: str
59+
func: ConversionFunc
60+
61+
62+
CONVERSION_TYPES: dict[str, ConversionInfo] = {
63+
"str": {
64+
"regex": r"[^/]+",
65+
"func": str,
66+
},
67+
"int": {
68+
"regex": r"\d+",
69+
"func": int,
70+
},
71+
"float": {
72+
"regex": r"\d+(\.\d+)?",
73+
"func": float,
74+
},
75+
"uuid": {
76+
"regex": r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
77+
"func": uuid.UUID,
78+
},
79+
"path": {
80+
"regex": r".+",
81+
"func": str,
82+
},
83+
}
84+
85+
86+
router = create_router(SimpleResolver)

‎idom_router/types.py

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,33 @@
11
from __future__ import annotations
22

3-
from dataclasses import dataclass
3+
from dataclasses import dataclass, field
44
from typing import Any, Sequence, TypeVar
55

6-
from idom.types import Key, ComponentType
6+
from idom.core.vdom import is_vdom
7+
from idom.types import ComponentType, Key
78
from typing_extensions import Protocol, Self
89

910

10-
@dataclass
11+
@dataclass(frozen=True)
1112
class Route:
1213
path: str
13-
element: Any
14+
element: Any = field(hash=False)
1415
routes: Sequence[Self]
1516

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:
24-
self.path = path
25-
self.element = element
26-
self.routes = (*routes_, *routes)
27-
28-
29-
class Router(Protocol):
30-
def __call__(self, *routes: Route) -> ComponentType:
31-
"""Return a component that renders the first matching route"""
17+
def __hash__(self) -> int:
18+
el = self.element
19+
key = el["key"] if is_vdom(el) and "key" in el else getattr(el, "key", id(el))
20+
return hash((self.path, key, self.routes))
3221

3322

3423
R = TypeVar("R", bound=Route, contravariant=True)
3524

3625

26+
class Router(Protocol[R]):
27+
def __call__(self, *routes: R) -> ComponentType:
28+
"""Return a component that renders the first matching route"""
29+
30+
3731
class RouteCompiler(Protocol[R]):
3832
def __call__(self, route: R) -> RouteResolver:
3933
"""Compile a route into a resolver that can be matched against a path"""

‎requirements/pkg-deps.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
idom >=1
22
typing_extensions
3-
starlette

‎tests/test_router.py renamed to ‎tests/test_core.py

Lines changed: 18 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
from idom import Ref, component, html, use_location
22
from idom.testing import DisplayFixture
33

4-
from idom_router import Route, link, router, use_params, use_query
5-
from tests.utils import compile_simple_regex_route
4+
from idom_router import link, route, simple, use_params, use_query
65

76

87
async def test_simple_router(display: DisplayFixture):
@@ -14,11 +13,11 @@ def check_location():
1413
assert use_location().pathname == path
1514
return html.h1({"id": name}, path)
1615

17-
return Route(path, check_location(), *routes)
16+
return route(path, check_location(), *routes)
1817

1918
@component
2019
def sample():
21-
return router(
20+
return simple.router(
2221
make_location_check("/a"),
2322
make_location_check("/b"),
2423
make_location_check("/c"),
@@ -49,14 +48,14 @@ def sample():
4948
async def test_nested_routes(display: DisplayFixture):
5049
@component
5150
def sample():
52-
return router(
53-
Route(
51+
return simple.router(
52+
route(
5453
"/a",
5554
html.h1({"id": "a"}, "A"),
56-
Route(
55+
route(
5756
"/b",
5857
html.h1({"id": "b"}, "B"),
59-
Route("/c", html.h1({"id": "c"}, "C")),
58+
route("/c", html.h1({"id": "c"}, "C")),
6059
),
6160
),
6261
)
@@ -78,12 +77,12 @@ async def test_navigate_with_link(display: DisplayFixture):
7877
@component
7978
def sample():
8079
render_count.current += 1
81-
return router(
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")),
86-
Route("/{path:path}", html.h1({"id": "default"}, "Default")),
80+
return simple.router(
81+
route("/", link("Root", to="/a", id="root")),
82+
route("/a", link("A", to="/b", id="a")),
83+
route("/b", link("B", to="/c", id="b")),
84+
route("/c", link("C", to="/default", id="c")),
85+
route("/{path:path}", html.h1({"id": "default"}, "Default")),
8786
)
8887

8988
await display.show(sample)
@@ -109,14 +108,14 @@ def check_params():
109108

110109
@component
111110
def sample():
112-
return router(
113-
Route(
111+
return simple.router(
112+
route(
114113
"/first/{first:str}",
115114
check_params(),
116-
Route(
115+
route(
117116
"/second/{second:str}",
118117
check_params(),
119-
Route(
118+
route(
120119
"/third/{third:str}",
121120
check_params(),
122121
),
@@ -145,50 +144,10 @@ def check_query():
145144

146145
@component
147146
def sample():
148-
return router(Route("/", check_query()))
147+
return simple.router(route("/", check_query()))
149148

150149
await display.show(sample)
151150

152151
expected_query = {"hello": ["world"], "thing": ["1", "2"]}
153152
await display.goto("?hello=world&thing=1&thing=2")
154153
await display.page.wait_for_selector("#success")
155-
156-
157-
async def test_custom_path_compiler(display: DisplayFixture):
158-
expected_params = {}
159-
160-
@component
161-
def check_params():
162-
assert use_params() == expected_params
163-
return html.h1({"id": "success"}, "success")
164-
165-
@component
166-
def sample():
167-
return router(
168-
Route(
169-
r"/first/(?P<first__int>\d+)",
170-
check_params(),
171-
Route(
172-
r"/second/(?P<second__float>[\d\.]+)",
173-
check_params(),
174-
Route(
175-
r"/third/(?P<third__list>[\d,]+)",
176-
check_params(),
177-
),
178-
),
179-
),
180-
compiler=compile_simple_regex_route,
181-
)
182-
183-
await display.show(sample)
184-
185-
for path, expected_params in [
186-
("/first/1", {"first": 1}),
187-
("/first/1/second/2.1", {"first": 1, "second": 2.1}),
188-
(
189-
"/first/1/second/2.1/third/3,3",
190-
{"first": 1, "second": 2.1, "third": ["3", "3"]},
191-
),
192-
]:
193-
await display.goto(path)
194-
await display.page.wait_for_selector("#success")

‎tests/test_simple.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import re
2+
import uuid
3+
4+
import pytest
5+
6+
from idom_router.simple import parse_path
7+
8+
9+
def test_parse_path():
10+
assert parse_path("/a/b/c") == (re.compile("^/a/b/c$"), {})
11+
assert parse_path("/a/{b}/c") == (
12+
re.compile(r"^/a/(?P<b>[^/]+)/c$"),
13+
{"b": str},
14+
)
15+
assert parse_path("/a/{b:int}/c") == (
16+
re.compile(r"^/a/(?P<b>\d+)/c$"),
17+
{"b": int},
18+
)
19+
assert parse_path("/a/{b:int}/{c:float}/c") == (
20+
re.compile(r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/c$"),
21+
{"b": int, "c": float},
22+
)
23+
assert parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == (
24+
re.compile(
25+
r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-["
26+
r"0-9a-f]{4}-[0-9a-f]{12})/c$"
27+
),
28+
{"b": int, "c": float, "d": uuid.UUID},
29+
)
30+
assert parse_path("/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c") == (
31+
re.compile(
32+
r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-["
33+
r"0-9a-f]{4}-[0-9a-f]{12})/(?P<e>.+)/c$"
34+
),
35+
{"b": int, "c": float, "d": uuid.UUID, "e": str},
36+
)
37+
38+
39+
def test_parse_path_unkown_conversion():
40+
with pytest.raises(ValueError):
41+
parse_path("/a/{b:unknown}/c")
42+
43+
44+
def test_parse_path_re_escape():
45+
"""Check that we escape regex characters in the path"""
46+
assert parse_path("/a/{b:int}/c.d") == (
47+
# ^ regex character
48+
re.compile(r"^/a/(?P<b>\d+)/c\.d$"),
49+
{"b": int},
50+
)

0 commit comments

Comments
 (0)
Please sign in to comment.