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
156 try:
157 value_num = self.num_convert(value)
158 except ValueError as e:
159 raise ValidationError() from e
160
161 if (self.min is not None and value_num < self.min) or (
162 self.max is not None and value_num > self.max
163 ):
164 raise ValidationError()
165 return value_num
166
167 def to_url(self, value: t.Any) -> str:
168 value_str = str(self.num_convert(value))
169 if self.fixed_digits:
170 value_str = value_str.zfill(self.fixed_digits)
171 return value_str
172
173 @property
174 def signed_regex(self) -> str:
175 return f"-?{self.regex}"
176
177
178class IntegerConverter(NumberConverter):
179 """This converter only accepts integer values::
180
181 Rule("/page/<int:page>")
182
183 By default it only accepts unsigned, positive values. The ``signed``
184 parameter will enable signed, negative values. ::
185
186 Rule("/page/<int(signed=True):page>")
187
188 :param map: The :class:`Map`.
189 :param fixed_digits: The number of fixed digits in the URL. If you
190 set this to ``4`` for example, the rule will only match if the
191 URL looks like ``/0001/``. The default is variable length.
192 :param min: The minimal value.
193 :param max: The maximal value.
194 :param signed: Allow signed (negative) values.
195
196 .. versionadded:: 0.15
197 The ``signed`` parameter.
198 """
199
200 regex = r"\d+"
201
202
203class FloatConverter(NumberConverter):
204 """This converter only accepts floating point values::
205
206 Rule("/probability/<float:probability>")
207
208 By default it only accepts unsigned, positive values. The ``signed``
209 parameter will enable signed, negative values. ::
210
211 Rule("/offset/<float(signed=True):offset>")
212
213 :param map: The :class:`Map`.
214 :param min: The minimal value.
215 :param max: The maximal value.
216 :param signed: Allow signed (negative) values.
217
218 .. versionadded:: 0.15
219 The ``signed`` parameter.
220 """
221
222 regex = r"\d+\.\d+"
223 num_convert = float
224
225 def __init__(
226 self,
227 map: Map,
228 min: float | None = None,
229 max: float | None = None,
230 signed: bool = False,
231 ) -> None:
232 super().__init__(map, min=min, max=max, signed=signed) # type: ignore
233
234
235class UUIDConverter(BaseConverter):
236 """This converter only accepts UUID strings::
237
238 Rule('/object/<uuid:identifier>')
239
240 .. versionadded:: 0.10
241
242 :param map: the :class:`Map`.
243 """
244
245 regex = (
246 r"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-"
247 r"[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}"
248 )
249
250 def to_python(self, value: str) -> uuid.UUID:
251 return uuid.UUID(value)
252
253 def to_url(self, value: uuid.UUID) -> str:
254 return str(value)
255
256
257#: the default converter mapping for the map.
258DEFAULT_CONVERTERS: t.Mapping[str, type[BaseConverter]] = {
259 "default": UnicodeConverter,
260 "string": UnicodeConverter,
261 "any": AnyConverter,
262 "path": PathConverter,
263 "int": IntegerConverter,
264 "float": FloatConverter,
265 "uuid": UUIDConverter,
266}