chore: rename project coven → hack-house ⛧

Rebrand the Rust client crate (coven/ → hh/, package+binary "hack-house"),
README, CLI strings, and branch (coven → hack-house). Gitea repo renamed
cmd-chat → hack-house to match. Crypto/server logic unchanged; selftest +
golden-vector test still green, binary is now `hack-house`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-30 13:29:14 -07:00
parent 82a04f3e12
commit bb1d662ee1
2730 changed files with 712933 additions and 46 deletions
@@ -0,0 +1,7 @@
from .group import RouteGroup
from .route import Route
from .router import BaseRouter
__version__ = "23.12.0"
__all__ = ("BaseRouter", "Route", "RouteGroup")
@@ -0,0 +1,49 @@
from typing import Optional, Set
class BaseException(Exception):
...
class NotFound(BaseException):
def __init__(
self,
message: str = "Not Found",
path: Optional[str] = None,
):
super().__init__(message)
self.path = path
class BadMethod(BaseException):
...
class NoMethod(BaseException):
def __init__(
self,
message: str = "Method does not exist",
method: Optional[str] = None,
allowed_methods: Optional[Set[str]] = None,
path: Optional[str] = None,
):
super().__init__(message)
self.method = method
self.allowed_methods = allowed_methods
self.path = path
class FinalizationError(BaseException):
...
class InvalidUsage(BaseException):
...
class RouteExists(BaseException):
...
class ParameterNameConflicts(BaseException):
...
@@ -0,0 +1,206 @@
from __future__ import annotations
from typing import FrozenSet, List, Optional, Sequence, Tuple
from sanic_routing.route import Requirements, Route
from sanic_routing.utils import Immutable
from .exceptions import InvalidUsage, RouteExists
class RouteGroup:
methods_index: Immutable
passthru_properties = (
"labels",
"params",
"parts",
"path",
"pattern",
"raw_path",
"regex",
"router",
"segments",
"strict",
"unquote",
"uri",
)
#: The _reconstructed_ path after the Route has been normalized.
#: Does not contain preceding ``/`` (see also
#: :py:attr:`uri`)
path: str
#: A regex version of the :py:attr:`~sanic_routing.route.Route.path`
pattern: Optional[str]
#: Whether the route requires regular expression evaluation
regex: bool
#: The raw version of the path exploded (see also
#: :py:attr:`segments`)
parts: Tuple[str, ...]
#: Same as :py:attr:`parts` except
#: generalized so that any dynamic parts do not
#: include param keys since they have no impact on routing.
segments: Tuple[str, ...]
#: Whether the route should be matched with strict evaluation
strict: bool
#: Whether the route should be unquoted after matching if (for example) it
#: is suspected to contain non-URL friendly characters
unquote: bool
#: Since :py:attr:`path` does NOT
#: include a preceding '/', this adds it back.
uri: str
def __init__(self, *routes) -> None:
if len(set(route.parts for route in routes)) > 1:
raise InvalidUsage("Cannot group routes with differing paths")
if any(routes[-1].strict != route.strict for route in routes):
raise InvalidUsage("Cannot group routes with differing strictness")
route_list = list(routes)
route_list.pop()
self._routes = routes
self.pattern_idx = 0
def __str__(self):
display = (
f"path={self.path or self.router.delimiter} len={len(self.routes)}"
)
return f"<{self.__class__.__name__}: {display}>"
def __repr__(self) -> str:
return str(self)
def __iter__(self):
return iter(self.routes)
def __getitem__(self, key):
return self.routes[key]
def __getattr__(self, key):
# There are a number of properties that all of the routes in the group
# share in common. We pass thrm through to make them available
# on the RouteGroup, and then cache them so that they are permanent.
if key in self.passthru_properties:
value = getattr(self[0], key)
setattr(self, key, value)
return value
raise AttributeError(f"RouteGroup has no '{key}' attribute")
def finalize(self):
self.methods_index = Immutable(
{
method: route
for route in self._routes
for method in route.methods
}
)
def prioritize_routes(self) -> None:
"""
Sorts the routes in the group by priority
"""
self._routes = tuple(
sorted(self._routes, key=lambda route: route.priority)
)
def reset(self):
self.methods_index = dict(self.methods_index)
def merge(
self, group: RouteGroup, overwrite: bool = False, append: bool = False
) -> None:
"""
The purpose of merge is to group routes with the same path, but
declarared individually. In other words to group these:
.. code-block:: python
@app.get("/path/to")
def handler1(...):
...
@app.post("/path/to")
def handler2(...):
...
The other main purpose is to look for conflicts and
raise ``RouteExists``
A duplicate route is when:
1. They have the same path and any overlapping methods; AND
2. If they have requirements, they are the same
:param group: Incoming route group
:type group: RouteGroup
:param overwrite: whether to allow an otherwise duplicate route group
to overwrite the existing, if ``True`` will not raise exception
on duplicates, defaults to False
:type overwrite: bool, optional
:param append: whether to allow an otherwise duplicate route group to
append its routes to the existing route group, defaults to False
:type append: bool, optional
:raises RouteExists: Raised when there is a duplicate
"""
_routes = list(self._routes)
for other_route in group.routes:
for current_route in self:
if (
current_route == other_route
or (
current_route.requirements
and not other_route.requirements
)
or (
not current_route.requirements
and other_route.requirements
)
) and not append:
if not overwrite:
raise RouteExists(
f"Route already registered: {self.raw_path} "
f"[{','.join(self.methods)}]"
)
else:
_routes.append(other_route)
_routes.sort(
key=lambda route: route.priority, reverse=True
)
self._routes = tuple(_routes)
@property
def depth(self) -> int:
"""
The number of parts in :py:attr:`parts`
"""
return len(self[0].parts)
@property
def dynamic_path(self) -> bool:
return any(
(param.label == "path") or ("/" in param.label)
for param in self.params.values()
)
@property
def methods(self) -> FrozenSet[str]:
""""""
return frozenset(
[method for route in self for method in route.methods]
)
@property
def routes(self) -> Sequence[Route]:
return self._routes
@property
def requirements(self) -> List[Requirements]:
return [route.requirements for route in self if route.requirements]
@@ -0,0 +1,17 @@
class Line:
TAB = " "
def __init__(
self,
src: str,
indent: int,
offset: int = 0,
render: bool = True,
) -> None:
self.src = src
self.indent = indent
self.offset = offset
self.render = render
def __str__(self):
return (self.TAB * self.indent) + self.src + "\n"
@@ -0,0 +1,178 @@
import re
import typing as t
import uuid
from datetime import date, datetime
from types import SimpleNamespace
from typing import Any, Callable, Dict, Pattern, Tuple, Type
from sanic_routing.exceptions import InvalidUsage, NotFound
def parse_date(d) -> date:
return datetime.strptime(d, "%Y-%m-%d").date()
def alpha(param: str) -> str:
if not param.isalpha():
raise ValueError(f"Value {param} contains non-alphabetic chracters")
return param
def slug(param: str) -> str:
if not REGEX_TYPES["slug"][1].match(param):
raise ValueError(f"Value {param} does not match the slug format")
return param
def ext(param: str) -> Tuple[str, ...]:
parts = tuple(param.split("."))
if any(not p for p in parts) or len(parts) == 1:
raise ValueError(f"Value {param} does not match filename format")
return parts
def nonemptystr(param: str) -> str:
if not param:
raise ValueError(f"Value {param} is an empty string")
return param
class ParamInfo:
__slots__ = (
"cast",
"ctx",
"label",
"name",
"pattern",
"priority",
"raw_path",
"regex",
)
def __init__(
self,
name: str,
raw_path: str,
label: str,
cast: t.Callable[[str], t.Any],
pattern: re.Pattern,
regex: bool,
priority: int,
) -> None:
self.name = name
self.raw_path = raw_path
self.label = label
self.cast = cast
self.pattern = pattern
self.regex = regex
self.priority = priority
self.ctx = SimpleNamespace()
def process(
self,
params: t.Dict[str, t.Any],
value: t.Union[str, t.Tuple[str, ...]],
) -> None:
params[self.name] = value
class ExtParamInfo(ParamInfo):
def __init__(self, **kwargs):
super().__init__(**kwargs)
match = REGEX_PARAM_EXT_PATH.search(self.raw_path)
if not match:
raise InvalidUsage(
f"Invalid extension parameter definition: {self.raw_path}"
)
if match.group(2) == "path":
raise InvalidUsage(
"Extension parameter matching does not support the "
"`path` type."
)
ext_type = match.group(3)
regex_type = REGEX_TYPES.get(match.group(2))
self.ctx.cast = None
if regex_type:
self.ctx.cast = regex_type[0]
elif match.group(2):
raise InvalidUsage(
"Extension parameter matching only supports filename matching "
"on known parameter types, and not regular expressions."
)
self.ctx.allowed = []
self.ctx.allowed_sub_count = 0
if ext_type:
self.ctx.allowed = ext_type.split("|")
allowed_subs = {allowed.count(".") for allowed in self.ctx.allowed}
if len(allowed_subs) > 1:
raise InvalidUsage(
"All allowed extensions within a single route definition "
"must contain the same number of subparts. For example: "
"<foo:ext=js|css> and <foo:ext=min.js|min.css> are both "
"acceptable, but <foo:ext=js|min.js> is not."
)
self.ctx.allowed_sub_count = next(iter(allowed_subs))
for extension in self.ctx.allowed:
if not REGEX_ALLOWED_EXTENSION.match(extension):
raise InvalidUsage(f"Invalid extension: {extension}")
def process(self, params, value):
stop = -1 * (self.ctx.allowed_sub_count + 1)
filename = ".".join(value[:stop])
ext = ".".join(value[stop:])
if self.ctx.allowed and ext not in self.ctx.allowed:
raise NotFound(f"Invalid extension: {ext}")
if self.ctx.cast:
try:
filename = self.ctx.cast(filename)
except ValueError:
raise NotFound(f"Invalid filename: {filename}")
params[self.name] = filename
params["ext"] = ext
EXTENSION = r"[a-z0-9](?:[a-z0-9\.]*[a-z0-9])?"
PARAM_EXT = (
r"<([a-zA-Z_][a-zA-Z0-9_]*)(?:=([a-z]+))?(?::ext(?:=([a-z0-9|\.]+))?)>"
)
REGEX_PARAM_NAME = re.compile(r"^<([a-zA-Z_][a-zA-Z0-9_]*)(?::(.*))?>$")
REGEX_PARAM_EXT_PATH = re.compile(PARAM_EXT)
REGEX_PARAM_NAME_EXT = re.compile(r"^" + PARAM_EXT + r"$")
REGEX_ALLOWED_EXTENSION = re.compile(r"^" + EXTENSION + r"$")
# Predefined path parameter types. The value is a tuple consisteing of a
# callable and a compiled regular expression.
# The callable should:
# 1. accept a string input
# 2. cast the string to desired type
# 3. raise ValueError if it cannot
# The regular expression is generally NOT used. Unless the path is forced
# to use regex patterns.
REGEX_TYPES_ANNOTATION = Dict[
str, Tuple[Callable[[str], Any], Pattern, Type[ParamInfo]]
]
REGEX_TYPES: REGEX_TYPES_ANNOTATION = {
"strorempty": (str, re.compile(r"^[^/]*$"), ParamInfo),
"str": (nonemptystr, re.compile(r"^[^/]+$"), ParamInfo),
"ext": (ext, re.compile(r"^[^/]+\." + EXTENSION + r"$"), ExtParamInfo),
"slug": (slug, re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$"), ParamInfo),
"alpha": (alpha, re.compile(r"^[A-Za-z]+$"), ParamInfo),
"path": (str, re.compile(r"^[^/]?.*?$"), ParamInfo),
"float": (float, re.compile(r"^-?(?:\d+(?:\.\d*)?|\.\d+)$"), ParamInfo),
"int": (int, re.compile(r"^-?\d+$"), ParamInfo),
"ymd": (
parse_date,
re.compile(r"^([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))$"),
ParamInfo,
),
"uuid": (
uuid.UUID,
re.compile(
r"^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-"
r"[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$"
),
ParamInfo,
),
}
@@ -0,0 +1,380 @@
import re
import typing as t
from types import SimpleNamespace
from warnings import warn
from .exceptions import InvalidUsage, ParameterNameConflicts
from .patterns import ParamInfo
from .utils import Immutable, parts_to_path, path_to_parts
class Requirements(Immutable):
def __hash__(self):
return hash(frozenset(self.items()))
class Route:
__slots__ = (
"_params",
"_raw_path",
"ctx",
"extra",
"handler",
"labels",
"methods",
"name",
"overloaded",
"params",
"parts",
"path",
"pattern",
"priority",
"regex",
"requirements",
"router",
"static",
"strict",
"unquote",
)
#: A container for route meta-data
ctx: SimpleNamespace
#: A container for route application-data
extra: SimpleNamespace
#: The route handler
handler: t.Callable[..., t.Any]
#: The HTTP methods that the route can handle
methods: t.FrozenSet[str]
#: The route name, either generated or as defined in the route definition
name: str
#: The raw version of the path exploded (see also
#: :py:attr:`~sanic_routing.route.Route.segments`)
parts: t.Tuple[str, ...]
#: The _reconstructed_ path after the Route has been normalized.
#: Does not contain preceding ``/`` (see also
#: :py:attr:`~sanic_routing.route.Route.uri`)
path: str
#: A regex version of the :py:attr:`~sanic_routing.route.Route.path`
pattern: t.Optional[str]
#: Whether the route requires regular expression evaluation
regex: bool
#: A representation of the non-path route requirements
requirements: Requirements
#: When ``True``, the route does not have any dynamic path parameters
static: bool
#: Whether the route should be matched with strict evaluation
strict: bool
#: Whether the route should be unquoted after matching if (for example) it
#: is suspected to contain non-URL friendly characters
unquote: bool
def __init__(
self,
router,
raw_path: str,
name: str,
handler: t.Callable[..., t.Any],
methods: t.Union[t.Sequence[str], t.FrozenSet[str]],
requirements: t.Optional[t.Dict[str, t.Any]] = None,
strict: bool = False,
unquote: bool = False,
static: bool = False,
regex: bool = False,
overloaded: bool = False,
*,
priority: int = 0,
):
self.router = router
self.name = name
self.handler = handler # type: ignore
self.methods = frozenset(methods)
self.requirements = Requirements(requirements or {})
self.priority = priority
self.ctx = SimpleNamespace()
self.extra = SimpleNamespace()
self._params: t.Dict[int, ParamInfo] = {}
self._raw_path = raw_path
# Main goal is to do some normalization. Any dynamic segments
# that are missing a type are rewritten with str type
ingested_path = self._ingest_path(raw_path)
# By passing the path back and forth to deconstruct and reconstruct
# we can normalize it and make sure we are dealing consistently
parts = path_to_parts(ingested_path, self.router.delimiter)
self.path = parts_to_path(parts, delimiter=self.router.delimiter)
self.parts = parts
self.static = static
self.regex = regex
self.overloaded = overloaded
self.pattern = None
self.strict: bool = strict
self.unquote: bool = unquote
self.labels: t.Optional[t.List[str]] = None
self._setup_params()
def __str__(self):
display = (
f"name={self.name} path={self.path or self.router.delimiter}"
if self.name and self.name != self.path
else f"path={self.path or self.router.delimiter}"
)
return f"<{self.__class__.__name__}: {display}>"
def __repr__(self) -> str:
return str(self)
def __eq__(self, other) -> bool:
if not isinstance(other, self.__class__):
return False
# Equality specifically uses self.segments and not self.parts.
# In general, these properties are nearly identical.
# self.segments is generalized and only displays dynamic param types
# and self.parts has both the param key and the param type.
# In this test, we use the & operator so that we create a union and a
# positive equality if there is one or more overlaps in the methods.
return bool(
(
self.segments,
self.requirements,
)
== (
other.segments,
other.requirements,
)
and (self.methods & other.methods)
)
def _ingest_path(self, path):
segments = []
for part in path.split(self.router.delimiter):
if part.startswith("<") and ":" not in part:
name = part[1:-1]
part = f"<{name}:str>"
segments.append(part)
return self.router.delimiter.join(segments)
def _setup_params(self):
key_path = parts_to_path(
path_to_parts(self.raw_path, self.router.delimiter),
self.router.delimiter,
)
if not self.static:
parts = path_to_parts(key_path, self.router.delimiter)
for idx, part in enumerate(parts):
if part.startswith("<"):
(
name,
label,
_type,
pattern,
param_info_class,
) = self.parse_parameter_string(part[1:-1])
self.add_parameter(
idx,
name,
key_path,
label,
_type,
pattern,
param_info_class,
)
def add_parameter(
self,
idx: int,
name: str,
raw_path: str,
label: str,
cast: t.Type,
pattern=None,
param_info_class=ParamInfo,
):
if pattern and isinstance(pattern, str):
if not pattern.startswith("^"):
pattern = f"^{pattern}"
if not pattern.endswith("$"):
pattern = f"{pattern}$"
pattern = re.compile(pattern)
is_regex = label not in self.router.regex_types
priority = (
0
if is_regex
else list(self.router.regex_types.keys()).index(label)
)
self._params[idx] = param_info_class(
name=name,
raw_path=raw_path,
label=label,
cast=cast,
pattern=pattern,
regex=is_regex,
priority=priority,
)
def _finalize_params(self):
params = dict(self._params)
label_pairs = set([(param.name, idx) for idx, param in params.items()])
labels = [item[0] for item in label_pairs]
if len(labels) != len(set(labels)):
raise ParameterNameConflicts(
f"Duplicate named parameters in: {self._raw_path}"
)
self.labels = labels
self.params = dict(
sorted(params.items(), key=lambda param: self._sorting(param[1]))
)
if not self.regex and any(
":" in param.label for param in self.params.values()
):
raise InvalidUsage(
f"Invalid parameter declaration: {self.raw_path}"
)
def _compile_regex(self):
components = []
for part in self.parts:
if part.startswith("<"):
name, *_, pattern, __ = self.parse_parameter_string(part)
if not isinstance(pattern, str):
pattern = pattern.pattern.strip("^$")
compiled = re.compile(pattern)
if compiled.groups == 1:
if compiled.groupindex:
if list(compiled.groupindex)[0] != name:
raise InvalidUsage(
f"Named group ({list(compiled.groupindex)[0]})"
f" must match your named parameter ({name})"
)
components.append(pattern)
else:
if pattern.count("(") > 1:
raise InvalidUsage(
f"Could not compile pattern {pattern}. "
"Try using a named group instead: "
f"'(?P<{name}>your_matching_group)'"
)
beginning, end = pattern.split("(")
components.append(f"{beginning}(?P<{name}>{end}")
elif compiled.groups > 1:
raise InvalidUsage(f"Invalid matching pattern {pattern}")
else:
components.append(f"(?P<{name}>{pattern})")
else:
components.append(part)
self.pattern = self.router.delimiter + self.router.delimiter.join(
components
)
def finalize(self):
self._finalize_params()
if self.regex:
self._compile_regex()
self.requirements = Immutable(self.requirements)
def reset(self):
self.requirements = dict(self.requirements)
@property
def defined_params(self):
return self._params
@property
def raw_path(self):
"""
The raw path from the route definition
"""
return self._raw_path
@property
def segments(self) -> t.Tuple[str, ...]:
"""
Same as :py:attr:`~sanic_routing.route.Route.parts` except
generalized so that any dynamic parts do not
include param keys since they have no impact on routing.
"""
return tuple(
f"<__dynamic__:{self._params[idx].label}>"
if idx in self._params
else segment
for idx, segment in enumerate(self.parts)
)
@property
def uri(self):
"""
Since :py:attr:`~sanic_routing.route.Route.path` does NOT
include a preceding '/', this adds it back.
"""
return f"{self.router.delimiter}{self.path}"
def _sorting(self, item) -> int:
try:
return list(self.router.regex_types.keys()).index(item.label)
except ValueError:
return len(list(self.router.regex_types.keys()))
def parse_parameter_string(self, parameter_string: str):
"""Parse a parameter string into its constituent name, type, and
pattern
For example:
```text
parse_parameter_string('<param_one:[A-z]>')` -> ('param_one', '[A-z]', <class 'str'>, '[A-z]')
```
:param parameter_string: String to parse
:return: tuple containing
(parameter_name, parameter_type, parameter_pattern)
""" # noqa: E501
# We could receive NAME or NAME:PATTERN
parameter_string = parameter_string.strip("<>")
name = parameter_string
label = "str"
if ":" in parameter_string:
name, label = parameter_string.split(":", 1)
if "=" in label:
label, _ = label.split("=", 1)
if "=" in name:
name, _ = name.split("=", 1)
if not name:
raise ValueError(
f"Invalid parameter syntax: {parameter_string}"
)
if label == "string":
warn(
"Use of 'string' as a path parameter type is deprected, "
"and will be removed in Sanic v21.12. "
f"Instead, use <{name}:str>.",
DeprecationWarning,
)
elif label == "number":
warn(
"Use of 'number' as a path parameter type is deprected, "
"and will be removed in Sanic v21.12. "
f"Instead, use <{name}:float>.",
DeprecationWarning,
)
default = (str, label, ParamInfo)
# Pull from pre-configured types
found = self.router.regex_types.get(label, default)
_type, pattern, param_info_class = found
return name, label, _type, pattern, param_info_class
@@ -0,0 +1,639 @@
import ast
import sys
import typing as t
from abc import ABC, abstractmethod
from types import SimpleNamespace
from warnings import warn
from sanic_routing.group import RouteGroup
from sanic_routing.patterns import ParamInfo
from .exceptions import (
BadMethod,
FinalizationError,
InvalidUsage,
NoMethod,
NotFound,
)
from .line import Line
from .patterns import REGEX_TYPES, REGEX_TYPES_ANNOTATION
from .route import Route
from .tree import Node, Tree
from .utils import parts_to_path, path_to_parts
# The below functions might be called by the compiled source code, and
# therefore should be made available here by import
import re # noqa isort:skip
from datetime import datetime # noqa isort:skip
from urllib.parse import unquote # noqa isort:skip
from uuid import UUID # noqa isort:skip
from .patterns import parse_date, alpha, slug, nonemptystr # noqa isort:skip
class BaseRouter(ABC):
DEFAULT_METHOD = "BASE"
ALLOWED_METHODS: t.Tuple[str, ...] = tuple()
def __init__(
self,
delimiter: str = "/",
exception: t.Type[NotFound] = NotFound,
method_handler_exception: t.Type[NoMethod] = NoMethod,
route_class: t.Type[Route] = Route,
group_class: t.Type[RouteGroup] = RouteGroup,
stacking: bool = False,
cascade_not_found: bool = False,
) -> None:
self._find_route = None
self._matchers = None
self.static_routes: t.Dict[t.Tuple[str, ...], RouteGroup] = {}
self.dynamic_routes: t.Dict[t.Tuple[str, ...], RouteGroup] = {}
self.regex_routes: t.Dict[t.Tuple[str, ...], RouteGroup] = {}
self.name_index: t.Dict[str, Route] = {}
self.delimiter = delimiter
self.exception = exception
self.method_handler_exception = method_handler_exception
self.route_class = route_class
self.group_class = group_class
self.tree = Tree(router=self)
self.finalized = False
self.stacking = stacking
self.ctx = SimpleNamespace()
self.cascade_not_found = cascade_not_found
self.regex_types: REGEX_TYPES_ANNOTATION = {}
for label, (cast, pattern, param_info_class) in REGEX_TYPES.items():
self.register_pattern(label, cast, pattern, param_info_class)
@abstractmethod
def get(self, **kwargs):
...
def resolve(
self,
path: str,
*,
method: t.Optional[str] = None,
orig: t.Optional[str] = None,
extra: t.Optional[t.Dict[str, str]] = None,
) -> t.Tuple[Route, t.Callable[..., t.Any], t.Dict[str, t.Any]]:
try:
route, param_basket = self.find_route(
path,
method,
self,
{"__params__": {}, "__matches__": {}},
extra,
)
except (NotFound, NoMethod) as e:
# If we did not find the route, we might need to try routing one
# more time to handle strict_slashes
if path.endswith(self.delimiter):
return self.resolve(
path=path[:-1],
method=method,
orig=path,
extra=extra,
)
raise e.__class__(str(e), path=path)
if isinstance(route, RouteGroup):
try:
route = route.methods_index[method]
except KeyError:
raise self.method_handler_exception(
f"Method '{method}' not found on {route}",
method=method,
allowed_methods=route.methods,
)
# Convert matched values to parameters
params = param_basket["__params__"]
if not params or param_basket["__matches__"]:
# If param_basket["__params__"] does not exist, we might have
# param_basket["__matches__"], which are indexed based matches
# on path segments. They should already be cast types.
for idx, param in route.params.items():
# If the param index does not exist, then rely upon
# the __params__
try:
value = param_basket["__matches__"][idx]
except KeyError:
continue
# Apply if tuple (from ext) or if it is not a regex matcher
if isinstance(value, tuple):
param.process(params, value)
elif not route.regex or (
route.regex and param.cast is not str
):
params[param.name] = value
# Double check that if we made a match it is not a false positive
# because of strict_slashes
if route.strict and orig and orig[-1] != route.path[-1]:
raise self.exception("Path not found", path=path)
if method not in route.methods:
raise self.method_handler_exception(
f"Method '{method}' not found on {route}",
method=method,
allowed_methods=route.methods,
)
return route, route.handler, params
def add(
self,
path: str,
handler: t.Callable,
methods: t.Optional[
t.Union[t.Sequence[str], t.FrozenSet[str], str]
] = None,
name: t.Optional[str] = None,
requirements: t.Optional[t.Dict[str, t.Any]] = None,
strict: bool = False,
unquote: bool = False, # noqa
overwrite: bool = False,
append: bool = False,
*,
priority: int = 0,
) -> Route:
# Can add a route with overwrite, or append, not both.
# - overwrite: if matching path exists, replace it
# - append: if matching path exists, append handler to it
if overwrite and append:
raise FinalizationError(
"Cannot add a route with both overwrite and append equal "
"to True"
)
if priority and not append:
raise FinalizationError(
"Cannot add a route with priority if append is False"
)
if not methods:
methods = [self.DEFAULT_METHOD]
if hasattr(methods, "__iter__") and not isinstance(methods, frozenset):
methods = frozenset(methods)
elif isinstance(methods, str):
methods = frozenset([methods])
if self.ALLOWED_METHODS and any(
method not in self.ALLOWED_METHODS for method in methods
):
bad = [
method
for method in methods
if method not in self.ALLOWED_METHODS
]
raise BadMethod(
f"Bad method: {bad}. Must be one of: {self.ALLOWED_METHODS}"
)
if self.finalized:
raise FinalizationError("Cannot finalize router more than once.")
static = "<" not in path and requirements is None
regex = self._is_regex(path)
# There are generally three pools of routes on the router:
# - those that are static patterns with not matching
# - those that have one or more dynamic parts, but NO regex
# - those that have one or more dynamic parts, with at least one regex
if regex:
routes = self.regex_routes
elif static:
routes = self.static_routes
else:
routes = self.dynamic_routes
# Only URL encode the static parts of the path
path = parts_to_path(
path_to_parts(path, self.delimiter), self.delimiter
)
# We need to clean off the delimiters are the beginning, and maybe the
# end, depending upon whether we are in strict mode
strip = path.lstrip if strict else path.strip
path = strip(self.delimiter)
route = self.route_class(
self,
path,
name or "",
handler=handler,
methods=methods,
requirements=requirements,
strict=strict,
unquote=unquote,
static=static,
regex=regex,
priority=priority,
)
group = self.group_class(route)
# Catch the scenario where a route is overloaded with and
# and without requirements, first as dynamic then as static
if static and route.segments in self.dynamic_routes:
routes = self.dynamic_routes
# Catch the reverse scenario where a route is overload first as static
# and then as dynamic
if not static and route.segments in self.static_routes:
existing_group = self.static_routes.pop(route.segments)
group.merge(existing_group, overwrite, append)
else:
if route.segments in routes:
existing_group = routes[route.segments]
group.merge(existing_group, overwrite, append)
routes[route.segments] = group
if name:
self.name_index[name] = route
group.finalize()
return route
def register_pattern(
self,
label: str,
cast: t.Callable[[str], t.Any],
pattern: t.Union[t.Pattern, str],
param_info_class: t.Type[ParamInfo] = ParamInfo,
):
"""
Add a custom parameter type to the router. The cast should raise a
ValueError if it is an incorrect type. The order of registration is
important if it is possible that a single value could pass multiple
pattern types. Therefore, patterns are tried in the REVERSE order of
registration. All custom patterns will be evaluated before any built-in
patterns.
:param label: The parts that is used to signify the type: example
:type label: str
:param cast: The callable that casts the value to the desired type, or
fails trying
:type cast: t.Callable[[str], t.Any]
:param pattern: A regular expression that could also match the path
segment
:type pattern: Union[t.Pattern, str]
"""
if not isinstance(label, str):
raise InvalidUsage(
"When registering a pattern, label must be a "
f"string, not label={label}"
)
if not callable(cast):
raise InvalidUsage(
"When registering a pattern, cast must be a "
f"callable, not cast={cast}"
)
if not isinstance(pattern, str) and not isinstance(pattern, t.Pattern):
raise InvalidUsage(
"When registering a pattern, pattern must be a "
f"string or a Pattern, not pattern={pattern}, "
f"type={type(pattern)}"
)
if isinstance(pattern, str):
pattern = re.compile(pattern)
globals()[cast.__name__] = cast
self.regex_types[label] = (cast, pattern, param_info_class)
def finalize(self, do_compile: bool = True, do_optimize: bool = False):
"""
After all routes are added, we can put everything into a final state
and build the routing dource
:param do_compile: Whether to compile the source, mainly a debugging
tool, defaults to True
:type do_compile: bool, optional
:param do_optimize: Experimental feature that uses AST module to make
some optimizations, defaults to False
:type do_optimize: bool, optional
:raises FinalizationError: Cannot finalize if there are no routes, or
the router has already been finalized (can call reset() to undo it)
"""
if self.finalized:
raise FinalizationError("Cannot finalize router more than once.")
if not self.routes:
raise FinalizationError("Cannot finalize with no routes defined.")
self.finalized = True
for group in (
list(self.static_routes.values())
+ list(self.dynamic_routes.values())
+ list(self.regex_routes.values())
):
group.finalize()
for route in group.routes:
route.finalize()
group.prioritize_routes()
# Evaluates all of the paths and arranges them into a hierarchichal
# tree of nodes
self._generate_tree()
# Renders the source code
self._render(do_compile, do_optimize)
def reset(self):
self.finalized = False
self.tree = Tree(router=self)
self._find_route = None
for group in (
list(self.static_routes.values())
+ list(self.dynamic_routes.values())
+ list(self.regex_routes.values())
):
group.reset()
for route in group.routes:
route.reset()
def _get_non_static_non_path_groups(
self, has_dynamic_path: bool
) -> t.List[RouteGroup]:
"""
Paths that have some matching params (includes dynamic and regex),
but excludes any routes with a <path:path> or delimiter in its regex.
This is because those special cases need to be evaluated seperately.
Anything else can be evaluated in the node tree.
:param has_dynamic_path: Whether the path catches a path, or path-like
type
:type has_dynamic_path: bool
:return: list of routes that have no path, but do need matching
:rtype: List[RouteGroup]
"""
return sorted(
[
group
for group in list(self.dynamic_routes.values())
+ list(self.regex_routes.values())
if group.dynamic_path is has_dynamic_path
],
key=lambda x: x.depth,
reverse=True,
)
def _generate_tree(self) -> None:
self.tree.generate(self._get_non_static_non_path_groups(False))
self.tree.finalize()
def _render(
self, do_compile: bool = True, do_optimize: bool = False
) -> None:
# Initial boilerplate for the function source
src = [
Line("def find_route(path, method, router, basket, extra):", 0),
Line("parts = tuple(path[1:].split(router.delimiter))", 1),
]
delayed = []
# Add static path matching
if self.static_routes:
# TODO:
# - future improvement would be to decide which option to use
# at runtime based upon the makeup of the router since this
# potentially has an impact on performance
src += [
Line("try:", 1),
Line(
"group = router.static_routes[parts]",
2,
),
Line("basket['__raw_path__'] = path", 2),
Line("return group, basket", 2),
Line("except KeyError:", 1),
Line("pass", 2),
]
# src += [
# Line("if parts in router.static_routes:", 1),
# Line("route = router.static_routes[parts]", 2),
# Line("basket['__raw_path__'] = route.path", 2),
# Line("return route, basket", 2),
# ]
# src += [
# Line("if path in router.static_routes:", 1),
# Line("route = router.static_routes.get(path)", 2),
# Line("basket['__raw_path__'] = route.path", 2),
# Line("return route, basket", 2),
# ]
# Add in pre-compiled regular expressions so they do not need to
# compile at run time
if self.regex_routes:
routes = sorted(
self.regex_routes.values(),
key=lambda route: len(route.parts),
reverse=True,
)
delayed.append(Line("matchers = [", 0))
for idx, group in enumerate(routes):
group.pattern_idx = idx
delayed.append(Line(f"re.compile(r'^{group.pattern}$'),", 1))
delayed.append(Line("]", 0))
# Generate all the dynamic code
if self.dynamic_routes or self.regex_routes:
src += [Line("num = len(parts)", 1)]
src += self.tree.render()
# Inject regex matching that could not be in the tree
for group in self._get_non_static_non_path_groups(True):
route_container = (
"regex_routes" if group.regex else "dynamic_routes"
)
route_idx: t.Union[str, int] = 0
holder: t.List[Line] = []
if group.requirements:
route_idx = "route_idx"
Node()._inject_requirements(holder, 2, group)
if route_idx == 0 and len(group.routes) > 1:
route_idx = "route_idx"
Node._inject_method_check(holder, 2, group)
src.extend(
[
Line(
(
"match = router.matchers"
f"[{group.pattern_idx}].match(path)"
),
1,
),
Line("if match:", 1),
*holder,
Line("basket['__params__'] = match.groupdict()", 2),
Line(
(
f"return router.{route_container}"
f"[{group.segments}][{route_idx}], basket"
),
2,
),
]
)
src.append(Line("raise NotFound", 1))
src.extend(delayed)
self.find_route_src = "".join(
map(str, filter(lambda x: x.render, src))
)
if do_compile:
try:
syntax_tree = ast.parse(self.find_route_src)
if do_optimize:
self._optimize(syntax_tree.body[0])
if sys.version_info.major == 3 and sys.version_info.minor >= 9:
# This is purely a convenience thing. Python 3.9 added this
# feature, so it allows us to see exactly how the
# interpreter will see the code after compiling and any
# optimizing.
setattr(
self,
"find_route_src_compiled",
ast.unparse(syntax_tree), # type: ignore
)
# Sometimes there may be missing meta data, so we add it back
# before compiling
ast.fix_missing_locations(syntax_tree)
compiled_src = compile(
syntax_tree,
"",
"exec",
)
except SyntaxError as se:
syntax_error = (
f"Line {se.lineno}: {se.msg}\n{se.text}"
f"{' '*max(0,int(se.offset or 0)-1) + '^'}"
)
raise FinalizationError(
f"Cannot compile route AST:\n{self.find_route_src}"
f"\n{syntax_error}"
)
ctx: t.Dict[t.Any, t.Any] = {}
exec(compiled_src, None, ctx)
self._find_route = ctx["find_route"]
self._matchers = ctx.get("matchers")
@property
def find_route(self):
return self._find_route
@property
def matchers(self):
return self._matchers
@property
def groups(self):
return {
**self.static_routes,
**self.dynamic_routes,
**self.regex_routes,
}
@property
def routes(self):
return tuple(
[route for group in self.groups.values() for route in group]
)
def _optimize(self, node) -> None:
warn(
"Router AST optimization is an experimental only feature. "
"Results may vary from unoptimized code."
)
if hasattr(node, "body"):
for child in node.body:
self._optimize(child)
# concatenate nested single if blocks
# EXAMPLE:
# if parts[1] == "foo":
# if num > 3:
# BECOMES:
# if parts[1] == 'foo' and num > 3:
# Testing has shown that further recursion does not actually
# produce any faster results.
if self._is_lone_if(node) and self._is_lone_if(node.body[0]):
current = node.body[0]
nested = node.body[0].body[0]
values: t.List[t.Any] = []
for test in [current.test, nested.test]:
if isinstance(test, ast.Compare):
values.append(test)
elif isinstance(test, ast.BoolOp) and isinstance(
test.op, ast.And
):
values.extend(test.values)
else:
...
combined = ast.BoolOp(op=ast.And(), values=values)
current.test = combined
current.body = nested.body
# Look for identical successive if blocks
# EXAMPLE:
# if num == 5:
# foo1()
# if num == 5:
# foo2()
# BECOMES:
# if num == 5:
# foo1()
# foo2()
if (
all(isinstance(child, ast.If) for child in node.body)
# TODO: create func to peoperly compare equality of conditions
# and len({child.test for child in node.body})
and len(node.body) > 1
):
first, *rem = node.body
for item in rem:
first.body.extend(item.body)
node.body = [first]
if hasattr(node, "orelse"):
for child in node.orelse:
self._optimize(child)
@staticmethod
def _is_lone_if(node):
return len(node.body) == 1 and isinstance(node.body[0], ast.If)
def _is_regex(self, path: str):
parts = path_to_parts(path, self.delimiter)
def requires(part):
if not part.startswith("<") or ":" not in part:
return False
_, pattern_type, *__ = part[1:-1].split(":")
return (
part.endswith(":path>")
or self.delimiter in part
or pattern_type not in self.regex_types
)
return any(requires(part) for part in parts)
@@ -0,0 +1,484 @@
import typing as t
from logging import getLogger
from .group import RouteGroup
from .line import Line
from .patterns import REGEX_PARAM_NAME, REGEX_PARAM_NAME_EXT, alpha, ext, slug
logger = getLogger("sanic.root")
class Node:
def __init__(
self,
part: str = "",
root: bool = False,
parent=None,
router=None,
param=None,
unquote=False,
) -> None:
self.root = root
self.part = part
self.parent = parent
self.param = param
self._children: t.Dict[str, "Node"] = {}
self.children: t.Dict[str, "Node"] = {}
self.level = 0
self.base_indent = 0
self.offset = 0
self.groups: t.List[RouteGroup] = []
self.dynamic = False
self.first = False
self.last = False
self.children_basketed = False
self.children_param_injected = False
self.has_deferred = False
self.equality_check = False
self.unquote = unquote
self.router = router
def __str__(self) -> str:
internals = ", ".join(
f"{prop}={getattr(self, prop)}"
for prop in ["part", "level", "groups", "dynamic"]
if getattr(self, prop) or prop in ["level"]
)
return f"<Node: {internals}>"
def __repr__(self) -> str:
return str(self)
@property
def ident(self) -> str:
prefix = (
f"{self.parent.ident}."
if self.parent and not self.parent.root
else ""
)
return f"{prefix}{self.idx}"
@property
def idx(self) -> int:
if not self.parent:
return 1
return list(self.parent.children.keys()).index(self.part) + 1
def finalize_children(self):
"""
Sort the children (if any), and set properties for easy checking
# they are at the beginning or end of the line.
"""
self.children = {
k: v for k, v in sorted(self._children.items(), key=self._sorting)
}
if self.children:
keys = list(self.children.keys())
self.children[keys[0]].first = True
self.children[keys[-1]].last = True
for child in self.children.values():
child.finalize_children()
def display(self) -> None:
"""
Visual display of the tree of nodes
"""
logger.info(" " * 4 * self.level + str(self))
for child in self.children.values():
child.display()
def render(self) -> t.Tuple[t.List[Line], t.List[Line]]:
# output - code injected into the source as it is being
# called/evaluated
# delayed - code that is injected after you do all of its children
# first
# final - code that is injected at the very end of all rendering
src: t.List[Line] = []
delayed: t.List[Line] = []
final: t.List[Line] = []
if not self.root:
src, delayed, final = self.to_src()
for child in self.children.values():
o, f = child.render()
src += o
final += f
return src + delayed, final
def to_src(self) -> t.Tuple[t.List[Line], t.List[Line], t.List[Line]]:
siblings = self.parent.children if self.parent else {}
first_sibling: t.Optional[Node] = None
if not self.first:
first_sibling = next(iter(siblings.values()))
self.base_indent = (
bool(self.level >= 1 or self.first) + self.parent.base_indent
if self.parent
else 0
)
indent = self.base_indent
# See render() docstring for definition of these three sequences
delayed: t.List[Line] = []
final: t.List[Line] = []
src: t.List[Line] = []
# Some cleanup to make code easier to read
src.append(Line("", indent))
src.append(Line(f"# node={self.ident} // part={self.part}", indent))
level = self.level
idx = level - 1
return_bump = not self.dynamic
operation = ">"
conditional = "if"
# The "equality_check" is when we do a "==" operation to check
# that the incoming path is the same length as a particular target.
# Since this could take place in a few different locations, we need
# to be able to track if it has been set.
if self.groups:
operation = "==" if self.level == self.parent.depth else ">="
self.equality_check = operation == "=="
src.append(
Line(
f"{conditional} num {operation} {level}: # CHECK 1",
indent,
)
)
indent += 1
if self.dynamic:
# Injects code to try casting a segment to all POTENTIAL types that
# the defined routes could catch in this location
self._inject_param_check(src, indent, idx)
indent += 1
else:
if (
not self.equality_check
and self.groups
and not self.first
and first_sibling
):
self.equality_check = first_sibling.equality_check
# Maybe try and sneak an equality check in?
if_stmt = "if"
len_check = (
f" and num == {self.level}"
if not self.children and not self.equality_check
else ""
)
self.equality_check |= bool(len_check)
src.append(
Line(
f'{if_stmt} parts[{idx}] == "{self.part}"{len_check}:'
" # CHECK 4",
indent,
)
)
self.base_indent += 1
# Get ready to return some handlers
if self.groups:
return_indent = indent + return_bump
route_idx: t.Union[int, str] = 0
location = delayed
# Do any missing equality_check
if not self.equality_check:
# If if we have not done an equality check and there are
# children nodes, then we know there is a CHECK 1
# for the children that starts at the same level, and will
# be an exclusive conditional to what is being evaluated here.
# Therefore, we can use elif
# example:
# if num == 7: # CHECK 1
# child_node_stuff
# elif num == 6: # CHECK 5
# current_node_stuff
conditional = "elif" if self.children else "if"
operation = "=="
location.append(
Line(
f"{conditional} num {operation} {level}: # CHECK 5",
return_indent,
)
)
return_indent += 1
for group in sorted(self.groups, key=self._group_sorting):
group_bump = 0
# If the route had some requirements, let's make sure we check
# them in the source
if group.requirements:
route_idx = "route_idx"
self._inject_requirements(
location, return_indent + group_bump, group
)
# This is for any inline regex routes. It sould not include,
# path or path-like routes.
if group.regex:
self._inject_regex(
location, return_indent + group_bump, group
)
group_bump += 1
# Since routes are grouped, we need to know which to select
# Inside the compiled source, we keep track so we know which
# handler to assign this to
if route_idx == 0 and len(group.routes) > 1:
route_idx = "route_idx"
self._inject_method_check(
location, return_indent + group_bump, group
)
# The return.kingdom
self._inject_return(
location, return_indent + group_bump, route_idx, group
)
return src, delayed, final
def add_child(self, child: "Node") -> None:
self._children[child.part] = child
def _inject_param_check(self, location, indent, idx):
"""
Try and cast relevant path segments.
"""
lines = [
Line("try:", indent),
Line(
f"basket['__matches__'][{idx}] = "
f"{self.param.cast.__name__}(parts[{idx}])",
indent + 1,
),
Line("except ValueError:", indent),
Line("pass", indent + 1),
Line("else:", indent),
]
if self.unquote and self._cast_as_str(self.param.cast):
lines.append(
Line(
f"basket['__matches__'][{idx}] = "
f"unquote(basket['__matches__'][{idx}])",
indent + 1,
)
)
self.base_indent += 1
location.extend(lines)
@staticmethod
def _cast_as_str(cast) -> bool:
return_type_hint = t.get_type_hints(cast).get("return")
return cast in (str, ext, slug, alpha) or return_type_hint is str
@staticmethod
def _inject_method_check(location, indent, group):
"""
Sometimes we need to check the routing methods inside the generated src
"""
for i, route in enumerate(group.routes):
if_stmt = "if" if i == 0 else "elif"
location.extend(
[
Line(
f"{if_stmt} method in {route.methods}:",
indent,
),
Line(f"route_idx = {i}", indent + 1),
]
)
location.extend(
[
Line("else:", indent),
Line("raise NoMethod", indent + 1),
]
)
def _inject_return(self, location, indent, route_idx, group):
"""
The return statement for the node if needed
"""
routes = "regex_routes" if group.regex else "dynamic_routes"
route_return = "" if group.router.stacking else f"[{route_idx}]"
location.extend(
[
Line(f"# Return {self.ident}", indent),
Line(
(
f"return router.{routes}[{group.segments}]"
f"{route_return}, basket"
),
indent,
),
]
)
def _inject_requirements(self, location, indent, group):
"""
Check any extra checks needed for a route. In path routing, for exampe,
this is used for matching vhosts.
"""
for k, route in enumerate(group):
conditional = "if" if k == 0 else "elif"
location.extend(
[
Line(
(
f"{conditional} extra == {route.requirements} "
f"and method in {route.methods}:"
),
indent,
),
Line((f"route_idx = {k}"), indent + 1),
]
)
location.extend(
[
Line(("else:"), indent),
Line(("raise NotFound"), indent + 1),
]
)
def _inject_regex(self, location, indent, group):
"""
For any path matching that happens in the course of the tree (anything
that has a path matching--<path:path>--or similar matching with regex
delimiter)
"""
location.extend(
[
Line(
(
"match = router.matchers"
f"[{group.pattern_idx}].match(path)"
),
indent,
),
Line("if match:", indent),
Line(
"basket['__params__'] = match.groupdict()",
indent + 1,
),
]
)
def _sorting(self, item) -> t.Tuple[bool, bool, int, int, int, bool, str]:
"""
Primarily use to sort nodes to determine the order of the nested tree
"""
key, child = item
type_ = 0
if child.dynamic:
type_ = child.param.priority
return (
bool(child.groups),
child.dynamic,
type_ * -1,
child.depth * -1,
len(child._children),
not bool(
child.groups and any(group.regex for group in child.groups)
),
key,
)
def _group_sorting(self, item) -> t.Tuple[int, ...]:
"""
When multiple RouteGroups terminate on the same node, we want to
evaluate them based upon the priority of the param matching types
"""
def get_type(segment):
type_ = 0
if segment.startswith("<"):
key = segment[1:-1]
if ":" in key:
key, param_type = key.split(":", 1)
try:
type_ = list(self.router.regex_types.keys()).index(
param_type
)
except ValueError:
type_ = len(list(self.router.regex_types.keys()))
return type_ * -1
segments = tuple(map(get_type, item.parts))
return segments
@property
def depth(self):
if not self._children:
return self.level
return max(child.depth for child in self._children.values())
class Tree:
def __init__(self, router) -> None:
self.root = Node(root=True, router=router)
self.root.level = 0
self.router = router
def generate(self, groups: t.Iterable[RouteGroup]) -> None:
"""
Arrange RouteGroups into hierarchical nodes and arrange them into
a tree
"""
for group in groups:
current = self.root
current.unquote = current.unquote or group.unquote
for level, part in enumerate(group.parts):
param = None
dynamic = part.startswith("<")
if dynamic:
if not REGEX_PARAM_NAME.match(
part
) and not REGEX_PARAM_NAME_EXT.match(part):
raise ValueError(f"Invalid declaration: {part}")
part = f"__dynamic__:{group.params[level].label}"
param = group.params[level]
if part not in current._children:
child = Node(
part=part,
parent=current,
router=self.router,
param=param,
unquote=current.unquote,
)
child.dynamic = dynamic
current.add_child(child)
current = current._children[part]
current.level = level + 1
current.groups.append(group)
def display(self) -> None:
"""
Debug tool to output visual of the tree
"""
self.root.display()
def render(self) -> t.List[Line]:
o, f = self.root.render()
return o + f
def finalize(self):
self.root.finalize_children()
@@ -0,0 +1,97 @@
import re
from urllib.parse import quote, unquote
from sanic_routing.exceptions import InvalidUsage
from .patterns import REGEX_PARAM_NAME, REGEX_PARAM_NAME_EXT
class Immutable(dict):
def __setitem__(self, *args):
raise TypeError("Cannot change immutable dict")
def __delitem__(self, *args):
raise TypeError("Cannot change immutable dict")
def parse_parameter_basket(route, basket, raw_path=None):
params = {}
if basket:
for idx, value in basket.items():
for p in route.params[idx]:
if not raw_path or p.raw_path == raw_path:
if not p.regex:
raw_path = p.raw_path
params[p.name] = p.cast(value)
break
elif p.pattern.search(value):
raw_path = p.raw_path
if "(" in p.pattern:
groups = p.pattern.match(value)
value = groups.group(1)
params[p.name] = p.cast(value)
break
if raw_path:
raise ValueError("Invalid parameter")
if raw_path and not params[p.name]:
raise ValueError("Invalid parameter")
if route.unquote:
for p in route.params[idx]:
if isinstance(params[p.name], str):
params[p.name] = unquote(params[p.name])
if raw_path is None:
raise ValueError("Invalid parameter")
return params, raw_path
def path_to_parts(path, delimiter="/"):
r"""
OK > /foo/<id:int>/bar/<name:[A-z]+>
OK > /foo/<unhashable:[A-Za-z0-9/]+>
OK > /foo/<ext:file\.(?P<ext>txt)>/<ext:[a-z]>
OK > /foo/<user>/<user:str>
OK > /foo/<ext:[a-z]>/<ext:file\.(?P<ext>txt)d>
NOT OK > /foo/<ext:file\.(?P<ext>txt)d>/<ext:[a-z]>
"""
path = unquote(path.lstrip(delimiter))
delimiter = re.escape(delimiter)
return tuple(
part if part.startswith("<") else quote(part)
for part in re.split(rf"{delimiter}(?=[^>]*(?:<(?<!\?<)|$))", path)
)
def parts_to_path(parts, delimiter="/"):
path = []
for part in parts:
if part.startswith("<"):
try:
match = REGEX_PARAM_NAME.match(part)
param_type = ""
if match.group(2):
param_type = f":{match.group(2)}"
path.append(f"<{match.group(1)}{param_type}>")
except AttributeError:
try:
match = REGEX_PARAM_NAME_EXT.match(part)
filename_type = ""
extension_type = ""
if match.group(2):
filename_type = f"={match.group(2)}"
if match.group(3):
extension_type = f"={match.group(3)}"
segment = (
f"<{match.group(1)}{filename_type}:"
f"ext{extension_type}>"
)
path.append(segment)
except AttributeError:
raise InvalidUsage(f"Invalid declaration: {part}")
else:
path.append(part)
return delimiter.join(path)