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 4 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
16 changes: 6 additions & 10 deletions idom_router/__init__.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
# 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, RouteResolver

from .core import link, create_router, router_component, use_params, use_query

__all__ = [
"create_router",
"link",
"Route",
"RouterConstructor",
"RouteCompiler",
"router_component",
"RouteResolver",
"use_location",
"use_params",
"use_query",
Expand Down
142 changes: 142 additions & 0 deletions idom_router/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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, 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_component(
*routes: R,
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
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(*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 _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 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
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)
160 changes: 0 additions & 160 deletions idom_router/router.py

This file was deleted.

Empty file added idom_router/routers/__init__.py
Empty file.
50 changes: 50 additions & 0 deletions idom_router/routers/regex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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<id__int>[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
Loading