Skip to content

Commit

Permalink
Add extension parameter type (#47)
Browse files Browse the repository at this point in the history
  • Loading branch information
ahopkins authored Feb 24, 2022
1 parent 15ddf4c commit 35facf6
Show file tree
Hide file tree
Showing 7 changed files with 385 additions and 46 deletions.
134 changes: 126 additions & 8 deletions sanic_routing/patterns.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
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:
Expand All @@ -19,13 +24,120 @@ def slug(param: str) -> str:
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_NAME_EXT.match(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])?"
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-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.
Expand All @@ -35,23 +147,29 @@ def nonemptystr(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 = {
"strorempty": (str, re.compile(r"^[^/]*$")),
"str": (nonemptystr, 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"^[^/]?.*?$")),
"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 = {
"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,
),
}
53 changes: 40 additions & 13 deletions sanic_routing/route.py
Original file line number Diff line number Diff line change
@@ -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 .patterns 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):
Expand Down Expand Up @@ -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(
Expand All @@ -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("^"):
Expand All @@ -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):
Expand All @@ -210,16 +220,25 @@ 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 = []

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)
Expand Down Expand Up @@ -316,8 +335,14 @@ 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(
f"Invalid parameter syntax: {parameter_string}"
Expand All @@ -337,7 +362,9 @@ 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, default)
_type, pattern, param_info_class = found
return name, label, _type, pattern, param_info_class
47 changes: 28 additions & 19 deletions sanic_routing/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from warnings import warn

from sanic_routing.group import RouteGroup
from sanic_routing.patterns import ParamInfo

from .exceptions import (
BadMethod,
Expand All @@ -15,7 +16,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
Expand Down Expand Up @@ -60,7 +61,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):
Expand Down Expand Up @@ -106,21 +110,25 @@ def resolve(

# Convert matched values to parameters
params = param_basket["__params__"]
if route.regex:
params.update(
{
param.name: param.cast(
param_basket["__params__"][param.name]
)
for param in route.params.values()
if param.cast is not str
}
)
elif param_basket["__matches__"]:
params = {
param.name: param_basket["__matches__"][idx]
for idx, param in route.params.items()
}
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
Expand Down Expand Up @@ -248,6 +256,7 @@ def register_pattern(
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
Expand Down Expand Up @@ -288,7 +297,7 @@ def register_pattern(
pattern = re.compile(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):
"""
Expand Down Expand Up @@ -605,7 +614,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>")
Expand Down
Loading

0 comments on commit 35facf6

Please sign in to comment.