diff --git a/idom_router/__init__.py b/idom_router/__init__.py index 2ccf316..8d0c697 100644 --- a/idom_router/__init__.py +++ b/idom_router/__init__.py @@ -1,22 +1,20 @@ # the version is statically loaded by setup.py __version__ = "0.0.1" -from .router import ( - Route, - RouterConstructor, - create_router, - link, - use_location, - use_params, - use_query, -) +from . import simple +from .core import create_router, link, route, router_component, use_params, use_query +from .types import Route, RouteCompiler, RouteResolver -__all__ = [ +__all__ = ( "create_router", "link", + "route", + "route", "Route", - "RouterConstructor", - "use_location", + "RouteCompiler", + "router_component", + "RouteResolver", + "simple", "use_params", "use_query", -] +) diff --git a/idom_router/core.py b/idom_router/core.py new file mode 100644 index 0000000..408c922 --- /dev/null +++ b/idom_router/core.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Callable, Iterator, Sequence, TypeVar +from urllib.parse import parse_qs + +from idom import ( + component, + create_context, + use_context, + use_location, + use_memo, + use_state, +) +from idom.backend.hooks import ConnectionContext, use_connection +from idom.backend.types import Connection, Location +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.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""" + + def wrapper(*routes: R) -> ComponentType: + return router_component(*routes, compiler=compiler) + + return wrapper + + +@component +def router_component( + *routes: R, + compiler: RouteCompiler[R], +) -> ComponentType | None: + old_conn = use_connection() + location, set_location = use_state(old_conn.location) + + resolvers = use_memo( + lambda: tuple(map(compiler, _iter_routes(routes))), + dependencies=(compiler, hash(routes)), + ) + + match = use_memo(lambda: _match_route(resolvers, location)) + + if match is not None: + element, params = match + return ConnectionContext( + _route_state_context(element, value=_RouteState(set_location, params)), + value=Connection(old_conn.scope, location, old_conn.carrier), + ) + + return None + + +@component +def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: + set_location = _use_route_state().set_location + attrs = { + **attributes, + "to": to, + "onClick": lambda event: set_location(Location(**event)), + } + return _link(attrs, *children) + + +def use_params() -> dict[str, Any]: + """Get parameters from the currently matching route pattern""" + return _use_route_state().params + + +def use_query( + keep_blank_values: bool = False, + strict_parsing: bool = False, + errors: str = "replace", + max_num_fields: int | None = None, + separator: str = "&", +) -> dict[str, list[str]]: + """See :func:`urllib.parse.parse_qs` for parameter info.""" + return parse_qs( + use_location().search[1:], + keep_blank_values=keep_blank_values, + strict_parsing=strict_parsing, + errors=errors, + max_num_fields=max_num_fields, + separator=separator, + ) + + +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) # type: ignore[misc] + yield parent + + +def _match_route( + 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 + + +_link = export( + module_from_file("idom-router", file=Path(__file__).parent / "bundle.js"), + "Link", +) + + +@dataclass +class _RouteState: + set_location: Callable[[Location], None] + 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/router.py b/idom_router/router.py deleted file mode 100644 index af6c84f..0000000 --- a/idom_router/router.py +++ /dev/null @@ -1,160 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass -from pathlib import Path -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.core.types import VdomAttributesAndChildren, VdomDict -from idom.core.vdom import coalesce_attributes_and_children -from idom.types import BackendImplementation, ComponentType, Context, Location -from idom.web.module import export, module_from_file -from starlette.routing import compile_path - -try: - from typing import Protocol -except ImportError: # pragma: no cover - 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 - - -@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 link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDict: - attributes, children = coalesce_attributes_and_children(attributes_or_children) - set_location = _use_location_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 - - -def use_params() -> dict[str, Any]: - """Get parameters from the currently matching route pattern""" - return _use_location_state().params - - -def use_query( - keep_blank_values: bool = False, - strict_parsing: bool = False, - errors: str = "replace", - max_num_fields: int | None = None, - separator: str = "&", -) -> dict[str, list[str]]: - """See :func:`urllib.parse.parse_qs` for parameter info.""" - return parse_qs( - use_location().search[1:], - keep_blank_values=keep_blank_values, - strict_parsing=strict_parsing, - errors=errors, - max_num_fields=max_num_fields, - separator=separator, - ) - - -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): - yield r.path + path, element - 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?" - return location_state - - -@dataclass -class _LocationState: - location: Location - 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", - fallback="⏳", - ), - "Link", -) diff --git a/idom_router/simple.py b/idom_router/simple.py new file mode 100644 index 0000000..7c6901e --- /dev/null +++ b/idom_router/simple.py @@ -0,0 +1,84 @@ +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: + 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 new file mode 100644 index 0000000..092b734 --- /dev/null +++ b/idom_router/types.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Sequence, TypeVar + +from idom.core.vdom import is_vdom +from idom.types import ComponentType, Key +from typing_extensions import Protocol, Self + + +@dataclass(frozen=True) +class Route: + path: str + element: Any = field(hash=False) + routes: Sequence[Self] + + 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""" + + +class RouteResolver(Protocol): + """A compiled route that can be matched against a path""" + + @property + def key(self) -> Key: + """Uniquely identified this resolver""" + + def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: + """Return the path's associated element and path params or None""" 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/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index a61b19f..002aef5 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,3 +1,2 @@ -idom >=0.40.2,<0.41 +idom >=1 typing_extensions -starlette 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) diff --git a/tests/test_router.py b/tests/test_core.py similarity index 63% rename from tests/test_router.py rename to tests/test_core.py index 378ba28..f8c390c 100644 --- a/tests/test_router.py +++ b/tests/test_core.py @@ -1,33 +1,10 @@ -import pytest -from idom import Ref, component, html -from idom.testing import BackendFixture, DisplayFixture +from idom import Ref, component, html, use_location +from idom.testing import DisplayFixture -from idom_router import ( - Route, - RouterConstructor, - create_router, - link, - use_location, - use_params, - use_query, -) +from idom_router import link, route, simple, use_params, use_query -@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("/", "-") @@ -36,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"), @@ -59,7 +36,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" @@ -68,17 +45,17 @@ 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( - 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")), ), ), ) @@ -94,18 +71,18 @@ 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 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("/{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) @@ -121,7 +98,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 @@ -131,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(), ), @@ -157,7 +134,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 @@ -167,7 +144,7 @@ def check_query(): @component def sample(): - return router(Route("/", check_query())) + return simple.router(route("/", check_query())) await display.show(sample) 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}, + )