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>
381 lines
12 KiB
Python
381 lines
12 KiB
Python
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
|