Skip to content

Commit

Permalink
initial work on router compiler
Browse files Browse the repository at this point in the history
  • Loading branch information
rmorshea committed Oct 20, 2022
1 parent d014434 commit 15e7804
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 100 deletions.
8 changes: 3 additions & 5 deletions idom_router/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
108 changes: 35 additions & 73 deletions idom_router/router.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,68 +8,50 @@
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
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

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
Expand Down Expand Up @@ -113,28 +94,13 @@ 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):
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?"
Expand All @@ -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",
)
28 changes: 28 additions & 0 deletions idom_router/types.py
Original file line number Diff line number Diff line change
@@ -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]]
71 changes: 49 additions & 22 deletions tests/test_router.py
Original file line number Diff line number Diff line change
@@ -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("/", "-")

Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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")

0 comments on commit 15e7804

Please sign in to comment.