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}