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:
@@ -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)
|
||||
Reference in New Issue
Block a user