From 15e7804374a098c05696aa51d26fc4d6445e7832 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 20 Oct 2022 00:52:24 -0700 Subject: [PATCH 1/6] initial work on router compiler --- idom_router/__init__.py | 8 ++- idom_router/router.py | 108 +++++++++++++--------------------------- idom_router/types.py | 28 +++++++++++ tests/test_router.py | 71 ++++++++++++++++++-------- 4 files changed, 115 insertions(+), 100 deletions(-) create mode 100644 idom_router/types.py diff --git a/idom_router/__init__.py b/idom_router/__init__.py index 2ccf316..472e2a9 100644 --- a/idom_router/__init__.py +++ b/idom_router/__init__.py @@ -3,19 +3,17 @@ from .router import ( Route, - RouterConstructor, - create_router, link, + router, use_location, use_params, use_query, ) __all__ = [ - "create_router", - "link", "Route", - "RouterConstructor", + "link", + "router", "use_location", "use_params", "use_query", diff --git a/idom_router/router.py b/idom_router/router.py index af6c84f..efadfa6 100644 --- a/idom_router/router.py +++ b/idom_router/router.py @@ -1,6 +1,5 @@ from __future__ import annotations -import re from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Iterator, Sequence @@ -9,9 +8,11 @@ from idom import component, create_context, use_context, use_memo, use_state from idom.core.types import VdomAttributesAndChildren, VdomDict from idom.core.vdom import coalesce_attributes_and_children -from idom.types import BackendImplementation, ComponentType, Context, Location +from idom.types import ComponentType, Context, Location from idom.web.module import export, module_from_file -from starlette.routing import compile_path +from starlette.routing import compile_path as _compile_starlette_path + +from idom_router.types import RoutePattern, RouteCompiler, Route try: from typing import Protocol @@ -19,58 +20,38 @@ from typing_extensions import Protocol # type: ignore -class RouterConstructor(Protocol): - def __call__(self, *routes: Route) -> ComponentType: - ... - - -def create_router( - implementation: BackendImplementation[Any] | Callable[[], Location] -) -> RouterConstructor: - if isinstance(implementation, BackendImplementation): - use_location = implementation.use_location - elif callable(implementation): - use_location = implementation - else: - raise TypeError( - "Expected a 'BackendImplementation' or " - f"'use_location' hook, not {implementation}" - ) - - @component - def router(*routes: Route) -> ComponentType | None: - initial_location = use_location() - location, set_location = use_state(initial_location) - compiled_routes = use_memo( - lambda: _iter_compile_routes(routes), dependencies=routes - ) - for r in compiled_routes: - match = r.pattern.match(location.pathname) - if match: - return _LocationStateContext( - r.element, - value=_LocationState( - location, - set_location, - {k: r.converters[k](v) for k, v in match.groupdict().items()}, - ), - key=r.pattern.pattern, - ) - return None - - return router - +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()}) -@dataclass -class Route: - path: str - element: Any - routes: Sequence[Route] - def __init__(self, path: str, element: Any | None, *routes: Route) -> None: - self.path = path - self.element = element - self.routes = routes +@component +def router( + *routes: Route, + compiler: RouteCompiler = compile_starlette_route, +) -> ComponentType | None: + initial_location = use_location() + location, set_location = use_state(initial_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: + return _LocationStateContext( + element, + value=_LocationState( + location, + set_location, + { + k: compiled_route.converters[k](v) + for k, v in match.groupdict().items() + }, + ), + key=compiled_route.pattern.pattern, + ) + return None @component @@ -113,14 +94,6 @@ def use_query( ) -def _iter_compile_routes(routes: Sequence[Route]) -> Iterator[_CompiledRoute]: - for path, element in _iter_routes(routes): - pattern, _, converters = compile_path(path) - yield _CompiledRoute( - pattern, {k: v.convert for k, v in converters.items()}, element - ) - - def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]: for r in routes: for path, element in _iter_routes(r.routes): @@ -128,13 +101,6 @@ def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]: yield r.path, r.element -@dataclass -class _CompiledRoute: - pattern: re.Pattern[str] - converters: dict[str, Callable[[Any], Any]] - element: Any - - def _use_location_state() -> _LocationState: location_state = use_context(_LocationStateContext) assert location_state is not None, "No location state. Did you use a Router?" @@ -151,10 +117,6 @@ class _LocationState: _LocationStateContext: Context[_LocationState | None] = create_context(None) _Link = export( - module_from_file( - "idom-router", - file=Path(__file__).parent / "bundle.js", - fallback="⏳", - ), + module_from_file("idom-router", file=Path(__file__).parent / "bundle.js"), "Link", ) diff --git a/idom_router/types.py b/idom_router/types.py new file mode 100644 index 0000000..ba473f9 --- /dev/null +++ b/idom_router/types.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Callable, Any, Protocol, Sequence + + +@dataclass +class Route: + path: str + element: Any + routes: Sequence[Route] + + def __init__(self, path: str, element: Any | None, *routes: Route) -> None: + self.path = path + self.element = element + self.routes = routes + + +class RouteCompiler(Protocol): + def __call__(self, route: str) -> RoutePattern: + ... + + +@dataclass +class RoutePattern: + pattern: re.Pattern[str] + converters: dict[str, Callable[[Any], Any]] diff --git a/tests/test_router.py b/tests/test_router.py index 378ba28..f958854 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,33 +1,19 @@ import pytest from idom import Ref, component, html -from idom.testing import BackendFixture, DisplayFixture +from idom.testing import DisplayFixture from idom_router import ( Route, - RouterConstructor, - create_router, + router, link, use_location, use_params, use_query, ) +from idom_router.types import RoutePattern -@pytest.fixture -def router(backend: BackendFixture): - return create_router(backend.implementation) - - -def test_create_router(backend): - create_router(backend.implementation) - create_router(backend.implementation.use_location) - with pytest.raises( - TypeError, match="Expected a 'BackendImplementation' or 'use_location' hook" - ): - create_router(None) - - -async def test_simple_router(display: DisplayFixture, router: RouterConstructor): +async def test_simple_router(display: DisplayFixture): def make_location_check(path, *routes): name = path.lstrip("/").replace("/", "-") @@ -68,7 +54,7 @@ def sample(): assert not await root_element.inner_html() -async def test_nested_routes(display: DisplayFixture, router: RouterConstructor): +async def test_nested_routes(display: DisplayFixture): @component def sample(): return router( @@ -94,7 +80,7 @@ def sample(): await display.page.wait_for_selector(selector) -async def test_navigate_with_link(display: DisplayFixture, router: RouterConstructor): +async def test_navigate_with_link(display: DisplayFixture): render_count = Ref(0) @component @@ -121,7 +107,7 @@ def sample(): assert render_count.current == 1 -async def test_use_params(display: DisplayFixture, router: RouterConstructor): +async def test_use_params(display: DisplayFixture): expected_params = {} @component @@ -157,7 +143,7 @@ def sample(): await display.page.wait_for_selector("#success") -async def test_use_query(display: DisplayFixture, router: RouterConstructor): +async def test_use_query(display: DisplayFixture): expected_query = {} @component @@ -174,3 +160,44 @@ def sample(): expected_query = {"hello": ["world"], "thing": ["1", "2"]} await display.goto("?hello=world&thing=1&thing=2") await display.page.wait_for_selector("#success") + + +def custom_path_compiler(path): + pattern = re.compile(path) + + +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( + "/first/{first:str}", + check_params(), + Route( + "/second/{second:str}", + check_params(), + Route( + "/third/{third:str}", + check_params(), + ), + ), + ), + compiler=lambda path: RoutePattern(re.compile()), + ) + + await display.show(sample) + + for path, expected_params in [ + ("/first/1", {"first": "1"}), + ("/first/1/second/2", {"first": "1", "second": "2"}), + ("/first/1/second/2/third/3", {"first": "1", "second": "2", "third": "3"}), + ]: + await display.goto(path) + await display.page.wait_for_selector("#success") From 6d7254f146402b746c52e26918a6f56f181dc7fc Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sun, 30 Oct 2022 17:06:40 -0700 Subject: [PATCH 2/6] fix tests and upgrade to compat latest idom ver --- idom_router/router.py | 77 ++++++++++++++++++++++--------------------- tests/test_router.py | 33 ++++++++++--------- 2 files changed, 57 insertions(+), 53 deletions(-) diff --git a/idom_router/router.py b/idom_router/router.py index efadfa6..4c70423 100644 --- a/idom_router/router.py +++ b/idom_router/router.py @@ -5,20 +5,24 @@ from typing import Any, Callable, Iterator, Sequence from urllib.parse import parse_qs -from idom import component, create_context, use_context, use_memo, use_state +from idom import ( + component, + create_context, + use_memo, + use_state, + use_context, + use_location, +) from idom.core.types import VdomAttributesAndChildren, VdomDict from idom.core.vdom import coalesce_attributes_and_children -from idom.types import ComponentType, Context, Location +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 -try: - from typing import Protocol -except ImportError: # pragma: no cover - from typing_extensions import Protocol # type: ignore - def compile_starlette_route(route: str) -> RoutePattern: pattern, _, converters = _compile_starlette_path(route) @@ -30,8 +34,9 @@ def router( *routes: Route, compiler: RouteCompiler = compile_starlette_route, ) -> ComponentType | None: - initial_location = use_location() - location, set_location = use_state(initial_location) + 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, @@ -39,16 +44,19 @@ def router( for compiled_route, element in compiled_routes: match = compiled_route.pattern.match(location.pathname) if match: - return _LocationStateContext( - element, - value=_LocationState( - location, - set_location, - { - k: compiled_route.converters[k](v) - for k, v in match.groupdict().items() - }, + 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, ) return None @@ -57,23 +65,18 @@ def router( @component def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDict: attributes, children = coalesce_attributes_and_children(attributes_or_children) - set_location = _use_location_state().set_location + set_location = _use_route_state().set_location attrs = { **attributes, "to": to, "onClick": lambda event: set_location(Location(**event)), } - return _Link(attrs, *children) - - -def use_location() -> Location: - """Get the current route location""" - return _use_location_state().location + return _link(attrs, *children) def use_params() -> dict[str, Any]: """Get parameters from the currently matching route pattern""" - return _use_location_state().params + return use_context(_route_state_context).params def use_query( @@ -94,6 +97,10 @@ def use_query( ) +def _use_route_state() -> _RouteState: + return use_context(_route_state_context) + + def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]: for r in routes: for path, element in _iter_routes(r.routes): @@ -101,22 +108,16 @@ def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]: yield r.path, r.element -def _use_location_state() -> _LocationState: - location_state = use_context(_LocationStateContext) - assert location_state is not None, "No location state. Did you use a Router?" - return location_state +_link = export( + module_from_file("idom-router", file=Path(__file__).parent / "bundle.js"), + "Link", +) @dataclass -class _LocationState: - location: Location +class _RouteState: set_location: Callable[[Location], None] params: dict[str, Any] -_LocationStateContext: Context[_LocationState | None] = create_context(None) - -_Link = export( - module_from_file("idom-router", file=Path(__file__).parent / "bundle.js"), - "Link", -) +_route_state_context: Context[_RouteState | None] = create_context(None) diff --git a/tests/test_router.py b/tests/test_router.py index f958854..dc8f53b 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,12 +1,12 @@ -import pytest -from idom import Ref, component, html +import re + +from idom import Ref, component, html, use_location from idom.testing import DisplayFixture from idom_router import ( Route, router, link, - use_location, use_params, use_query, ) @@ -45,7 +45,7 @@ def sample(): await display.goto("/missing") try: - root_element = display.root_element() + root_element = await display.root_element() except AttributeError: root_element = await display.page.wait_for_selector( f"#display-{display._next_view_id}", state="attached" @@ -162,10 +162,6 @@ def sample(): await display.page.wait_for_selector("#success") -def custom_path_compiler(path): - pattern = re.compile(path) - - async def test_custom_path_compiler(display: DisplayFixture): expected_params = {} @@ -178,26 +174,33 @@ def check_params(): def sample(): return router( Route( - "/first/{first:str}", + r"/first/(?P\d+)", check_params(), Route( - "/second/{second:str}", + r"/second/(?P[\d\.]+)", check_params(), Route( - "/third/{third:str}", + r"/third/(?P[\d,]+)", check_params(), ), ), ), - compiler=lambda path: RoutePattern(re.compile()), + compiler=lambda path: RoutePattern( + re.compile(rf"^{path}$"), + { + "first": int, + "second": float, + "third": lambda s: list(map(int, s.split(","))), + }, + ), ) await display.show(sample) for path, expected_params in [ - ("/first/1", {"first": "1"}), - ("/first/1/second/2", {"first": "1", "second": "2"}), - ("/first/1/second/2/third/3", {"first": "1", "second": "2", "third": "3"}), + ("/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") From 54d6776f6ca029b41da71820a893b29ee9fe5197 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 29 Apr 2023 15:21:52 -0600 Subject: [PATCH 3/6] rework route compiler interface This allows compilers to work in a wider variety of ways. --- idom_router/__init__.py | 15 +++--- idom_router/compilers.py | 34 +++++++++++++ idom_router/router.py | 102 ++++++++++++++++++++------------------ idom_router/types.py | 41 ++++++++++----- requirements/pkg-deps.txt | 2 +- tests/test_router.py | 40 ++++++--------- tests/utils.py | 49 ++++++++++++++++++ 7 files changed, 186 insertions(+), 97 deletions(-) create mode 100644 idom_router/compilers.py create mode 100644 tests/utils.py 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..a86c45e 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 Protocol, Self @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 From 540d0ced64d00ee944a5e570be98fb69d662094f Mon Sep 17 00:00:00 2001 From: rmorshea Date: Sat, 6 May 2023 00:15:28 -0600 Subject: [PATCH 4/6] rework based on feedback --- idom_router/__init__.py | 9 ++++--- idom_router/{router.py => core.py} | 25 ++++++++++++++----- idom_router/routers/__init__.py | 0 .../utils.py => idom_router/routers/regex.py | 7 +++--- .../{compilers.py => routers/starlette.py} | 14 +++++++---- idom_router/types.py | 21 ++++++++++------ 6 files changed, 51 insertions(+), 25 deletions(-) rename idom_router/{router.py => core.py} (83%) create mode 100644 idom_router/routers/__init__.py rename tests/utils.py => idom_router/routers/regex.py (86%) rename idom_router/{compilers.py => routers/starlette.py} (66%) diff --git a/idom_router/__init__.py b/idom_router/__init__.py index 1859ab9..7ef08c0 100644 --- a/idom_router/__init__.py +++ b/idom_router/__init__.py @@ -1,16 +1,17 @@ # the version is statically loaded by setup.py __version__ = "0.0.1" -from idom_router.types import Route, RouteCompiler, RoutePattern +from idom_router.types import Route, RouteCompiler, RouteResolver -from .router import link, router, use_params, use_query +from .core import link, create_router, router_component, use_params, use_query __all__ = [ + "create_router", "link", "Route", "RouteCompiler", - "RoutePattern", - "router", + "router_component", + "RouteResolver", "use_location", "use_params", "use_query", diff --git a/idom_router/router.py b/idom_router/core.py similarity index 83% rename from idom_router/router.py rename to idom_router/core.py index bdb773d..73b32a9 100644 --- a/idom_router/router.py +++ b/idom_router/core.py @@ -19,19 +19,32 @@ 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 +from idom_router.types import Route, RouteCompiler, RouteResolver, Router R = TypeVar("R", bound=Route) +def create_router(compiler: RouteCompiler[R]) -> Router[R]: + """A decorator that turns a route compiler into a router""" + + def wrapper(*routes: R) -> ComponentType: + return router_component(*routes, compiler=compiler) + + return wrapper + + @component -def router( +def router_component( *routes: R, - compiler: RouteCompiler[R] = compile_starlette_route, + compiler: RouteCompiler[R], ) -> 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") # Memoize the compiled routes and the match separately so that we don't # recompile the routes on renders where only the location has changed @@ -87,7 +100,7 @@ def use_query( def _compile_routes( routes: Sequence[R], compiler: RouteCompiler[R] -) -> list[tuple[Any, RoutePattern]]: +) -> list[tuple[Any, RouteResolver]]: return [(r, compiler(r)) for r in _iter_routes(routes)] @@ -99,7 +112,7 @@ def _iter_routes(routes: Sequence[R]) -> Iterator[R]: def _match_route( - compiled_routes: list[tuple[R, RoutePattern]], location: Location + 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) diff --git a/idom_router/routers/__init__.py b/idom_router/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils.py b/idom_router/routers/regex.py similarity index 86% rename from tests/utils.py rename to idom_router/routers/regex.py index d0fdc9f..567215c 100644 --- a/tests/utils.py +++ b/idom_router/routers/regex.py @@ -1,19 +1,20 @@ from __future__ import annotations import re +from uuid import UUID from typing import Any, Callable from idom_router import Route -def compile_simple_regex_route(route: Route) -> RegexRoutePattern: +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 `list` where `list` will split on `,`. + Supported types are `int`, `float`, and `uuid`. """ pattern = re.compile(route.path) return RegexRoutePattern(pattern) @@ -41,7 +42,7 @@ def match(self, path: str) -> dict[str, str] | None: CONVERTERS: dict[str, Callable[[str], Any]] = { "int": int, "float": float, - "list": lambda s: s.split(","), + "uuid": UUID, } diff --git a/idom_router/compilers.py b/idom_router/routers/starlette.py similarity index 66% rename from idom_router/compilers.py rename to idom_router/routers/starlette.py index 76d5b52..8668afd 100644 --- a/idom_router/compilers.py +++ b/idom_router/routers/starlette.py @@ -4,17 +4,18 @@ from typing import Any from starlette.convertors import Convertor -from starlette.routing import compile_path as _compile_starlette_path +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) -> StarletteRoutePattern: - pattern, _, converters = _compile_starlette_path(route.path) - return StarletteRoutePattern(pattern, converters) +def compile_starlette_route(route: Route) -> StarletteRouteResolver: + pattern, _, converters = compile_path(route.path) + return StarletteRouteResolver(pattern, converters) -class StarletteRoutePattern: +class StarletteRouteResolver: def __init__( self, pattern: re.Pattern[str], @@ -32,3 +33,6 @@ def match(self, path: str) -> dict[str, Any] | None: for k, v in match.groupdict().items() } return None + + +starlette_router = create_router(compile_starlette_route) diff --git a/idom_router/types.py b/idom_router/types.py index a86c45e..813a5df 100644 --- a/idom_router/types.py +++ b/idom_router/types.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Any, Sequence, TypeVar -from idom.types import Key +from idom.types import Key, ComponentType from typing_extensions import Protocol, Self @@ -26,18 +26,25 @@ def __init__( self.routes = (*routes_, *routes) +class Router(Protocol): + def __call__(self, *routes: Route) -> ComponentType: + """Return a component that renders the first matching route""" + + R = TypeVar("R", bound=Route, contravariant=True) class RouteCompiler(Protocol[R]): - def __call__(self, route: R) -> RoutePattern: - """Compile a route into a pattern that can be matched against a path""" + def __call__(self, route: R) -> RouteResolver: + """Compile a route into a resolver that can be matched against a path""" + +class RouteResolver(Protocol): + """A compiled route that can be matched against a path""" -class RoutePattern(Protocol): @property def key(self) -> Key: - """Uniquely identified this pattern""" + """Uniquely identified this resolver""" - def match(self, path: str) -> dict[str, Any] | None: - """Returns otherwise a dict of path parameters if the path matches, else None""" + def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: + """Return the path's associated element and path params or None""" From 2f8f853d751b4389701b94f91cb79b2e4c7bd09f Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 10 May 2023 00:30:28 -0600 Subject: [PATCH 5/6] remove starlette as dep --- idom_router/__init__.py | 14 +++-- idom_router/core.py | 46 ++++++-------- idom_router/routers/__init__.py | 0 idom_router/routers/regex.py | 50 --------------- idom_router/routers/starlette.py | 38 ------------ idom_router/simple.py | 86 ++++++++++++++++++++++++++ idom_router/types.py | 34 +++++----- requirements/pkg-deps.txt | 1 - tests/{test_router.py => test_core.py} | 77 ++++++----------------- tests/test_simple.py | 50 +++++++++++++++ 10 files changed, 195 insertions(+), 201 deletions(-) delete mode 100644 idom_router/routers/__init__.py delete mode 100644 idom_router/routers/regex.py delete mode 100644 idom_router/routers/starlette.py create mode 100644 idom_router/simple.py rename tests/{test_router.py => test_core.py} (64%) create mode 100644 tests/test_simple.py 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..c61a0b0 --- /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 TypeAlias, TypedDict + +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}, + ) From a169d6f4ef436cf530cd764c460bf15a0efc24c7 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 10 May 2023 21:43:29 -0600 Subject: [PATCH 6/6] remove print --- idom_router/simple.py | 2 -- requirements/check-style.txt | 1 + setup.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/idom_router/simple.py b/idom_router/simple.py index c61a0b0..7c6901e 100644 --- a/idom_router/simple.py +++ b/idom_router/simple.py @@ -24,8 +24,6 @@ def __init__(self, route: Route) -> None: 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 ( diff --git a/requirements/check-style.txt b/requirements/check-style.txt index ea105f4..e8e2fb4 100644 --- a/requirements/check-style.txt +++ b/requirements/check-style.txt @@ -1,4 +1,5 @@ black flake8 +flake8-print flake8_idom_hooks isort diff --git a/setup.py b/setup.py index dc0fefa..cde7673 100644 --- a/setup.py +++ b/setup.py @@ -71,7 +71,7 @@ package["version"] = eval(line.split("=", 1)[1]) break else: - print("No version found in %s/__init__.py" % package_dir) + print("No version found in %s/__init__.py" % package_dir) # noqa: T201 sys.exit(1)