From 88ec72faa8572c308baa4fd25464763fdd74c20c Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 12 Jan 2025 03:07:08 -0800 Subject: [PATCH] Custom router API (#51) --- CHANGELOG.md | 10 +++ .../python/custom_router_easy_resolver.py | 16 +++++ .../python/custom_router_easy_router.py | 6 ++ docs/examples/python/example/__init__.py | 0 docs/examples/python/example/resolvers.py | 4 ++ docs/mkdocs.yml | 2 +- docs/src/learn/custom-router.md | 29 +++++++- src/reactpy_router/resolvers.py | 30 ++++---- src/reactpy_router/routers.py | 71 +++++++------------ src/reactpy_router/types.py | 36 +++++++--- tests/test_resolver.py | 27 +++++-- 11 files changed, 150 insertions(+), 81 deletions(-) create mode 100644 docs/examples/python/custom_router_easy_resolver.py create mode 100644 docs/examples/python/custom_router_easy_router.py create mode 100644 docs/examples/python/example/__init__.py create mode 100644 docs/examples/python/example/resolvers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fced87..7fc4f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,12 +19,22 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] +### Added + +- Support for custom routers. + ### Changed - Set maximum ReactPy version to `<2.0.0`. - Set minimum ReactPy version to `1.1.0`. - `link` element now calculates URL changes using the client. - Refactoring related to `reactpy>=1.1.0` changes. +- Changed ReactPy-Router's method of waiting for the initial URL to be deterministic. +- Rename `StarletteResolver` to `ReactPyResolver`. + +### Removed + +- `StarletteResolver` is removed in favor of `ReactPyResolver`. ### Fixed diff --git a/docs/examples/python/custom_router_easy_resolver.py b/docs/examples/python/custom_router_easy_resolver.py new file mode 100644 index 0000000..322cce3 --- /dev/null +++ b/docs/examples/python/custom_router_easy_resolver.py @@ -0,0 +1,16 @@ +from typing import ClassVar + +from reactpy_router.resolvers import ConversionInfo, ReactPyResolver + + +# Create a custom resolver that uses the following pattern: "{name:type}" +class CustomResolver(ReactPyResolver): + # Match parameters that use the "" format + param_pattern: str = r"<(?P\w+)(?P:\w+)?>" + + # Enable matching for the following types: int, str, any + converters: ClassVar[dict[str, ConversionInfo]] = { + "int": ConversionInfo(regex=r"\d+", func=int), + "str": ConversionInfo(regex=r"[^/]+", func=str), + "any": ConversionInfo(regex=r".*", func=str), + } diff --git a/docs/examples/python/custom_router_easy_router.py b/docs/examples/python/custom_router_easy_router.py new file mode 100644 index 0000000..7457138 --- /dev/null +++ b/docs/examples/python/custom_router_easy_router.py @@ -0,0 +1,6 @@ +from example.resolvers import CustomResolver + +from reactpy_router.routers import create_router + +# This can be used in any location where `browser_router` was previously used +custom_router = create_router(CustomResolver) diff --git a/docs/examples/python/example/__init__.py b/docs/examples/python/example/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/examples/python/example/resolvers.py b/docs/examples/python/example/resolvers.py new file mode 100644 index 0000000..ad328cb --- /dev/null +++ b/docs/examples/python/example/resolvers.py @@ -0,0 +1,4 @@ +from reactpy_router.resolvers import ReactPyResolver + + +class CustomResolver(ReactPyResolver): ... diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 28df470..ad4ab0f 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -6,7 +6,7 @@ nav: - Advanced Topics: - Routers, Routes, and Links: learn/routers-routes-and-links.md - Hooks: learn/hooks.md - - Creating a Custom Router 🚧: learn/custom-router.md + - Creating a Custom Router: learn/custom-router.md - Reference: - Routers: reference/routers.md - Components: reference/components.md diff --git a/docs/src/learn/custom-router.md b/docs/src/learn/custom-router.md index fa03675..c0b1bac 100644 --- a/docs/src/learn/custom-router.md +++ b/docs/src/learn/custom-router.md @@ -1,3 +1,28 @@ -# Custom Router +Custom routers can be used to define custom routing logic for your application. This is useful when you need to implement a custom routing algorithm or when you need to integrate with an existing URL routing system. -Under construction 🚧 +--- + +## Step 1: Creating a custom resolver + +You may want to create a custom resolver to allow ReactPy to utilize an existing routing syntax. + +To start off, you will need to create a subclass of `#!python ReactPyResolver`. Within this subclass, you have two attributes which you can modify to support your custom routing syntax: + +- `#!python param_pattern`: A regular expression pattern that matches the parameters in your URL. This pattern must contain the regex named groups `name` and `type`. +- `#!python converters`: A dictionary that maps a `type` to it's respective `regex` pattern and a converter `func`. + +=== "resolver.py" + + ```python + {% include "../../examples/python/custom_router_easy_resolver.py" %} + ``` + +## Step 2: Creating a custom router + +Then, you can use this resolver to create your custom router... + +=== "resolver.py" + + ```python + {% include "../../examples/python/custom_router_easy_router.py" %} + ``` diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py index 48de28f..58e7b7f 100644 --- a/src/reactpy_router/resolvers.py +++ b/src/reactpy_router/resolvers.py @@ -1,31 +1,29 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, ClassVar from reactpy_router.converters import CONVERTERS +from reactpy_router.types import MatchedRoute if TYPE_CHECKING: from reactpy_router.types import ConversionInfo, ConverterMapping, Route -__all__ = ["StarletteResolver"] +__all__ = ["ReactPyResolver"] -class StarletteResolver: - """URL resolver that matches routes using starlette's URL routing syntax. +class ReactPyResolver: + """URL resolver that can match a path against any given routes. - However, this resolver adds a few additional parameter types on top of Starlette's syntax.""" + URL routing syntax for this resolver is based on Starlette, and supports a mixture of Starlette and Django parameter types.""" - def __init__( - self, - route: Route, - param_pattern=r"{(?P\w+)(?P:\w+)?}", - converters: dict[str, ConversionInfo] | None = None, - ) -> None: + param_pattern: str = r"{(?P\w+)(?P:\w+)?}" + converters: ClassVar[dict[str, ConversionInfo]] = CONVERTERS + + def __init__(self, route: Route) -> None: self.element = route.element - self.registered_converters = converters or CONVERTERS self.converter_mapping: ConverterMapping = {} - self.param_regex = re.compile(param_pattern) + self.param_regex = re.compile(self.param_pattern) self.pattern = self.parse_path(route.path) self.key = self.pattern.pattern # Unique identifier for ReactPy rendering @@ -48,7 +46,7 @@ def parse_path(self, path: str) -> re.Pattern[str]: # Check if a converter exists for the type try: - conversion_info = self.registered_converters[param_type] + conversion_info = self.converters[param_type] except KeyError as e: msg = f"Unknown conversion type {param_type!r} in {path!r}" raise ValueError(msg) from e @@ -70,7 +68,7 @@ def parse_path(self, path: str) -> re.Pattern[str]: return re.compile(pattern) - def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: + def resolve(self, path: str) -> MatchedRoute | None: match = self.pattern.match(path) if match: # Convert the matched groups to the correct types @@ -80,5 +78,5 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: else parameter_name: self.converter_mapping[parameter_name](value) for parameter_name, value in match.groupdict().items() } - return (self.element, params) + return MatchedRoute(self.element, params, path) return None diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 25c37b4..fad94eb 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -4,7 +4,7 @@ from dataclasses import replace from logging import getLogger -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Union, cast from reactpy import component, use_memo, use_state from reactpy.backend.types import Connection, Location @@ -13,14 +13,14 @@ from reactpy_router.components import History from reactpy_router.hooks import RouteState, _route_state_context -from reactpy_router.resolvers import StarletteResolver +from reactpy_router.resolvers import ReactPyResolver if TYPE_CHECKING: from collections.abc import Iterator, Sequence from reactpy.core.component import Component - from reactpy_router.types import CompiledRoute, Resolver, Route, Router + from reactpy_router.types import CompiledRoute, MatchedRoute, Resolver, Route, Router __all__ = ["browser_router", "create_router"] _logger = getLogger(__name__) @@ -35,7 +35,7 @@ def wrapper(*routes: Route) -> Component: return wrapper -_starlette_router = create_router(StarletteResolver) +_router = create_router(ReactPyResolver) def browser_router(*routes: Route) -> Component: @@ -49,7 +49,7 @@ def browser_router(*routes: Route) -> Component: Returns: A router component that renders the given routes. """ - return _starlette_router(*routes) + return _router(*routes) @component @@ -57,36 +57,27 @@ def router( *routes: Route, resolver: Resolver[Route], ) -> VdomDict | None: - """A component that renders matching route(s) using the given resolver. + """A component that renders matching route using the given resolver. - This typically should never be used by a user. Instead, use `create_router` if creating + User notice: This component typically should never be used. Instead, use `create_router` if creating a custom routing engine.""" - old_conn = use_connection() - location, set_location = use_state(old_conn.location) - first_load, set_first_load = use_state(True) - + old_connection = use_connection() + location, set_location = use_state(cast(Union[Location, None], None)) resolvers = use_memo( lambda: tuple(map(resolver, _iter_routes(routes))), dependencies=(resolver, hash(routes)), ) - - match = use_memo(lambda: _match_route(resolvers, location, select="first")) + route_element = None + match = use_memo(lambda: _match_route(resolvers, location or old_connection.location)) if match: - if first_load: - # We need skip rendering the application on 'first_load' to avoid - # rendering it twice. The second render follows the on_history_change event - route_elements = [] - set_first_load(False) - else: - route_elements = [ - _route_state_context( - element, - value=RouteState(set_location, params), - ) - for element, params in match - ] + # Skip rendering until ReactPy-Router knows what URL the page is on. + if location: + route_element = _route_state_context( + match.element, + value=RouteState(set_location, match.params), + ) def on_history_change(event: dict[str, Any]) -> None: """Callback function used within the JavaScript `History` component.""" @@ -96,8 +87,8 @@ def on_history_change(event: dict[str, Any]) -> None: return ConnectionContext( History({"onHistoryChangeCallback": on_history_change}), # type: ignore[return-value] - *route_elements, - value=Connection(old_conn.scope, location, old_conn.carrier), + route_element, + value=Connection(old_connection.scope, location or old_connection.location, old_connection.carrier), ) return None @@ -110,9 +101,9 @@ def _iter_routes(routes: Sequence[Route]) -> Iterator[Route]: yield parent -def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any: +def _add_route_key(match: MatchedRoute, key: str | int) -> Any: """Add a key to the VDOM or component on the current route, if it doesn't already have one.""" - element, _params = match + element = match.element if hasattr(element, "render") and not element.key: element = cast(ComponentType, element) element.key = key @@ -125,24 +116,12 @@ def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any: def _match_route( compiled_routes: Sequence[CompiledRoute], location: Location, - select: Literal["first", "all"], -) -> list[tuple[Any, dict[str, Any]]]: - matches = [] - +) -> MatchedRoute | None: for resolver in compiled_routes: match = resolver.resolve(location.pathname) if match is not None: - if select == "first": - return [_add_route_key(match, resolver.key)] + return _add_route_key(match, resolver.key) - # Matching multiple routes is disabled since `react-router` no longer supports multiple - # matches via the `Route` component. However, it's kept here to support future changes - # or third-party routers. - # TODO: The `resolver.key` value has edge cases where it is not unique enough to use as - # a key here. We can potentially fix this by throwing errors for duplicate identical routes. - matches.append(_add_route_key(match, resolver.key)) # pragma: no cover + _logger.debug("No matching route found for %s", location.pathname) - if not matches: - _logger.debug("No matching route found for %s", location.pathname) - - return matches + return None diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index ca2c913..755e244 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -28,9 +28,9 @@ class Route: A class representing a route that can be matched against a path. Attributes: - path (str): The path to match against. - element (Any): The element to render if the path matches. - routes (Sequence[Self]): Child routes. + path: The path to match against. + element: The element to render if the path matches. + routes: Child routes. Methods: __hash__() -> int: Returns a hash value for the route based on its path, element, and child routes. @@ -67,11 +67,11 @@ def __call__(self, *routes: RouteType_contra) -> Component: class Resolver(Protocol[RouteType_contra]): - """Compile a route into a resolver that can be matched against a given path.""" + """A class, that when instantiated, can match routes against a given path.""" def __call__(self, route: RouteType_contra) -> CompiledRoute: """ - Compile a route into a resolver that can be matched against a given path. + Compile a route into a resolver that can be match routes against a given path. Args: route: The route to compile. @@ -87,18 +87,18 @@ class CompiledRoute(Protocol): A protocol for a compiled route that can be matched against a path. Attributes: - key (Key): A property that uniquely identifies this resolver. + key: A property that uniquely identifies this resolver. """ @property def key(self) -> Key: ... - def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: + def resolve(self, path: str) -> MatchedRoute | None: """ Return the path's associated element and path parameters or None. Args: - path (str): The path to resolve. + path: The path to resolve. Returns: A tuple containing the associated element and a dictionary of path parameters, or None if the path cannot be resolved. @@ -106,13 +106,29 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: ... +@dataclass(frozen=True) +class MatchedRoute: + """ + Represents a matched route. + + Attributes: + element: The element to render. + params: The parameters extracted from the path. + path: The path that was matched. + """ + + element: Any + params: dict[str, Any] + path: str + + class ConversionInfo(TypedDict): """ A TypedDict that holds information about a conversion type. Attributes: - regex (str): The regex to match the conversion type. - func (ConversionFunc): The function to convert the matched string to the expected type. + regex: The regex to match the conversion type. + func: The function to convert the matched string to the expected type. """ regex: str diff --git a/tests/test_resolver.py b/tests/test_resolver.py index a5dad38..cf1a17e 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -4,18 +4,33 @@ import pytest from reactpy_router import route -from reactpy_router.resolvers import StarletteResolver +from reactpy_router.resolvers import ReactPyResolver +from reactpy_router.types import MatchedRoute def test_resolve_any(): - resolver = StarletteResolver(route("{404:any}", "Hello World")) + resolver = ReactPyResolver(route("{404:any}", "Hello World")) assert resolver.parse_path("{404:any}") == re.compile("^(?P<_numeric_404>.*)$") assert resolver.converter_mapping == {"_numeric_404": str} - assert resolver.resolve("/hello/world") == ("Hello World", {"404": "/hello/world"}) + assert resolver.resolve("/hello/world") == MatchedRoute( + element="Hello World", params={"404": "/hello/world"}, path="/hello/world" + ) + + +def test_custom_resolver(): + class CustomResolver(ReactPyResolver): + param_pattern = r"<(?P\w+)(?P:\w+)?>" + + resolver = CustomResolver(route("<404:any>", "Hello World")) + assert resolver.parse_path("<404:any>") == re.compile("^(?P<_numeric_404>.*)$") + assert resolver.converter_mapping == {"_numeric_404": str} + assert resolver.resolve("/hello/world") == MatchedRoute( + element="Hello World", params={"404": "/hello/world"}, path="/hello/world" + ) def test_parse_path(): - resolver = StarletteResolver(route("/", None)) + resolver = ReactPyResolver(route("/", None)) assert resolver.parse_path("/a/b/c") == re.compile("^/a/b/c$") assert resolver.converter_mapping == {} @@ -45,13 +60,13 @@ def test_parse_path(): def test_parse_path_unkown_conversion(): - resolver = StarletteResolver(route("/", None)) + resolver = ReactPyResolver(route("/", None)) with pytest.raises(ValueError, match="Unknown conversion type 'unknown' in '/a/{b:unknown}/c'"): resolver.parse_path("/a/{b:unknown}/c") def test_parse_path_re_escape(): """Check that we escape regex characters in the path""" - resolver = StarletteResolver(route("/", None)) + resolver = ReactPyResolver(route("/", None)) assert resolver.parse_path("/a/{b:int}/c.d") == re.compile(r"^/a/(?P\d+)/c\.d$") assert resolver.converter_mapping == {"b": int}