From 5501794506a8e8c730de904cd6dbd57d68e90757 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Sun, 1 Aug 2021 10:35:55 +0300 Subject: [PATCH 01/10] Add extension parameter type --- sanic_routing/parameter.py | 42 ++++++++++ sanic_routing/patterns.py | 61 +++++++++++--- sanic_routing/route.py | 45 ++++++++--- sanic_routing/router.py | 39 +++++---- tests/test_builtin_param_types.py | 127 +++++++++++++++++++++++++++++- 5 files changed, 275 insertions(+), 39 deletions(-) create mode 100644 sanic_routing/parameter.py diff --git a/sanic_routing/parameter.py b/sanic_routing/parameter.py new file mode 100644 index 0000000..71ba2dd --- /dev/null +++ b/sanic_routing/parameter.py @@ -0,0 +1,42 @@ +import re +import typing as t +from types import SimpleNamespace + + +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: + ... diff --git a/sanic_routing/patterns.py b/sanic_routing/patterns.py index cdc6022..7540353 100644 --- a/sanic_routing/patterns.py +++ b/sanic_routing/patterns.py @@ -1,6 +1,11 @@ import re import uuid from datetime import datetime +from typing import Any, Callable, Dict, Pattern, Tuple, Type + +from sanic_routing.exceptions import InvalidUsage, NotFound + +from .parameter import ParamInfo def parse_date(d): @@ -19,6 +24,38 @@ def slug(param: str) -> str: return param +def ext(param: str) -> Tuple[str, str]: + if not param.count(".") >= 1: + raise ValueError(f"Value {param} does not match the ext format") + name, ext = param.rsplit(".", 1) + if not ext.isalnum(): + raise ValueError(f"Value {param} does not match the ext format") + return name, ext + + +class ExtParamInfo(ParamInfo): + def __init__(self, **kwargs): + super().__init__(**kwargs) + definition = self.raw_path[1:-1] + parts = definition.split(":") + self.ctx.allowed = [] + if len(parts) == 3: + self.ctx.allowed = parts[2].split("|") + if not all(ext.isalnum() for ext in self.ctx.allowed): + raise InvalidUsage( + "Extensions may only be alphabetic characters" + ) + elif len(parts) >= 3: + raise InvalidUsage(f"Invalid ext definition: {self.raw_path}") + + def process(self, params, value): + filename, ext = value + if self.ctx.allowed and ext not in self.ctx.allowed: + raise NotFound(f"Invalid extension: {ext}") + params[self.name] = filename + params["ext"] = ext + + REGEX_PARAM_NAME = re.compile(r"^<([a-zA-Z_][a-zA-Z0-9_]*)(?::(.*))?>$") # Predefined path parameter types. The value is a tuple consisteing of a @@ -29,18 +66,23 @@ def slug(param: str) -> str: # 3. raise ValueError if it cannot # The regular expression is generally NOT used. Unless the path is forced # to use regex patterns. -REGEX_TYPES = { - "string": (str, re.compile(r"^[^/]+$")), - "str": (str, re.compile(r"^[^/]+$")), - "slug": (slug, re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")), - "alpha": (alpha, re.compile(r"^[A-Za-z]+$")), - "path": (str, re.compile(r"^[^/]?.*?$")), - "number": (float, re.compile(r"^-?(?:\d+(?:\.\d*)?|\.\d+)$")), - "float": (float, re.compile(r"^-?(?:\d+(?:\.\d*)?|\.\d+)$")), - "int": (int, re.compile(r"^-?\d+$")), +REGEX_TYPES_ANNOTATION = Dict[ + str, Tuple[Callable[[str], Any], Pattern, Type[ParamInfo]] +] +REGEX_TYPES: REGEX_TYPES_ANNOTATION = { + "string": (str, re.compile(r"^[^/]+$"), ParamInfo), + "str": (str, re.compile(r"^[^/]+$"), ParamInfo), + "ext": (ext, re.compile(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), + "number": (float, re.compile(r"^-?(?:\d+(?:\.\d*)?|\.\d+)$"), 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, @@ -48,5 +90,6 @@ def slug(param: str) -> str: 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, ), } diff --git a/sanic_routing/route.py b/sanic_routing/route.py index f2ff972..71f31f1 100644 --- a/sanic_routing/route.py +++ b/sanic_routing/route.py @@ -1,17 +1,12 @@ import re import typing as t -from collections import namedtuple from types import SimpleNamespace from warnings import warn from .exceptions import InvalidUsage, ParameterNameConflicts +from .parameter import ParamInfo from .utils import Immutable, parts_to_path, path_to_parts -ParamInfo = namedtuple( - "ParamInfo", - ("name", "raw_path", "label", "cast", "pattern", "regex", "priority"), -) - class Requirements(Immutable): def __hash__(self): @@ -169,9 +164,17 @@ def _setup_params(self): label, _type, pattern, + param_info_class, ) = self.parse_parameter_string(part[1:-1]) + self.add_parameter( - idx, name, key_path, label, _type, pattern + idx, + name, + key_path, + label, + _type, + pattern, + param_info_class, ) def add_parameter( @@ -182,6 +185,7 @@ def add_parameter( label: str, cast: t.Type, pattern=None, + param_info_class=ParamInfo, ): if pattern and isinstance(pattern, str): if not pattern.startswith("^"): @@ -197,8 +201,14 @@ def add_parameter( if is_regex else list(self.router.regex_types.keys()).index(label) ) - self._params[idx] = ParamInfo( - name, raw_path, label, cast, pattern, is_regex, priority + 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): @@ -219,7 +229,8 @@ def _compile_regex(self): for part in self.parts: if part.startswith("<"): - name, *_, pattern = self.parse_parameter_string(part) + name, *_, pattern, __ = self.parse_parameter_string(part) + if not isinstance(pattern, str): pattern = pattern.pattern.strip("^$") compiled = re.compile(pattern) @@ -318,6 +329,7 @@ def parse_parameter_string(self, parameter_string: str): label = "str" if ":" in parameter_string: name, label = parameter_string.split(":", 1) + if not name: raise ValueError( f"Invalid parameter syntax: {parameter_string}" @@ -337,7 +349,14 @@ def parse_parameter_string(self, parameter_string: str): DeprecationWarning, ) - default = (str, label) + default = (str, label, ParamInfo) # Pull from pre-configured types - _type, pattern = self.router.regex_types.get(label, default) - return name, label, _type, pattern + found = self.router.regex_types.get(label) + if not found: + if ":" in label: + label, _ = label.split(":", 1) + found = self.router.regex_types.get(label, default) + else: + found = default + _type, pattern, param_info_class = found + return name, label, _type, pattern, param_info_class diff --git a/sanic_routing/router.py b/sanic_routing/router.py index 8e964d6..378486a 100644 --- a/sanic_routing/router.py +++ b/sanic_routing/router.py @@ -7,6 +7,7 @@ from warnings import warn from sanic_routing.group import RouteGroup +from sanic_routing.parameter import ParamInfo from .exceptions import ( BadMethod, @@ -16,7 +17,7 @@ NotFound, ) from .line import Line -from .patterns import REGEX_TYPES +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 @@ -24,10 +25,6 @@ # 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 # noqa isort:skip class BaseRouter(ABC): @@ -61,7 +58,10 @@ def __init__( self.ctx = SimpleNamespace() self.cascade_not_found = cascade_not_found - self.regex_types = {**REGEX_TYPES} + 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): @@ -108,14 +108,17 @@ def resolve( # Regex routes evaluate and can extract params directly. They are set # on param_basket["__params__"] params = param_basket["__params__"] + if not params: # 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. - params = { - param.name: param_basket["__matches__"][idx] - for idx, param in route.params.items() - } + for idx, param in route.params.items(): + value = param_basket["__matches__"][idx] + if isinstance(value, tuple): + param.process(params, value) + else: + params[param.name] = value # Double check that if we made a match it is not a false positive # because of strict_slashes @@ -239,7 +242,11 @@ def add( return route def register_pattern( - self, label: str, cast: t.Callable[[str], t.Any], pattern: Pattern + self, + label: str, + cast: t.Callable[[str], t.Any], + pattern: t.Union[str, Pattern], + param_info_class: t.Type[ParamInfo] = ParamInfo, ): """ Add a custom parameter type to the router. The cast shoud raise a @@ -269,14 +276,16 @@ def register_pattern( "When registering a pattern, cast must be a " f"callable, not cast={cast}" ) - if not isinstance(pattern, str): + if isinstance(pattern, str): + pattern = re.compile(pattern) + if not isinstance(pattern, Pattern): raise InvalidUsage( "When registering a pattern, pattern must be a " - f"string, not pattern={pattern}" + f"string, not pattern={type(pattern)}" ) globals()[cast.__name__] = cast - self.regex_types[label] = (cast, pattern) + self.regex_types[label] = (cast, pattern, param_info_class) def finalize(self, do_compile: bool = True, do_optimize: bool = False): """ @@ -591,7 +600,7 @@ def requires(part): if not part.startswith("<") or ":" not in part: return False - _, pattern_type = part[1:-1].split(":", 1) + _, pattern_type, *__ = part[1:-1].split(":") return ( part.endswith(":path>") diff --git a/tests/test_builtin_param_types.py b/tests/test_builtin_param_types.py index cd61b0a..5f3be28 100644 --- a/tests/test_builtin_param_types.py +++ b/tests/test_builtin_param_types.py @@ -1,7 +1,6 @@ import pytest - from sanic_routing import BaseRouter -from sanic_routing.exceptions import NotFound +from sanic_routing.exceptions import InvalidUsage, NotFound @pytest.fixture @@ -131,3 +130,127 @@ def test_correct_slug_v_string(handler): assert isinstance(retval, str) assert retval == "FooBar" + + +@pytest.mark.parametrize( + "value", ("somefile.txt", "SomeFile.mp3", "some.thing", "with.extra.dot") +) +def test_ext_not_defined_matches(value): + def handler(**kwargs): + return kwargs + + router = Router() + + router.add("/", handler) + router.finalize() + + _, handler, params = router.get(f"/{value}", "BASE") + retval = handler(**params) + + filename, ext = value.rsplit(".", 1) + assert retval["filename"] == filename + assert retval["ext"] == ext + + +@pytest.mark.parametrize("value", ("somefile.mp3", "with.extra.mp3")) +def test_ext_single_defined_matches(value): + def handler(**kwargs): + return kwargs + + router = Router() + + router.add("/", handler) + router.finalize() + + _, handler, params = router.get(f"/{value}", "BASE") + retval = handler(**params) + + filename, ext = value.rsplit(".", 1) + assert retval["filename"] == filename + assert retval["ext"] == ext + + +@pytest.mark.parametrize( + "value", + ("somefile.png", "with.extra.png", "somefile.jpg", "with.extra.jpg"), +) +def test_ext_multiple_defined_matches(value): + def handler(**kwargs): + return kwargs + + router = Router() + + router.add("/", handler) + router.finalize() + + _, handler, params = router.get(f"/{value}", "BASE") + retval = handler(**params) + + filename, ext = value.rsplit(".", 1) + assert retval["filename"] == filename + assert retval["ext"] == ext + + +@pytest.mark.parametrize( + "value", + ("somefile", "SomeFile."), +) +def test_ext_not_defined_no_matches(handler, value): + def handler(**kwargs): + return kwargs + + router = Router() + + router.add("/", handler) + router.finalize() + + with pytest.raises(NotFound): + router.get(f"/{value}", "BASE") + + +@pytest.mark.parametrize( + "value", + ("somefile", "SomeFile.", "somefile.jpg"), +) +def test_ext_single_defined_no_matches(handler, value): + def handler(**kwargs): + return kwargs + + router = Router() + + router.add("/", handler) + router.finalize() + + with pytest.raises(NotFound): + router.get(f"/{value}", "BASE") + + +@pytest.mark.parametrize( + "value", + ("somefile", "SomeFile.", "somefile.txt"), +) +def test_ext_multiple_defined_no_matches(handler, value): + def handler(**kwargs): + return kwargs + + router = Router() + + router.add("/", handler) + router.finalize() + + with pytest.raises(NotFound): + router.get(f"/{value}", "BASE") + + +@pytest.mark.parametrize( + "definition", + ( + "", + "", + ), +) +def test_bad_ext_definition(handler, definition): + router = Router() + + with pytest.raises(InvalidUsage): + router.add(f"/{definition}", handler) From 54214a1b20c3451f3e2562a21d552ab1faad7832 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 20 Dec 2021 02:24:27 +0200 Subject: [PATCH 02/10] WIP with alternative syntax to allow filename param types --- sanic_routing/patterns.py | 40 +++++++++++++++++++++++++++++++++------ sanic_routing/route.py | 15 ++++++++++----- sanic_routing/router.py | 2 +- sanic_routing/tree.py | 6 ++++-- sanic_routing/utils.py | 18 ++++++++++++++++-- 5 files changed, 65 insertions(+), 16 deletions(-) diff --git a/sanic_routing/patterns.py b/sanic_routing/patterns.py index 7540353..26e9f42 100644 --- a/sanic_routing/patterns.py +++ b/sanic_routing/patterns.py @@ -34,29 +34,57 @@ def ext(param: str) -> Tuple[str, str]: class ExtParamInfo(ParamInfo): + __slots__ = ( + "cast", + "ctx", + "label", + "name", + "pattern", + "priority", + "raw_path", + "regex", + "name_type", + "ext_type", + ) + def __init__(self, **kwargs): super().__init__(**kwargs) - definition = self.raw_path[1:-1] - parts = definition.split(":") + match = REGEX_PARAM_NAME_EXT.match(self.raw_path) + self.name_type = match.group(2) + self.ext_type = match.group(3) + # definition = self.raw_path[1:-1] + # parts = definition.split(":") + regex_type = REGEX_TYPES.get(self.name_type) + self.ctx.cast = None + if regex_type: + self.ctx.cast = regex_type[0] self.ctx.allowed = [] - if len(parts) == 3: - self.ctx.allowed = parts[2].split("|") + if self.ext_type: + self.ctx.allowed = self.ext_type.split("|") if not all(ext.isalnum() for ext in self.ctx.allowed): raise InvalidUsage( "Extensions may only be alphabetic characters" ) - elif len(parts) >= 3: - raise InvalidUsage(f"Invalid ext definition: {self.raw_path}") + # elif len(parts) >= 3: + # raise InvalidUsage(f"Invalid ext definition: {self.raw_path}") def process(self, params, value): filename, ext = value 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 REGEX_PARAM_NAME = re.compile(r"^<([a-zA-Z_][a-zA-Z0-9_]*)(?::(.*))?>$") +REGEX_PARAM_NAME_EXT = re.compile( + r"^<([a-zA-Z_][a-zA-Z0-9_]*)(?:=([a-z]+))?(?::ext(?:=([a-z|]+))?)>$" +) # Predefined path parameter types. The value is a tuple consisteing of a # callable and a compiled regular expression. diff --git a/sanic_routing/route.py b/sanic_routing/route.py index 71f31f1..af6a227 100644 --- a/sanic_routing/route.py +++ b/sanic_routing/route.py @@ -327,8 +327,13 @@ def parse_parameter_string(self, parameter_string: str): 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( @@ -353,10 +358,10 @@ def parse_parameter_string(self, parameter_string: str): # Pull from pre-configured types found = self.router.regex_types.get(label) if not found: - if ":" in label: - label, _ = label.split(":", 1) - found = self.router.regex_types.get(label, default) - else: - found = default + # if ":" in label: + # label, _ = label.split(":", 1) + # found = self.router.regex_types.get(label, default) + # else: + found = default _type, pattern, param_info_class = found return name, label, _type, pattern, param_info_class diff --git a/sanic_routing/router.py b/sanic_routing/router.py index 8772bd1..0e29d88 100644 --- a/sanic_routing/router.py +++ b/sanic_routing/router.py @@ -109,7 +109,7 @@ def resolve( # on param_basket["__params__"] params = param_basket["__params__"] - if not 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. diff --git a/sanic_routing/tree.py b/sanic_routing/tree.py index 23c0e52..4825152 100644 --- a/sanic_routing/tree.py +++ b/sanic_routing/tree.py @@ -3,7 +3,7 @@ from .group import RouteGroup from .line import Line -from .patterns import REGEX_PARAM_NAME +from .patterns import REGEX_PARAM_NAME, REGEX_PARAM_NAME_EXT logger = getLogger("sanic.root") @@ -440,7 +440,9 @@ def generate(self, groups: t.Iterable[RouteGroup]) -> None: param = None dynamic = part.startswith("<") if dynamic: - if not REGEX_PARAM_NAME.match(part): + 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] diff --git a/sanic_routing/utils.py b/sanic_routing/utils.py index c19ece4..c997fad 100644 --- a/sanic_routing/utils.py +++ b/sanic_routing/utils.py @@ -1,7 +1,7 @@ import re from urllib.parse import quote, unquote -from .patterns import REGEX_PARAM_NAME +from .patterns import REGEX_PARAM_NAME, REGEX_PARAM_NAME_EXT class Immutable(dict): @@ -74,7 +74,21 @@ def parts_to_path(parts, delimiter="/"): param_type = f":{match.group(2)}" path.append(f"<{match.group(1)}{param_type}>") except AttributeError: - raise ValueError(f"Invalid declaration: {part}") + 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 ValueError(f"Invalid declaration: {part}") else: path.append(part) return delimiter.join(path) From 703246464ba6af01f5a90ed59e86cf87e48c3fa4 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 20 Dec 2021 09:38:08 +0200 Subject: [PATCH 03/10] Allow for multiple dots in extensions --- sanic_routing/parameter.py | 42 ------------------- sanic_routing/patterns.py | 83 ++++++++++++++++++++++++++------------ sanic_routing/route.py | 2 +- sanic_routing/router.py | 2 +- 4 files changed, 59 insertions(+), 70 deletions(-) delete mode 100644 sanic_routing/parameter.py diff --git a/sanic_routing/parameter.py b/sanic_routing/parameter.py deleted file mode 100644 index 71ba2dd..0000000 --- a/sanic_routing/parameter.py +++ /dev/null @@ -1,42 +0,0 @@ -import re -import typing as t -from types import SimpleNamespace - - -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: - ... diff --git a/sanic_routing/patterns.py b/sanic_routing/patterns.py index 26e9f42..ca0ff3b 100644 --- a/sanic_routing/patterns.py +++ b/sanic_routing/patterns.py @@ -1,12 +1,12 @@ import re +import typing as t import uuid from datetime import datetime +from types import SimpleNamespace from typing import Any, Callable, Dict, Pattern, Tuple, Type from sanic_routing.exceptions import InvalidUsage, NotFound -from .parameter import ParamInfo - def parse_date(d): return datetime.strptime(d, "%Y-%m-%d").date() @@ -24,16 +24,11 @@ def slug(param: str) -> str: return param -def ext(param: str) -> Tuple[str, str]: - if not param.count(".") >= 1: - raise ValueError(f"Value {param} does not match the ext format") - name, ext = param.rsplit(".", 1) - if not ext.isalnum(): - raise ValueError(f"Value {param} does not match the ext format") - return name, ext +def ext(param: str) -> Tuple[str, ...]: + return tuple(param.split(".")) -class ExtParamInfo(ParamInfo): +class ParamInfo: __slots__ = ( "cast", "ctx", @@ -43,33 +38,67 @@ class ExtParamInfo(ParamInfo): "priority", "raw_path", "regex", - "name_type", - "ext_type", ) + 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: + ... + + +class ExtParamInfo(ParamInfo): def __init__(self, **kwargs): super().__init__(**kwargs) match = REGEX_PARAM_NAME_EXT.match(self.raw_path) - self.name_type = match.group(2) - self.ext_type = match.group(3) - # definition = self.raw_path[1:-1] - # parts = definition.split(":") - regex_type = REGEX_TYPES.get(self.name_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] self.ctx.allowed = [] - if self.ext_type: - self.ctx.allowed = self.ext_type.split("|") - if not all(ext.isalnum() for ext in 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( - "Extensions may only be alphabetic characters" + "All allowed extensions within a single route definition " + "must contain the same number of subparts. For example: " + " and are both " + "acceptable, but is not." ) - # elif len(parts) >= 3: - # raise InvalidUsage(f"Invalid ext definition: {self.raw_path}") + 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): - filename, ext = 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: @@ -81,10 +110,12 @@ def process(self, params, value): params["ext"] = ext +EXTENSION = r"[a-z0-9](?:[a-z0-9\.]*[a-z0-9])?" REGEX_PARAM_NAME = re.compile(r"^<([a-zA-Z_][a-zA-Z0-9_]*)(?::(.*))?>$") REGEX_PARAM_NAME_EXT = re.compile( - r"^<([a-zA-Z_][a-zA-Z0-9_]*)(?:=([a-z]+))?(?::ext(?:=([a-z|]+))?)>$" + r"^<([a-zA-Z_][a-zA-Z0-9_]*)(?:=([a-z]+))?(?::ext(?:=([a-z0-9|\.]+))?)>$" ) +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. @@ -100,7 +131,7 @@ def process(self, params, value): REGEX_TYPES: REGEX_TYPES_ANNOTATION = { "string": (str, re.compile(r"^[^/]+$"), ParamInfo), "str": (str, re.compile(r"^[^/]+$"), ParamInfo), - "ext": (ext, re.compile(r"^[^/]+$"), ExtParamInfo), + "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), diff --git a/sanic_routing/route.py b/sanic_routing/route.py index af6a227..a7e239d 100644 --- a/sanic_routing/route.py +++ b/sanic_routing/route.py @@ -4,7 +4,7 @@ from warnings import warn from .exceptions import InvalidUsage, ParameterNameConflicts -from .parameter import ParamInfo +from .patterns import ParamInfo from .utils import Immutable, parts_to_path, path_to_parts diff --git a/sanic_routing/router.py b/sanic_routing/router.py index 0e29d88..ccfa0e3 100644 --- a/sanic_routing/router.py +++ b/sanic_routing/router.py @@ -7,7 +7,7 @@ from warnings import warn from sanic_routing.group import RouteGroup -from sanic_routing.parameter import ParamInfo +from sanic_routing.patterns import ParamInfo from .exceptions import ( BadMethod, From cf5b29191951851bf68795d98462cff81bf43356 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Mon, 20 Dec 2021 10:09:56 +0200 Subject: [PATCH 04/10] Add some safety checks --- sanic_routing/patterns.py | 11 ++++++++++- sanic_routing/route.py | 9 ++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/sanic_routing/patterns.py b/sanic_routing/patterns.py index ca0ff3b..1de2e4e 100644 --- a/sanic_routing/patterns.py +++ b/sanic_routing/patterns.py @@ -71,12 +71,21 @@ class ExtParamInfo(ParamInfo): def __init__(self, **kwargs): super().__init__(**kwargs) match = REGEX_PARAM_NAME_EXT.match(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: diff --git a/sanic_routing/route.py b/sanic_routing/route.py index a7e239d..a2164d0 100644 --- a/sanic_routing/route.py +++ b/sanic_routing/route.py @@ -355,13 +355,8 @@ def parse_parameter_string(self, parameter_string: str): ) default = (str, label, ParamInfo) + # Pull from pre-configured types - found = self.router.regex_types.get(label) - if not found: - # if ":" in label: - # label, _ = label.split(":", 1) - # found = self.router.regex_types.get(label, default) - # else: - found = default + found = self.router.regex_types.get(label, default) _type, pattern, param_info_class = found return name, label, _type, pattern, param_info_class From 4c24831141ccaeaeb0293aa70a53d0c0fb5801a6 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Sun, 16 Jan 2022 11:33:02 +0200 Subject: [PATCH 05/10] squash --- sanic_routing/route.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sanic_routing/route.py b/sanic_routing/route.py index ab0698f..eb2d7f1 100644 --- a/sanic_routing/route.py +++ b/sanic_routing/route.py @@ -1,6 +1,5 @@ import re import typing as t -from telnetlib import EC from types import SimpleNamespace from warnings import warn From f783636f088547f6119c53e6e8453fef2a051932 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Sun, 16 Jan 2022 11:35:40 +0200 Subject: [PATCH 06/10] Reenable test --- tests/test_routing.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_routing.py b/tests/test_routing.py index 026b62b..d5cf28c 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -2,7 +2,6 @@ from datetime import date import pytest - from sanic_routing import BaseRouter from sanic_routing.exceptions import NoMethod, NotFound, RouteExists @@ -457,7 +456,7 @@ def handler2(): "uri", ( "a-random-path", - # "a/random/path", + "a/random/path", ), ) def test_identical_path_routes_with_different_methods_complex(uri): From 8fe31200b9dcde6495600c02030e471c3aca99f3 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 24 Feb 2022 15:08:46 +0200 Subject: [PATCH 07/10] Cleanup --- sanic_routing/patterns.py | 1 - tests/test_routing.py | 1 - 2 files changed, 2 deletions(-) diff --git a/sanic_routing/patterns.py b/sanic_routing/patterns.py index a1e2d09..9dd816b 100644 --- a/sanic_routing/patterns.py +++ b/sanic_routing/patterns.py @@ -73,7 +73,6 @@ def process( params: t.Dict[str, t.Any], value: t.Union[str, t.Tuple[str, ...]], ) -> None: - print("processing", self.name, value) params[self.name] = value diff --git a/tests/test_routing.py b/tests/test_routing.py index df75481..dc04b2b 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -474,7 +474,6 @@ def handler2(): "/api//hello_world/", handler2, methods=["GET"] ) router.finalize() - print(router.find_route_src) _, handler, params = router.get(f"/{uri}", "OPTIONS") assert handler() == "handler1" From d92059e8d18c07d53506b27031b7c260d6e1c078 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 24 Feb 2022 15:29:50 +0200 Subject: [PATCH 08/10] Remove finalization check --- sanic_routing/route.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/sanic_routing/route.py b/sanic_routing/route.py index eb2d7f1..a2164d0 100644 --- a/sanic_routing/route.py +++ b/sanic_routing/route.py @@ -223,10 +223,6 @@ def _finalize_params(self): self.params = dict( sorted(params.items(), key=lambda param: self._sorting(param[1])) ) - if not self.regex and self.raw_path.count(":") > 1: - raise InvalidUsage( - f"Invalid parameter declaration: {self.raw_path}" - ) def _compile_regex(self): components = [] From 404fce91c50316b007fe8add9ec13defe32168c5 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 24 Feb 2022 15:53:33 +0200 Subject: [PATCH 09/10] Finalization check and test --- sanic_routing/route.py | 8 ++++++++ tests/test_builtin_param_types.py | 9 ++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/sanic_routing/route.py b/sanic_routing/route.py index a2164d0..355566d 100644 --- a/sanic_routing/route.py +++ b/sanic_routing/route.py @@ -220,10 +220,18 @@ def _finalize_params(self): 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 = [] diff --git a/tests/test_builtin_param_types.py b/tests/test_builtin_param_types.py index fd5887f..43c5218 100644 --- a/tests/test_builtin_param_types.py +++ b/tests/test_builtin_param_types.py @@ -1,7 +1,6 @@ from unittest.mock import Mock import pytest - from sanic_routing import BaseRouter from sanic_routing.exceptions import InvalidUsage, NotFound @@ -385,3 +384,11 @@ def test_empty_hierarchy(): assert params == expected handler1.assert_not_called() handler2.assert_called_once_with(**expected) + + +def test_invalid_def(handler): + router = Router() + router.add("/one//", handler) + + with pytest.raises(InvalidUsage): + router.finalize() From 76c0efd692709efd4f2f2bd2982f931f22067645 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Thu, 24 Feb 2022 15:58:09 +0200 Subject: [PATCH 10/10] reorder check --- sanic_routing/router.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sanic_routing/router.py b/sanic_routing/router.py index dfa77bd..bd0b052 100644 --- a/sanic_routing/router.py +++ b/sanic_routing/router.py @@ -125,9 +125,9 @@ def resolve( # Apply if tuple (from ext) or if it is not a regex matcher if isinstance(value, tuple): param.process(params, value) - elif ( + elif not route.regex or ( route.regex and param.cast is not str - ) or not route.regex: + ): params[param.name] = value # Double check that if we made a match it is not a false positive