diff --git a/idom_router/__init__.py b/idom_router/__init__.py index 472e2a9..1859ab9 100644 --- a/idom_router/__init__.py +++ b/idom_router/__init__.py @@ -1,18 +1,15 @@ # the version is statically loaded by setup.py __version__ = "0.0.1" -from .router import ( - Route, - link, - router, - use_location, - use_params, - use_query, -) +from idom_router.types import Route, RouteCompiler, RoutePattern + +from .router import link, router, use_params, use_query __all__ = [ - "Route", "link", + "Route", + "RouteCompiler", + "RoutePattern", "router", "use_location", "use_params", diff --git a/idom_router/compilers.py b/idom_router/compilers.py new file mode 100644 index 0000000..76d5b52 --- /dev/null +++ b/idom_router/compilers.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import re +from typing import Any + +from starlette.convertors import Convertor +from starlette.routing import compile_path as _compile_starlette_path + +from idom_router.types import Route + + +def compile_starlette_route(route: Route) -> StarletteRoutePattern: + pattern, _, converters = _compile_starlette_path(route.path) + return StarletteRoutePattern(pattern, converters) + + +class StarletteRoutePattern: + def __init__( + self, + pattern: re.Pattern[str], + converters: dict[str, Convertor], + ) -> None: + self.pattern = pattern + self.key = pattern.pattern + self.converters = converters + + def match(self, path: str) -> dict[str, Any] | None: + match = self.pattern.match(path) + if match: + return { + k: self.converters[k].convert(v) if k in self.converters else v + for k, v in match.groupdict().items() + } + return None diff --git a/idom_router/router.py b/idom_router/router.py index 4c70423..bdb773d 100644 --- a/idom_router/router.py +++ b/idom_router/router.py @@ -1,70 +1,58 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path -from typing import Any, Callable, Iterator, Sequence +from typing import Any, Callable, Iterator, Sequence, TypeVar from urllib.parse import parse_qs from idom import ( component, create_context, - use_memo, - use_state, use_context, use_location, + use_memo, + use_state, ) -from idom.core.types import VdomAttributesAndChildren, VdomDict -from idom.core.vdom import coalesce_attributes_and_children -from idom.types import ComponentType, Location, Context -from idom.web.module import export, module_from_file from idom.backend.hooks import ConnectionContext, use_connection from idom.backend.types import Connection, Location -from starlette.routing import compile_path as _compile_starlette_path - -from idom_router.types import RoutePattern, RouteCompiler, Route +from idom.core.types import VdomChild, VdomDict +from idom.types import ComponentType, Context, Location +from idom.web.module import export, module_from_file +from idom_router.compilers import compile_starlette_route +from idom_router.types import Route, RouteCompiler, RoutePattern -def compile_starlette_route(route: str) -> RoutePattern: - pattern, _, converters = _compile_starlette_path(route) - return RoutePattern(pattern, {k: v.convert for k, v in converters.items()}) +R = TypeVar("R", bound=Route) @component def router( - *routes: Route, - compiler: RouteCompiler = compile_starlette_route, + *routes: R, + compiler: RouteCompiler[R] = compile_starlette_route, ) -> ComponentType | None: old_conn = use_connection() location, set_location = use_state(old_conn.location) - compiled_routes = use_memo( - lambda: [(compiler(r), e) for r, e in _iter_routes(routes)], - dependencies=routes, - ) - for compiled_route, element in compiled_routes: - match = compiled_route.pattern.match(location.pathname) - if match: - convs = compiled_route.converters - return ConnectionContext( - _route_state_context( - element, - value=_RouteState( - set_location, - { - k: convs[k](v) if k in convs else v - for k, v in match.groupdict().items() - }, - ), - ), - value=Connection(old_conn.scope, location, old_conn.carrier), - key=compiled_route.pattern.pattern, - ) + # Memoize the compiled routes and the match separately so that we don't + # recompile the routes on renders where only the location has changed + compiled_routes = use_memo(lambda: _compile_routes(routes, compiler)) + match = use_memo(lambda: _match_route(compiled_routes, location)) + + if match is not None: + route, params = match + return ConnectionContext( + _route_state_context( + route.element, value=_RouteState(set_location, params) + ), + value=Connection(old_conn.scope, location, old_conn.carrier), + key=route.path, + ) + return None @component -def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDict: - attributes, children = coalesce_attributes_and_children(attributes_or_children) +def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: set_location = _use_route_state().set_location attrs = { **attributes, @@ -76,7 +64,7 @@ def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDic def use_params() -> dict[str, Any]: """Get parameters from the currently matching route pattern""" - return use_context(_route_state_context).params + return _use_route_state().params def use_query( @@ -97,15 +85,27 @@ def use_query( ) -def _use_route_state() -> _RouteState: - return use_context(_route_state_context) +def _compile_routes( + routes: Sequence[R], compiler: RouteCompiler[R] +) -> list[tuple[Any, RoutePattern]]: + return [(r, compiler(r)) for r in _iter_routes(routes)] + + +def _iter_routes(routes: Sequence[R]) -> Iterator[R]: + for parent in routes: + for child in _iter_routes(parent.routes): + yield replace(child, path=parent.path + child.path) + yield parent -def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]: - for r in routes: - for path, element in _iter_routes(r.routes): - yield r.path + path, element - yield r.path, r.element +def _match_route( + compiled_routes: list[tuple[R, RoutePattern]], location: Location +) -> tuple[R, dict[str, Any]] | None: + for route, pattern in compiled_routes: + params = pattern.match(location.pathname) + if params is not None: # explicitely None check (could be empty dict) + return route, params + return None _link = export( @@ -120,4 +120,10 @@ class _RouteState: params: dict[str, Any] +def _use_route_state() -> _RouteState: + route_state = use_context(_route_state_context) + assert route_state is not None + return route_state + + _route_state_context: Context[_RouteState | None] = create_context(None) diff --git a/idom_router/types.py b/idom_router/types.py index ba473f9..5513a17 100644 --- a/idom_router/types.py +++ b/idom_router/types.py @@ -1,28 +1,43 @@ from __future__ import annotations -import re from dataclasses import dataclass -from typing import Callable, Any, Protocol, Sequence +from typing import Any, Sequence, TypeVar + +from idom.types import Key +from typing_extensions import Self, Protocol @dataclass class Route: path: str element: Any - routes: Sequence[Route] - - def __init__(self, path: str, element: Any | None, *routes: Route) -> None: + routes: Sequence[Self] + + def __init__( + self, + path: str, + element: Any | None, + *routes_: Self, + # we need kwarg in order to play nice with the expected dataclass interface + routes: Sequence[Self] = (), + ) -> None: self.path = path self.element = element - self.routes = routes + self.routes = (*routes_, *routes) -class RouteCompiler(Protocol): - def __call__(self, route: str) -> RoutePattern: - ... +R = TypeVar("R", bound=Route, contravariant=True) -@dataclass -class RoutePattern: - pattern: re.Pattern[str] - converters: dict[str, Callable[[Any], Any]] +class RouteCompiler(Protocol[R]): + def __call__(self, route: R) -> RoutePattern: + """Compile a route into a pattern that can be matched against a path""" + + +class RoutePattern(Protocol): + @property + def key(self) -> Key: + """Uniquely identified this pattern""" + + def match(self, path: str) -> dict[str, Any] | None: + """Returns otherwise a dict of path parameters if the path matches, else None""" diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index a61b19f..c205c19 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,3 +1,3 @@ -idom >=0.40.2,<0.41 +idom >=1 typing_extensions starlette diff --git a/tests/test_router.py b/tests/test_router.py index dc8f53b..ce05220 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,16 +1,8 @@ -import re - from idom import Ref, component, html, use_location from idom.testing import DisplayFixture -from idom_router import ( - Route, - router, - link, - use_params, - use_query, -) -from idom_router.types import RoutePattern +from idom_router import Route, link, router, use_params, use_query +from tests.utils import compile_simple_regex_route async def test_simple_router(display: DisplayFixture): @@ -87,10 +79,10 @@ async def test_navigate_with_link(display: DisplayFixture): def sample(): render_count.current += 1 return router( - Route("/", link({"id": "root"}, "Root", to="/a")), - Route("/a", link({"id": "a"}, "A", to="/b")), - Route("/b", link({"id": "b"}, "B", to="/c")), - Route("/c", link({"id": "c"}, "C", to="/default")), + Route("/", link("Root", to="/a", id="root")), + Route("/a", link("A", to="/b", id="a")), + Route("/b", link("B", to="/c", id="b")), + Route("/c", link("C", to="/default", id="c")), Route("/{path:path}", html.h1({"id": "default"}, "Default")), ) @@ -174,25 +166,18 @@ def check_params(): def sample(): return router( Route( - r"/first/(?P\d+)", + r"/first/(?P\d+)", check_params(), Route( - r"/second/(?P[\d\.]+)", + r"/second/(?P[\d\.]+)", check_params(), Route( - r"/third/(?P[\d,]+)", + r"/third/(?P[\d,]+)", check_params(), ), ), ), - compiler=lambda path: RoutePattern( - re.compile(rf"^{path}$"), - { - "first": int, - "second": float, - "third": lambda s: list(map(int, s.split(","))), - }, - ), + compiler=compile_simple_regex_route, ) await display.show(sample) @@ -200,7 +185,10 @@ def sample(): for path, expected_params in [ ("/first/1", {"first": 1}), ("/first/1/second/2.1", {"first": 1, "second": 2.1}), - ("/first/1/second/2.1/third/3,3", {"first": 1, "second": 2.1, "third": [3, 3]}), + ( + "/first/1/second/2.1/third/3,3", + {"first": 1, "second": 2.1, "third": ["3", "3"]}, + ), ]: await display.goto(path) await display.page.wait_for_selector("#success") diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..d0fdc9f --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import re +from typing import Any, Callable + +from idom_router import Route + + +def compile_simple_regex_route(route: Route) -> RegexRoutePattern: + """Compile simple regex route. + + Named regex groups can end with a `__type` suffix to specify a type converter + + For example, `(?P[0-9]+)` will convert the `id` parameter to an `int`. + + Supported types are `int`, `float`, and `list` where `list` will split on `,`. + """ + pattern = re.compile(route.path) + return RegexRoutePattern(pattern) + + +class RegexRoutePattern: + def __init__(self, pattern: re.Pattern) -> None: + self.pattern = pattern + self.key = pattern.pattern + + def match(self, path: str) -> dict[str, str] | None: + match = self.pattern.match(path) + if match: + params: dict[str, Any] = {} + for k, v in match.groupdict().items(): + name, _, type_ = k.partition("__") + try: + params[name] = CONVERTERS.get(type_, DEFAULT_CONVERTER)(v) + except ValueError: + return None + return params + return None + + +CONVERTERS: dict[str, Callable[[str], Any]] = { + "int": int, + "float": float, + "list": lambda s: s.split(","), +} + + +def DEFAULT_CONVERTER(s: str) -> str: + return s