1from __future__ import annotations 
    2 
    3import re 
    4import typing as t 
    5import uuid 
    6from urllib.parse import quote 
    7 
    8if t.TYPE_CHECKING: 
    9    from .map import Map 
    10 
    11 
    12class ValidationError(ValueError): 
    13    """Validation error.  If a rule converter raises this exception the rule 
    14    does not match the current URL and the next URL is tried. 
    15    """ 
    16 
    17 
    18class BaseConverter: 
    19    """Base class for all converters. 
    20 
    21    .. versionchanged:: 2.3 
    22        ``part_isolating`` defaults to ``False`` if ``regex`` contains a ``/``. 
    23    """ 
    24 
    25    regex = "[^/]+" 
    26    weight = 100 
    27    part_isolating = True 
    28 
    29    def __init_subclass__(cls, **kwargs: t.Any) -> None: 
    30        super().__init_subclass__(**kwargs) 
    31 
    32        # If the converter isn't inheriting its regex, disable part_isolating by default 
    33        # if the regex contains a / character. 
    34        if "regex" in cls.__dict__ and "part_isolating" not in cls.__dict__: 
    35            cls.part_isolating = "/" not in cls.regex 
    36 
    37    def __init__(self, map: Map, *args: t.Any, **kwargs: t.Any) -> None: 
    38        self.map = map 
    39 
    40    def to_python(self, value: str) -> t.Any: 
    41        return value 
    42 
    43    def to_url(self, value: t.Any) -> str: 
    44        # safe = https://url.spec.whatwg.org/#url-path-segment-string 
    45        return quote(str(value), safe="!$&'()*+,/:;=@") 
    46 
    47 
    48class UnicodeConverter(BaseConverter): 
    49    """This converter is the default converter and accepts any string but 
    50    only one path segment.  Thus the string can not include a slash. 
    51 
    52    This is the default validator. 
    53 
    54    Example:: 
    55 
    56        Rule('/pages/<page>'), 
    57        Rule('/<string(length=2):lang_code>') 
    58 
    59    :param map: the :class:`Map`. 
    60    :param minlength: the minimum length of the string.  Must be greater 
    61                      or equal 1. 
    62    :param maxlength: the maximum length of the string. 
    63    :param length: the exact length of the string. 
    64    """ 
    65 
    66    def __init__( 
    67        self, 
    68        map: Map, 
    69        minlength: int = 1, 
    70        maxlength: int | None = None, 
    71        length: int | None = None, 
    72    ) -> None: 
    73        super().__init__(map) 
    74        if length is not None: 
    75            length_regex = f"{{{int(length)}}}" 
    76        else: 
    77            if maxlength is None: 
    78                maxlength_value = "" 
    79            else: 
    80                maxlength_value = str(int(maxlength)) 
    81            length_regex = f"{{{int(minlength)},{maxlength_value}}}" 
    82        self.regex = f"[^/]{length_regex}" 
    83 
    84 
    85class AnyConverter(BaseConverter): 
    86    """Matches one of the items provided.  Items can either be Python 
    87    identifiers or strings:: 
    88 
    89        Rule('/<any(about, help, imprint, class, "foo,bar"):page_name>') 
    90 
    91    :param map: the :class:`Map`. 
    92    :param items: this function accepts the possible items as positional 
    93                  arguments. 
    94 
    95    .. versionchanged:: 2.2 
    96        Value is validated when building a URL. 
    97    """ 
    98 
    99    def __init__(self, map: Map, *items: str) -> None: 
    100        super().__init__(map) 
    101        self.items = set(items) 
    102        self.regex = f"(?:{'|'.join([re.escape(x) for x in items])})" 
    103 
    104    def to_url(self, value: t.Any) -> str: 
    105        if value in self.items: 
    106            return str(value) 
    107 
    108        valid_values = ", ".join(f"'{item}'" for item in sorted(self.items)) 
    109        raise ValueError(f"'{value}' is not one of {valid_values}") 
    110 
    111 
    112class PathConverter(BaseConverter): 
    113    """Like the default :class:`UnicodeConverter`, but it also matches 
    114    slashes.  This is useful for wikis and similar applications:: 
    115 
    116        Rule('/<path:wikipage>') 
    117        Rule('/<path:wikipage>/edit') 
    118 
    119    :param map: the :class:`Map`. 
    120    """ 
    121 
    122    part_isolating = False 
    123    regex = "[^/].*?" 
    124    weight = 200 
    125 
    126 
    127class NumberConverter(BaseConverter): 
    128    """Baseclass for `IntegerConverter` and `FloatConverter`. 
    129 
    130    :internal: 
    131    """ 
    132 
    133    weight = 50 
    134    num_convert: t.Callable[[t.Any], t.Any] = int 
    135 
    136    def __init__( 
    137        self, 
    138        map: Map, 
    139        fixed_digits: int = 0, 
    140        min: int | None = None, 
    141        max: int | None = None, 
    142        signed: bool = False, 
    143    ) -> None: 
    144        if signed: 
    145            self.regex = self.signed_regex 
    146        super().__init__(map) 
    147        self.fixed_digits = fixed_digits 
    148        self.min = min 
    149        self.max = max 
    150        self.signed = signed 
    151 
    152    def to_python(self, value: str) -> t.Any: 
    153        if self.fixed_digits and len(value) != self.fixed_digits: 
    154            raise ValidationError() 
    155        value_num = self.num_convert(value) 
    156        if (self.min is not None and value_num < self.min) or ( 
    157            self.max is not None and value_num > self.max 
    158        ): 
    159            raise ValidationError() 
    160        return value_num 
    161 
    162    def to_url(self, value: t.Any) -> str: 
    163        value_str = str(self.num_convert(value)) 
    164        if self.fixed_digits: 
    165            value_str = value_str.zfill(self.fixed_digits) 
    166        return value_str 
    167 
    168    @property 
    169    def signed_regex(self) -> str: 
    170        return f"-?{self.regex}" 
    171 
    172 
    173class IntegerConverter(NumberConverter): 
    174    """This converter only accepts integer values:: 
    175 
    176        Rule("/page/<int:page>") 
    177 
    178    By default it only accepts unsigned, positive values. The ``signed`` 
    179    parameter will enable signed, negative values. :: 
    180 
    181        Rule("/page/<int(signed=True):page>") 
    182 
    183    :param map: The :class:`Map`. 
    184    :param fixed_digits: The number of fixed digits in the URL. If you 
    185        set this to ``4`` for example, the rule will only match if the 
    186        URL looks like ``/0001/``. The default is variable length. 
    187    :param min: The minimal value. 
    188    :param max: The maximal value. 
    189    :param signed: Allow signed (negative) values. 
    190 
    191    .. versionadded:: 0.15 
    192        The ``signed`` parameter. 
    193    """ 
    194 
    195    regex = r"\d+" 
    196 
    197 
    198class FloatConverter(NumberConverter): 
    199    """This converter only accepts floating point values:: 
    200 
    201        Rule("/probability/<float:probability>") 
    202 
    203    By default it only accepts unsigned, positive values. The ``signed`` 
    204    parameter will enable signed, negative values. :: 
    205 
    206        Rule("/offset/<float(signed=True):offset>") 
    207 
    208    :param map: The :class:`Map`. 
    209    :param min: The minimal value. 
    210    :param max: The maximal value. 
    211    :param signed: Allow signed (negative) values. 
    212 
    213    .. versionadded:: 0.15 
    214        The ``signed`` parameter. 
    215    """ 
    216 
    217    regex = r"\d+\.\d+" 
    218    num_convert = float 
    219 
    220    def __init__( 
    221        self, 
    222        map: Map, 
    223        min: float | None = None, 
    224        max: float | None = None, 
    225        signed: bool = False, 
    226    ) -> None: 
    227        super().__init__(map, min=min, max=max, signed=signed)  # type: ignore 
    228 
    229 
    230class UUIDConverter(BaseConverter): 
    231    """This converter only accepts UUID strings:: 
    232 
    233        Rule('/object/<uuid:identifier>') 
    234 
    235    .. versionadded:: 0.10 
    236 
    237    :param map: the :class:`Map`. 
    238    """ 
    239 
    240    regex = ( 
    241        r"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-" 
    242        r"[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}" 
    243    ) 
    244 
    245    def to_python(self, value: str) -> uuid.UUID: 
    246        return uuid.UUID(value) 
    247 
    248    def to_url(self, value: uuid.UUID) -> str: 
    249        return str(value) 
    250 
    251 
    252#: the default converter mapping for the map. 
    253DEFAULT_CONVERTERS: t.Mapping[str, type[BaseConverter]] = { 
    254    "default": UnicodeConverter, 
    255    "string": UnicodeConverter, 
    256    "any": AnyConverter, 
    257    "path": PathConverter, 
    258    "int": IntegerConverter, 
    259    "float": FloatConverter, 
    260    "uuid": UUIDConverter, 
    261}