diff --git a/idom_router/__init__.py b/idom_router/__init__.py index 7ef08c0..8d0c697 100644 --- a/idom_router/__init__.py +++ b/idom_router/__init__.py @@ -1,18 +1,20 @@ # the version is statically loaded by setup.py __version__ = "0.0.1" -from idom_router.types import Route, RouteCompiler, RouteResolver +from . import simple +from .core import create_router, link, route, router_component, use_params, use_query +from .types import Route, RouteCompiler, RouteResolver -from .core import link, create_router, router_component, use_params, use_query - -__all__ = [ +__all__ = ( "create_router", "link", + "route", + "route", "Route", "RouteCompiler", "router_component", "RouteResolver", - "use_location", + "simple", "use_params", "use_query", -] +) diff --git a/idom_router/core.py b/idom_router/core.py index 73b32a9..408c922 100644 --- a/idom_router/core.py +++ b/idom_router/core.py @@ -19,11 +19,15 @@ from idom.types import ComponentType, Context, Location from idom.web.module import export, module_from_file -from idom_router.types import Route, RouteCompiler, RouteResolver, Router +from idom_router.types import Route, RouteCompiler, Router, RouteResolver R = TypeVar("R", bound=Route) +def route(path: str, element: Any | None, *routes: Route) -> Route: + return Route(path, element, routes) + + def create_router(compiler: RouteCompiler[R]) -> Router[R]: """A decorator that turns a route compiler into a router""" @@ -40,25 +44,19 @@ def router_component( ) -> ComponentType | None: old_conn = use_connection() location, set_location = use_state(old_conn.location) - router_state = use_context(_route_state_context) - - if router_state is not None: - raise RuntimeError("Another router is already active in this context") + resolvers = use_memo( + lambda: tuple(map(compiler, _iter_routes(routes))), + dependencies=(compiler, hash(routes)), + ) - # 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)) + match = use_memo(lambda: _match_route(resolvers, location)) if match is not None: - route, params = match + element, params = match return ConnectionContext( - _route_state_context( - route.element, value=_RouteState(set_location, params) - ), + _route_state_context(element, value=_RouteState(set_location, params)), value=Connection(old_conn.scope, location, old_conn.carrier), - key=route.path, ) return None @@ -98,26 +96,20 @@ def use_query( ) -def _compile_routes( - routes: Sequence[R], compiler: RouteCompiler[R] -) -> list[tuple[Any, RouteResolver]]: - 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 replace(child, path=parent.path + child.path) # type: ignore[misc] yield parent def _match_route( - compiled_routes: list[tuple[R, RouteResolver]], 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 + compiled_routes: Sequence[RouteResolver], location: Location +) -> tuple[Any, dict[str, Any]] | None: + for resolver in compiled_routes: + match = resolver.resolve(location.pathname) + if match is not None: + return match return None diff --git a/idom_router/routers/__init__.py b/idom_router/routers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/idom_router/routers/regex.py b/idom_router/routers/regex.py deleted file mode 100644 index 567215c..0000000 --- a/idom_router/routers/regex.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -import re -from uuid import UUID -from typing import Any, Callable - -from idom_router import Route - - -def compile_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 `uuid`. - """ - 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, - "uuid": UUID, -} - - -def DEFAULT_CONVERTER(s: str) -> str: - return s diff --git a/idom_router/routers/starlette.py b/idom_router/routers/starlette.py deleted file mode 100644 index 8668afd..0000000 --- a/idom_router/routers/starlette.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -import re -from typing import Any - -from starlette.convertors import Convertor -from starlette.routing import compile_path - -from idom_router.types import Route -from idom_router.core import create_router - - -def compile_starlette_route(route: Route) -> StarletteRouteResolver: - pattern, _, converters = compile_path(route.path) - return StarletteRouteResolver(pattern, converters) - - -class StarletteRouteResolver: - 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 - - -starlette_router = create_router(compile_starlette_route) diff --git a/idom_router/simple.py b/idom_router/simple.py new file mode 100644 index 0000000..c9b203e --- /dev/null +++ b/idom_router/simple.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import re +import uuid +from typing import Any, Callable + +from typing_extensions import TypedDict, TypeAlias + +from idom_router.core import create_router +from idom_router.types import Route + +__all__ = ["router"] + +ConversionFunc: TypeAlias = "Callable[[str], Any]" +ConverterMapping: TypeAlias = "dict[str, ConversionFunc]" + +PARAM_REGEX = re.compile(r"{(?P\w+)(?P:\w+)?}") + + +class SimpleResolver: + def __init__(self, route: Route) -> None: + self.element = route.element + self.pattern, self.converters = parse_path(route.path) + self.key = self.pattern.pattern + + def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: + print(path) + print(self.key) + match = self.pattern.match(path) + if match: + return ( + self.element, + {k: self.converters[k](v) for k, v in match.groupdict().items()}, + ) + return None + + +def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]: + pattern = "^" + last_match_end = 0 + converters: ConverterMapping = {} + for match in PARAM_REGEX.finditer(path): + param_name = match.group("name") + param_type = (match.group("type") or "str").lstrip(":") + try: + param_conv = CONVERSION_TYPES[param_type] + except KeyError: + raise ValueError(f"Unknown conversion type {param_type!r} in {path!r}") + pattern += re.escape(path[last_match_end : match.start()]) + pattern += f"(?P<{param_name}>{param_conv['regex']})" + converters[param_name] = param_conv["func"] + last_match_end = match.end() + pattern += re.escape(path[last_match_end:]) + "$" + return re.compile(pattern), converters + + +class ConversionInfo(TypedDict): + regex: str + func: ConversionFunc + + +CONVERSION_TYPES: dict[str, ConversionInfo] = { + "str": { + "regex": r"[^/]+", + "func": str, + }, + "int": { + "regex": r"\d+", + "func": int, + }, + "float": { + "regex": r"\d+(\.\d+)?", + "func": float, + }, + "uuid": { + "regex": r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + "func": uuid.UUID, + }, + "path": { + "regex": r".+", + "func": str, + }, +} + + +router = create_router(SimpleResolver) diff --git a/idom_router/types.py b/idom_router/types.py index 813a5df..092b734 100644 --- a/idom_router/types.py +++ b/idom_router/types.py @@ -1,39 +1,33 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Sequence, TypeVar -from idom.types import Key, ComponentType +from idom.core.vdom import is_vdom +from idom.types import ComponentType, Key from typing_extensions import Protocol, Self -@dataclass +@dataclass(frozen=True) class Route: path: str - element: Any + element: Any = field(hash=False) 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_, *routes) - - -class Router(Protocol): - def __call__(self, *routes: Route) -> ComponentType: - """Return a component that renders the first matching route""" + def __hash__(self) -> int: + el = self.element + key = el["key"] if is_vdom(el) and "key" in el else getattr(el, "key", id(el)) + return hash((self.path, key, self.routes)) R = TypeVar("R", bound=Route, contravariant=True) +class Router(Protocol[R]): + def __call__(self, *routes: R) -> ComponentType: + """Return a component that renders the first matching route""" + + class RouteCompiler(Protocol[R]): def __call__(self, route: R) -> RouteResolver: """Compile a route into a resolver that can be matched against a path""" diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index c205c19..002aef5 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,3 +1,2 @@ idom >=1 typing_extensions -starlette diff --git a/tests/test_router.py b/tests/test_core.py similarity index 64% rename from tests/test_router.py rename to tests/test_core.py index ce05220..f8c390c 100644 --- a/tests/test_router.py +++ b/tests/test_core.py @@ -1,8 +1,7 @@ from idom import Ref, component, html, use_location from idom.testing import DisplayFixture -from idom_router import Route, link, router, use_params, use_query -from tests.utils import compile_simple_regex_route +from idom_router import link, route, simple, use_params, use_query async def test_simple_router(display: DisplayFixture): @@ -14,11 +13,11 @@ def check_location(): assert use_location().pathname == path return html.h1({"id": name}, path) - return Route(path, check_location(), *routes) + return route(path, check_location(), *routes) @component def sample(): - return router( + return simple.router( make_location_check("/a"), make_location_check("/b"), make_location_check("/c"), @@ -49,14 +48,14 @@ def sample(): async def test_nested_routes(display: DisplayFixture): @component def sample(): - return router( - Route( + return simple.router( + route( "/a", html.h1({"id": "a"}, "A"), - Route( + route( "/b", html.h1({"id": "b"}, "B"), - Route("/c", html.h1({"id": "c"}, "C")), + route("/c", html.h1({"id": "c"}, "C")), ), ), ) @@ -78,12 +77,12 @@ async def test_navigate_with_link(display: DisplayFixture): @component def sample(): render_count.current += 1 - return router( - 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")), + return simple.router( + 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")), ) await display.show(sample) @@ -109,14 +108,14 @@ def check_params(): @component def sample(): - return router( - Route( + return simple.router( + route( "/first/{first:str}", check_params(), - Route( + route( "/second/{second:str}", check_params(), - Route( + route( "/third/{third:str}", check_params(), ), @@ -145,50 +144,10 @@ def check_query(): @component def sample(): - return router(Route("/", check_query())) + return simple.router(route("/", check_query())) await display.show(sample) expected_query = {"hello": ["world"], "thing": ["1", "2"]} await display.goto("?hello=world&thing=1&thing=2") await display.page.wait_for_selector("#success") - - -async def test_custom_path_compiler(display: DisplayFixture): - expected_params = {} - - @component - def check_params(): - assert use_params() == expected_params - return html.h1({"id": "success"}, "success") - - @component - def sample(): - return router( - Route( - r"/first/(?P\d+)", - check_params(), - Route( - r"/second/(?P[\d\.]+)", - check_params(), - Route( - r"/third/(?P[\d,]+)", - check_params(), - ), - ), - ), - compiler=compile_simple_regex_route, - ) - - await display.show(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"]}, - ), - ]: - await display.goto(path) - await display.page.wait_for_selector("#success") diff --git a/tests/test_simple.py b/tests/test_simple.py new file mode 100644 index 0000000..2047eae --- /dev/null +++ b/tests/test_simple.py @@ -0,0 +1,50 @@ +import re +import uuid + +import pytest + +from idom_router.simple import parse_path + + +def test_parse_path(): + assert parse_path("/a/b/c") == (re.compile("^/a/b/c$"), {}) + assert parse_path("/a/{b}/c") == ( + re.compile(r"^/a/(?P[^/]+)/c$"), + {"b": str}, + ) + assert parse_path("/a/{b:int}/c") == ( + re.compile(r"^/a/(?P\d+)/c$"), + {"b": int}, + ) + assert parse_path("/a/{b:int}/{c:float}/c") == ( + re.compile(r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/c$"), + {"b": int, "c": float}, + ) + assert parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == ( + re.compile( + r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[" + r"0-9a-f]{4}-[0-9a-f]{12})/c$" + ), + {"b": int, "c": float, "d": uuid.UUID}, + ) + assert parse_path("/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c") == ( + re.compile( + r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[" + r"0-9a-f]{4}-[0-9a-f]{12})/(?P.+)/c$" + ), + {"b": int, "c": float, "d": uuid.UUID, "e": str}, + ) + + +def test_parse_path_unkown_conversion(): + with pytest.raises(ValueError): + parse_path("/a/{b:unknown}/c") + + +def test_parse_path_re_escape(): + """Check that we escape regex characters in the path""" + assert parse_path("/a/{b:int}/c.d") == ( + # ^ regex character + re.compile(r"^/a/(?P\d+)/c\.d$"), + {"b": int}, + )