import re import typing as t import uuid from ..urls import _fast_url_quote if t.TYPE_CHECKING: from .map import Map class ValidationError(ValueError): """Validation error. If a rule converter raises this exception the rule does not match the current URL and the next URL is tried. """ class BaseConverter: """Base class for all converters.""" regex = "[^/]+" weight = 100 part_isolating = True def __init__(self, map: "Map", *args: t.Any, **kwargs: t.Any) -> None: self.map = map def to_python(self, value: str) -> t.Any: return value def to_url(self, value: t.Any) -> str: if isinstance(value, (bytes, bytearray)): return _fast_url_quote(value) return _fast_url_quote(str(value).encode(self.map.charset)) class UnicodeConverter(BaseConverter): """This converter is the default converter and accepts any string but only one path segment. Thus the string can not include a slash. This is the default validator. Example:: Rule('/pages/'), Rule('/') :param map: the :class:`Map`. :param minlength: the minimum length of the string. Must be greater or equal 1. :param maxlength: the maximum length of the string. :param length: the exact length of the string. """ part_isolating = True def __init__( self, map: "Map", minlength: int = 1, maxlength: t.Optional[int] = None, length: t.Optional[int] = None, ) -> None: super().__init__(map) if length is not None: length_regex = f"{{{int(length)}}}" else: if maxlength is None: maxlength_value = "" else: maxlength_value = str(int(maxlength)) length_regex = f"{{{int(minlength)},{maxlength_value}}}" self.regex = f"[^/]{length_regex}" class AnyConverter(BaseConverter): """Matches one of the items provided. Items can either be Python identifiers or strings:: Rule('/') :param map: the :class:`Map`. :param items: this function accepts the possible items as positional arguments. .. versionchanged:: 2.2 Value is validated when building a URL. """ part_isolating = True def __init__(self, map: "Map", *items: str) -> None: super().__init__(map) self.items = set(items) self.regex = f"(?:{'|'.join([re.escape(x) for x in items])})" def to_url(self, value: t.Any) -> str: if value in self.items: return str(value) valid_values = ", ".join(f"'{item}'" for item in sorted(self.items)) raise ValueError(f"'{value}' is not one of {valid_values}") class PathConverter(BaseConverter): """Like the default :class:`UnicodeConverter`, but it also matches slashes. This is useful for wikis and similar applications:: Rule('/') Rule('//edit') :param map: the :class:`Map`. """ regex = "[^/].*?" weight = 200 part_isolating = False class NumberConverter(BaseConverter): """Baseclass for `IntegerConverter` and `FloatConverter`. :internal: """ weight = 50 num_convert: t.Callable = int part_isolating = True def __init__( self, map: "Map", fixed_digits: int = 0, min: t.Optional[int] = None, max: t.Optional[int] = None, signed: bool = False, ) -> None: if signed: self.regex = self.signed_regex super().__init__(map) self.fixed_digits = fixed_digits self.min = min self.max = max self.signed = signed def to_python(self, value: str) -> t.Any: if self.fixed_digits and len(value) != self.fixed_digits: raise ValidationError() value = self.num_convert(value) if (self.min is not None and value < self.min) or ( self.max is not None and value > self.max ): raise ValidationError() return value def to_url(self, value: t.Any) -> str: value = str(self.num_convert(value)) if self.fixed_digits: value = value.zfill(self.fixed_digits) return value @property def signed_regex(self) -> str: return f"-?{self.regex}" class IntegerConverter(NumberConverter): """This converter only accepts integer values:: Rule("/page/") By default it only accepts unsigned, positive values. The ``signed`` parameter will enable signed, negative values. :: Rule("/page/") :param map: The :class:`Map`. :param fixed_digits: The number of fixed digits in the URL. If you set this to ``4`` for example, the rule will only match if the URL looks like ``/0001/``. The default is variable length. :param min: The minimal value. :param max: The maximal value. :param signed: Allow signed (negative) values. .. versionadded:: 0.15 The ``signed`` parameter. """ regex = r"\d+" part_isolating = True class FloatConverter(NumberConverter): """This converter only accepts floating point values:: Rule("/probability/") By default it only accepts unsigned, positive values. The ``signed`` parameter will enable signed, negative values. :: Rule("/offset/") :param map: The :class:`Map`. :param min: The minimal value. :param max: The maximal value. :param signed: Allow signed (negative) values. .. versionadded:: 0.15 The ``signed`` parameter. """ regex = r"\d+\.\d+" num_convert = float part_isolating = True def __init__( self, map: "Map", min: t.Optional[float] = None, max: t.Optional[float] = None, signed: bool = False, ) -> None: super().__init__(map, min=min, max=max, signed=signed) # type: ignore class UUIDConverter(BaseConverter): """This converter only accepts UUID strings:: Rule('/object/') .. versionadded:: 0.10 :param map: the :class:`Map`. """ regex = ( r"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-" r"[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}" ) part_isolating = True def to_python(self, value: str) -> uuid.UUID: return uuid.UUID(value) def to_url(self, value: uuid.UUID) -> str: return str(value) #: the default converter mapping for the map. DEFAULT_CONVERTERS: t.Mapping[str, t.Type[BaseConverter]] = { "default": UnicodeConverter, "string": UnicodeConverter, "any": AnyConverter, "path": PathConverter, "int": IntegerConverter, "float": FloatConverter, "uuid": UUIDConverter, }