ranchimallflo-api/py3.7/lib/python3.7/site-packages/quart/routing.py

500 lines
17 KiB
Python

import re
import uuid
from ast import literal_eval
from collections import defaultdict
from typing import (
Any, Dict, Generator, Iterator, List, NamedTuple, Optional, Pattern, Set, Tuple, Union,
)
from urllib.parse import urlencode, urlunsplit
from sortedcontainers import SortedListWithKey
from .exceptions import MethodNotAllowed, NotFound, RedirectRequired
ROUTE_VAR_RE = re.compile(r''' # noqa
(?P<static>[^<]*) # static rule data
<
(?:
(?P<converter>[a-zA-Z_][a-zA-Z0-9_]*) # converter name
(?:\((?P<args>.*?)\))? # converter arguments
\: # variable delimiter
)?
(?P<variable>[a-zA-Z][a-zA-Z0-9_]*) # variable name
>
''', re.VERBOSE) # noqa
CONVERTER_ARGS_RE = re.compile(r''' # noqa
((?P<name>\w+)\s*=\s*)?
(?P<value>
True|False|
\d+.\d+|
\d+.|
\d+|
\w+|
[urUR]?(?P<str_value>"[^"]*?"|'[^']*')
)\s*,
''', re.VERBOSE | re.UNICODE) # noqa
VariablePart = NamedTuple(
'VariablePart',
[('converter', Optional[str]), ('arguments', Tuple[List[Any], Dict[str, Any]]), ('name', str)],
)
WeightedPart = NamedTuple('Weight', [('converter', bool), ('weight', int)])
class ValidationError(Exception):
pass
class BuildError(Exception):
def __init__(
self,
endpoint: str,
rules: List['Rule'],
values: Optional[Dict]=None,
method: Optional[str]=None,
) -> None:
self.endpoint = endpoint
self.rules = rules
self.values = values
self.method = method
def __str__(self) -> str:
message = [f"Could not build rule for endpoint '{self.endpoint}'."]
if len(self.rules):
for rule in self.rules:
message.append(f"{rule.rule} Cannot be built")
if self.method is not None and self.method not in rule.methods:
message.append(f"as {self.method} is not one of {rule.methods}.")
elif self.values is not None:
message.append(
f"as {self.values.keys()} do not match {rule._converters.keys()}.",
)
else:
message.append('No endpoint found.')
return ' '.join(message)
class BaseConverter:
regex = r'[^/]+'
weight = 100
def to_python(self, value: str) -> Any:
return value
def to_url(self, value: Any) -> str:
return value
class StringConverter(BaseConverter):
def __init__(
self, minlength: int=1, maxlength: Optional[int]=None, length: Optional[int]=None,
) -> None:
if length is not None:
re_length = '{%d}' % length
else:
maxlength = '' if maxlength is None else int(maxlength) # type: ignore
re_length = '{%d,%s}' % (minlength, maxlength)
self.regex = f"[^/]{re_length}"
class AnyConverter(BaseConverter):
def __init__(self, *items: str) -> None:
self.regex = '(?:%s)' % '|'.join((re.escape(x) for x in items))
class PathConverter(BaseConverter):
regex = r'[^/].*?'
weight = 200
class IntegerConverter(BaseConverter):
regex = r'\d+'
weight = 50
def __init__(
self, fixed_digits: Optional[int]=None, min: Optional[int]=None,
max: Optional[int]=None,
) -> None:
self.fixed_digits = fixed_digits
self.min = min
self.max = max
def to_python(self, value: str) -> int:
if self.fixed_digits is not None and len(value) > self.fixed_digits:
raise ValidationError()
converted_value = int(value)
if (
self.min is not None and self.min > converted_value or
self.max is not None and self.max < converted_value
):
raise ValidationError()
return converted_value
def to_url(self, value: int) -> str:
if self.fixed_digits is not None:
return f"{value:0{self.fixed_digits}d}"
else:
return str(value)
class FloatConverter(BaseConverter):
regex = r'\d+\.\d+'
weight = 50
def __init__(self, min: Optional[float]=None, max: Optional[float]=None) -> None:
self.min = min
self.max = max
def to_python(self, value: str) -> float:
converted_value = float(value)
if (
self.min is not None and self.min > converted_value or
self.max is not None and self.max < converted_value
):
raise ValidationError()
return converted_value
def to_url(self, value: float) -> str:
return str(value)
class UUIDConverter(BaseConverter):
regex = r'[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}' # noqa
def to_python(self, value: str) -> uuid.UUID:
return uuid.UUID(value)
def to_url(self, value: uuid.UUID) -> str:
return str(value)
class Map:
default_converters = {
'any': AnyConverter,
'default': StringConverter,
'float': FloatConverter,
'int': IntegerConverter,
'path': PathConverter,
'string': StringConverter,
'uuid': UUIDConverter,
}
def __init__(self, host_matching: bool=False) -> None:
self.rules = SortedListWithKey(key=lambda rule: rule.match_key)
self.endpoints: Dict[str, SortedListWithKey] = defaultdict(lambda: SortedListWithKey(key=lambda rule: rule.build_key)) # noqa
self.converters = self.default_converters.copy()
self.host_matching = host_matching
def add(self, rule: 'Rule') -> None:
rule.bind(self)
self.endpoints[rule.endpoint].add(rule)
self.rules.add(rule)
def bind_to_request(
self,
scheme: str,
server_name: str,
method: str,
path: str,
query_string: bytes,
) -> 'MapAdapter':
return MapAdapter(self, scheme, server_name, method, path, query_string)
def bind(self, scheme: str, server_name: str) -> 'MapAdapter':
return MapAdapter(self, scheme, server_name)
def iter_rules(self, endpoint: Optional[str]=None) -> Iterator['Rule']:
if endpoint is not None:
return iter(self.endpoints[endpoint])
return iter(self.rules)
class MapAdapter:
def __init__(
self,
map: Map,
scheme: str,
server_name: str,
method: Optional[str]=None,
path: Optional[str]=None,
query_string: Optional[bytes]=None,
) -> None:
self.map = map
self.scheme = scheme
self.server_name = server_name
self.path = f"/{path.lstrip('/')}" if path is not None else path
self.method = method
self.query_string = query_string
def build(
self,
endpoint: str,
values: Optional[dict]=None,
method: Optional[str]=None,
scheme: Optional[str]=None,
external: bool=False,
) -> str:
values = values or {}
rules = self.map.endpoints[endpoint]
for rule in rules:
if rule.buildable(values, method=method):
path = rule.build(**values)
if external:
scheme = scheme or self.scheme
host = rule.host or self.server_name
return f"{scheme}://{host}{path}"
else:
return path
raise BuildError(endpoint, rules, values, method)
def match(self) -> Tuple['Rule', Dict[str, Any]]:
allowed_methods: Set[str] = set()
for rule, variables, needs_slash in self._matches():
if self.method in rule.methods:
if needs_slash:
raise RedirectRequired(self._make_redirect_url(rule, variables))
# Check if there is a default rule that can be used instead
for potential_rule in self.map.endpoints[rule.endpoint]:
if potential_rule.provides_defaults_for(rule, **variables):
raise RedirectRequired(self._make_redirect_url(potential_rule, variables))
return rule, variables
else:
allowed_methods.update(rule.methods)
if allowed_methods:
raise MethodNotAllowed(allowed_methods=allowed_methods)
raise NotFound()
def _make_redirect_url(self, rule: 'Rule', variables: Dict[str, Any]) -> str:
path = rule.build(**variables)
suffix = self.query_string.decode('ascii')
return urlunsplit((self.scheme, self.server_name, path, suffix, ''))
def allowed_methods(self) -> Set[str]:
allowed_methods: Set[str] = set()
for rule, *_ in self._matches():
allowed_methods.update(rule.methods)
return allowed_methods
def _matches(self) -> Generator[Tuple['Rule', Dict[str, Any], bool], None, None]:
if self.map.host_matching:
full_path = f"{self.server_name}|{self.path}"
else:
full_path = f"|{self.path}"
for rule in self.map.rules:
variables, needs_slash = rule.match(full_path)
if variables is not None:
yield rule, variables, needs_slash
class Rule:
def __init__(
self,
rule: str,
methods: Set[str],
endpoint: str,
strict_slashes: bool=True,
defaults: Optional[dict]=None,
host: Optional[str]=None,
*,
provide_automatic_options: bool=True,
is_websocket: bool=False,
) -> None:
if not rule.startswith('/'):
raise ValueError(f"Rule '{rule}' does not start with a slash")
self.rule = rule
self.is_leaf = not rule.endswith('/')
self.is_websocket = is_websocket
if 'GET' in methods and 'HEAD' not in methods and not self.is_websocket:
methods.add('HEAD')
self.methods = frozenset(method.upper() for method in methods)
if self.is_websocket and self.methods != {'GET'}: # type: ignore
raise ValueError(f"{methods} must only be GET for a websocket route")
self.endpoint = endpoint
self.strict_slashes = strict_slashes
self.defaults = defaults or {}
self.host = host
self.map: Optional[Map] = None
self._pattern: Optional[Pattern] = None
self._builder: Optional[str] = None
self._converters: Dict[str, BaseConverter] = {}
self._weights: List[WeightedPart] = []
self.provide_automatic_options = provide_automatic_options
def __repr__(self) -> str:
return f"Rule({self.rule}, {self.methods}, {self.endpoint}, {self.strict_slashes})"
def match(self, path: str) -> Tuple[Optional[Dict[str, Any]], bool]:
"""Check if the path matches this Rule.
If it does it returns a dict of matched and converted values,
otherwise None is returned.
"""
match = self._pattern.match(path)
if match is not None:
# If the route is a branch (not leaf) and the path is
# missing a trailing slash then it needs one to be
# considered a match in the strict slashes mode.
needs_slash = (
self.strict_slashes and not self.is_leaf and match.groupdict()['__slash__'] != '/'
)
try:
converted_varaibles = {
name: self._converters[name].to_python(value)
for name, value in match.groupdict().items()
if name != '__slash__'
}
except ValidationError: # Doesn't meet conversion rules, no match
return None, False
else:
return {**self.defaults, **converted_varaibles}, needs_slash
else:
return None, False
def provides_defaults_for(self, rule: 'Rule', **values: Any) -> bool:
"""Returns true if this rule provides defaults for the argument and values."""
defaults_match = all(
values[key] == self.defaults[key] for key in self.defaults if key in values # noqa: S101, E501
)
return self != rule and bool(self.defaults) and defaults_match
def build(self, **values: Any) -> str:
"""Build this rule into a path using the values given."""
converted_values = {
key: self._converters[key].to_url(value)
for key, value in values.items()
if key in self._converters
}
result = self._builder.format(**converted_values).split('|', 1)[1]
query_string = urlencode(
{
key: value
for key, value in values.items()
if key not in self._converters and key not in self.defaults
},
doseq=True,
)
if query_string:
result = "{}?{}".format(result, query_string)
return result
def buildable(self, values: Optional[dict]=None, method: Optional[str]=None) -> bool:
"""Return True if this rule can build with the values and method."""
if method is not None and method not in self.methods:
return False
defaults_match = all(
values[key] == self.defaults[key] for key in self.defaults if key in values # noqa: S101, E501
)
return defaults_match and set(values.keys()) >= set(self._converters.keys())
def bind(self, map: Map) -> None:
"""Bind the Rule to a Map and compile it."""
if self.map is not None:
raise RuntimeError(f"{self!r} is already bound to {self.map!r}")
self.map = map
pattern = ''
builder = ''
full_rule = "{}\\|{}".format(self.host or '', self.rule)
for part in _parse_rule(full_rule):
if isinstance(part, VariablePart):
converter = self.map.converters[part.converter](
*part.arguments[0], **part.arguments[1],
)
pattern += f"(?P<{part.name}>{converter.regex})"
self._converters[part.name] = converter
builder += '{' + part.name + '}'
self._weights.append(WeightedPart(True, converter.weight))
else:
builder += part
pattern += part
self._weights.append(WeightedPart(False, -len(part)))
if not self.is_leaf or not self.strict_slashes:
# Pattern should match with or without a trailing slash
pattern = f"{pattern.rstrip('/')}(?<!/)(?P<__slash__>/?)$"
else:
pattern = f"{pattern}$"
self._pattern = re.compile(pattern)
self._builder = builder
@property
def match_key(self) -> Tuple[bool, bool, int, List[WeightedPart]]:
"""A Key to sort the rules by weight for matching.
The key leads to ordering:
- By first order by defaults as they are simple rules without
conversions.
- Then on the complexity of the rule, i.e. does it have any
converted parts. This is as simple rules are quick to match
or reject.
- Then by the number of parts, with more complex (more parts)
first.
- Finally by the weights themselves. Note that weights are also
sub keyed by converter first then weight second.
"""
if self.map is None:
raise RuntimeError(f"{self!r} is not bound to a Map")
complex_rule = any(weight.converter for weight in self._weights)
return (not bool(self.defaults), complex_rule, -len(self._weights), self._weights)
@property
def build_key(self) -> Tuple[bool, int]:
"""A Key to sort the rules by weight for building.
The key leads to ordering:
- By routes with defaults first, as these must be evaulated
for building before ones without.
- Then the more complex routes (most converted parts).
"""
if self.map is None:
raise RuntimeError(f"{self!r} is not bound to a Map")
return (not bool(self.defaults), -sum(1 for weight in self._weights if weight.converter))
def _parse_rule(rule: str) -> Generator[Union[str, VariablePart], None, None]:
variable_names: Set[str] = set()
final_match = 0
for match in ROUTE_VAR_RE.finditer(rule):
named_groups = match.groupdict()
if named_groups['static'] is not None:
yield named_groups['static']
variable = named_groups['variable']
if variable in variable_names:
raise ValueError(f"Variable name {variable} used more than once")
else:
variable_names.add(variable)
arguments = _parse_converter_args(named_groups['args'] or '')
yield VariablePart(named_groups['converter'] or 'default', arguments, variable)
final_match = match.span()[-1]
yield rule[final_match:]
def _parse_converter_args(raw: str) -> Tuple[List[Any], Dict[str, Any]]:
raw += ',' # Simplifies matching regex if each argument has a trailing comma
args = []
kwargs = {}
for match in CONVERTER_ARGS_RE.finditer(raw):
value = match.group('str_value') or match.group('value')
try:
value = literal_eval(value)
except ValueError:
value = str(value)
name = match.group('name')
if not name:
args.append(value)
else:
kwargs[name] = value
return args, kwargs