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]', , '[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