Skip to content

initial work on router compiler #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 11, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions idom_router/__init__.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
# 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 idom_router.types import Route, RouteCompiler, RoutePattern

from .router import link, router, use_params, use_query

__all__ = [
"create_router",
"link",
"Route",
"RouterConstructor",
"RouteCompiler",
"RoutePattern",
"router",
"use_location",
"use_params",
"use_query",
Expand Down
34 changes: 34 additions & 0 deletions idom_router/compilers.py
Original file line number Diff line number Diff line change
@@ -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
177 changes: 73 additions & 104 deletions idom_router/router.py
Original file line number Diff line number Diff line change
@@ -1,98 +1,70 @@
from __future__ import annotations

import re
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_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 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 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
from idom_router.compilers import compile_starlette_route
from idom_router.types import Route, RouteCompiler, RoutePattern

R = TypeVar("R", bound=Route)

@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: R,
compiler: RouteCompiler[R] = compile_starlette_route,
) -> ComponentType | None:
old_conn = use_connection()
location, set_location = use_state(old_conn.location)

# 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)
set_location = _use_location_state().set_location
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_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_route_state().params


def use_query(
Expand All @@ -113,48 +85,45 @@ 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 _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[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 _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


@dataclass
class _CompiledRoute:
pattern: re.Pattern[str]
converters: dict[str, Callable[[Any], Any]]
element: Any
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


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)
def _use_route_state() -> _RouteState:
route_state = use_context(_route_state_context)
assert route_state is not None
return route_state

_Link = export(
module_from_file(
"idom-router",
file=Path(__file__).parent / "bundle.js",
fallback="⏳",
),
"Link",
)

_route_state_context: Context[_RouteState | None] = create_context(None)
43 changes: 43 additions & 0 deletions idom_router/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from __future__ import annotations

from dataclasses import dataclass
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[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)


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"""


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"""
2 changes: 1 addition & 1 deletion requirements/pkg-deps.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
idom >=0.40.2,<0.41
idom >=1
typing_extensions
starlette
Loading