1"""
2
3uritemplate.variable
4====================
5
6This module contains the URIVariable class which powers the URITemplate class.
7
8What treasures await you:
9
10- URIVariable class
11
12You see a hammer in front of you.
13What do you do?
14>
15
16"""
17
18import collections.abc
19import enum
20import string
21import typing as t
22import urllib.parse
23
24ScalarVariableValue = t.Union[int, float, complex, str, None]
25VariableValue = t.Union[
26 t.Sequence[ScalarVariableValue],
27 t.List[ScalarVariableValue],
28 t.Mapping[str, ScalarVariableValue],
29 t.Tuple[str, ScalarVariableValue],
30 ScalarVariableValue,
31]
32VariableValueDict = t.Dict[str, VariableValue]
33
34
35_UNRESERVED_CHARACTERS: t.Final[str] = (
36 f"{string.ascii_letters}{string.digits}~-_."
37)
38_GEN_DELIMS: t.Final[str] = ":/?#[]@"
39_SUB_DELIMS: t.Final[str] = "!$&'()*+,;="
40_RESERVED_CHARACTERS: t.Final[str] = f"{_GEN_DELIMS}{_SUB_DELIMS}"
41
42
43class Operator(enum.Enum):
44 # Section 2.2. Expressions
45 # expression = "{" [ operator ] variable-list "}"
46 # operator = op-level2 / op-level3 / op-reserve
47 # op-level2 = "+" / "#"
48 # op-level3 = "." / "/" / ";" / "?" / "&"
49 # op-reserve = "=" / "," / "!" / "@" / "|"
50 default = "" # 3.2.2. Simple String Expansiona: {var}
51 # Operator Level 2 (op-level2)
52 reserved = "+" # 3.2.3. Reserved Expansion: {+var}
53 fragment = "#" # 3.2.4. Fragment Expansion: {#var}
54 # Operator Level 3 (op-level3)
55 # 3.2.5. Label Expansion with Dot-Prefix: {.var}
56 label_with_dot_prefix = "."
57 path_segment = "/" # 3.2.6. Path Segment Expansion: {/var}
58 path_style_parameter = (
59 ";" # 3.2.7. Path-Style Parameter Expansion: {;var}
60 )
61 form_style_query = "?" # 3.2.8. Form-Style Query Expansion: {?var}
62 # 3.2.9. Form-Style Query Continuation: {&var}
63 form_style_query_continuation = "&"
64 # Reserved Operators (op-reserve)
65 reserved_eq = "="
66 reserved_comma = ","
67 reserved_bang = "!"
68 reserved_at = "@"
69 reserved_pipe = "|"
70
71 def reserved_characters(self) -> str:
72 # TODO: Re-enable after un-commenting 3.9
73 # match self:
74 # case Operator.reserved:
75 # return _RESERVED_CHARACTERS + "%"
76 # # case Operator.default | Operator.reserved | Operator.fragment:
77 # case Operator.fragment:
78 # return _RESERVED_CHARACTERS
79 # case _:
80 # return ""
81 if self == Operator.reserved:
82 return _RESERVED_CHARACTERS + "%"
83 if self == Operator.fragment:
84 return _RESERVED_CHARACTERS
85 return ""
86
87 def expansion_separator(self) -> str:
88 """Identify the separator used during expansion.
89
90 Per `Section 3.2.1. Variable Expansion`_:
91
92 ====== =========== =========
93 Type Separator
94 ====== =========== =========
95 ``","`` (default)
96 ``+`` ``","``
97 ``#`` ``","``
98 ``.`` ``"."``
99 ``/`` ``"/"``
100 ``;`` ``";"``
101 ``?`` ``"&"``
102 ``&`` ``"&"``
103 ====== =========== =========
104
105 .. _`Section 3.2.1. Variable Expansion`:
106 https://www.rfc-editor.org/rfc/rfc6570#section-3.2.1
107 """
108 if self == Operator.label_with_dot_prefix:
109 return "."
110 if self == Operator.path_segment:
111 return "/"
112 if self == Operator.path_style_parameter:
113 return ";"
114 if (
115 self == Operator.form_style_query
116 or self == Operator.form_style_query_continuation
117 ):
118 return "&"
119 # if self == Operator.reserved or self == Operator.fragment:
120 # return ","
121 return ","
122 # match self:
123 # case Operator.label_with_dot_prefix:
124 # return "."
125 # case Operator.path_segment:
126 # return "/"
127 # case Operator.path_style_parameter:
128 # return ";"
129 # case (
130 # Operator.form_style_query |
131 # Operator.form_style_query_continuation
132 # ):
133 # return "&"
134 # case Operator.reserved | Operator.fragment:
135 # return ","
136 # case _:
137 # return ","
138
139 def variable_prefix(self) -> str:
140 if self == Operator.reserved:
141 return ""
142 return t.cast(str, self.value)
143 # match self:
144 # case Operator.reserved:
145 # return ""
146 # case _:
147 # return t.cast(str, self.value)
148
149 def _always_quote(self, value: str) -> str:
150 return quote(value, "")
151
152 def _only_quote_unquoted_characters(self, value: str) -> str:
153 if urllib.parse.unquote(value) == value:
154 return quote(value, _RESERVED_CHARACTERS)
155 return value
156
157 def quote(self, value: t.Any) -> str:
158 if not isinstance(value, (str, bytes)):
159 value = str(value)
160 if isinstance(value, bytes):
161 value = value.decode()
162
163 if self == Operator.reserved or self == Operator.fragment:
164 return self._only_quote_unquoted_characters(value)
165 return self._always_quote(value)
166
167 @staticmethod
168 def from_string(s: str) -> "Operator":
169 return _operators.get(s, Operator.default)
170
171
172_operators: t.Final[t.Dict[str, Operator]] = {
173 "+": Operator.reserved,
174 "#": Operator.fragment,
175 ".": Operator.label_with_dot_prefix,
176 "/": Operator.path_segment,
177 ";": Operator.path_style_parameter,
178 "?": Operator.form_style_query,
179 "&": Operator.form_style_query_continuation,
180 "!": Operator.reserved_bang,
181 "|": Operator.reserved_pipe,
182 "@": Operator.reserved_at,
183 "=": Operator.reserved_eq,
184 ",": Operator.reserved_comma,
185}
186
187
188class URIVariable:
189 """This object validates everything inside the URITemplate object.
190
191 It validates template expansions and will truncate length as decided by
192 the template.
193
194 Please note that just like the :class:`URITemplate <URITemplate>`, this
195 object's ``__str__`` and ``__repr__`` methods do not return the same
196 information. Calling ``str(var)`` will return the original variable.
197
198 This object does the majority of the heavy lifting. The ``URITemplate``
199 object finds the variables in the URI and then creates ``URIVariable``
200 objects. Expansions of the URI are handled by each ``URIVariable``
201 object. ``URIVariable.expand()`` returns a dictionary of the original
202 variable and the expanded value. Check that method's documentation for
203 more information.
204
205 """
206
207 def __init__(self, var: str):
208 #: The original string that comes through with the variable
209 self.original: str = var
210 #: The operator for the variable
211 self.operator: Operator = Operator.default
212 #: List of variables in this variable
213 self.variables: t.List[t.Tuple[str, t.MutableMapping[str, t.Any]]] = (
214 []
215 )
216 #: List of variable names
217 self.variable_names: t.List[str] = []
218 #: List of defaults passed in
219 self.defaults: t.MutableMapping[str, ScalarVariableValue] = {}
220 # Parse the variable itself.
221 self.parse()
222
223 def __repr__(self) -> str:
224 return "URIVariable(%s)" % self
225
226 def __str__(self) -> str:
227 return self.original
228
229 def parse(self) -> None:
230 """Parse the variable.
231
232 This finds the:
233 - operator,
234 - set of safe characters,
235 - variables, and
236 - defaults.
237
238 """
239 var_list_str = self.original
240 if (operator_str := self.original[0]) in _operators:
241 self.operator = Operator.from_string(operator_str)
242 var_list_str = self.original[1:]
243
244 var_list = var_list_str.split(",")
245
246 for var in var_list:
247 default_val = None
248 name = var
249 # NOTE(sigmavirus24): This is from an earlier draft but is not in
250 # the specification
251 if "=" in var:
252 name, default_val = tuple(var.split("=", 1))
253
254 explode = name.endswith("*")
255 name = name.rstrip("*")
256
257 prefix: t.Optional[int] = None
258 if ":" in name:
259 name, prefix_str = tuple(name.split(":", 1))
260 prefix = int(prefix_str, 10)
261
262 if default_val:
263 self.defaults[name] = default_val
264
265 self.variables.append(
266 (name, {"explode": explode, "prefix": prefix})
267 )
268
269 self.variable_names = [varname for (varname, _) in self.variables]
270
271 def _query_expansion(
272 self,
273 name: str,
274 value: VariableValue,
275 explode: bool,
276 prefix: t.Optional[int],
277 ) -> t.Optional[str]:
278 """Expansion method for the '?' and '&' operators."""
279 if value is None:
280 return None
281
282 tuples, items = is_list_of_tuples(value)
283
284 safe = self.operator.reserved_characters()
285 _quote = self.operator.quote
286 if list_test(value) and not tuples:
287 if not value:
288 return None
289 value = t.cast(t.Sequence[ScalarVariableValue], value)
290 if explode:
291 return self.operator.expansion_separator().join(
292 f"{name}={_quote(v)}" for v in value
293 )
294 else:
295 value = ",".join(_quote(v) for v in value)
296 return f"{name}={value}"
297
298 if dict_test(value) or tuples:
299 if not value:
300 return None
301 value = t.cast(t.Mapping[str, ScalarVariableValue], value)
302 items = items or sorted(value.items())
303 if explode:
304 return self.operator.expansion_separator().join(
305 f"{quote(k, safe)}={_quote(v)}" for k, v in items
306 )
307 else:
308 value = ",".join(
309 f"{quote(k, safe)},{_quote(v)}" for k, v in items
310 )
311 return f"{name}={value}"
312
313 if value:
314 value = t.cast(t.Text, value)
315 value = value[:prefix] if prefix else value
316 return f"{name}={_quote(value)}"
317 return name + "="
318
319 def _label_path_expansion(
320 self,
321 name: str,
322 value: VariableValue,
323 explode: bool,
324 prefix: t.Optional[int],
325 ) -> t.Optional[str]:
326 """Label and path expansion method.
327
328 Expands for operators: '/', '.'
329
330 """
331 join_str = self.operator.expansion_separator()
332 safe = self.operator.reserved_characters()
333
334 if value is None or (
335 not isinstance(value, (str, int, float, complex))
336 and len(value) == 0
337 ):
338 return None
339
340 tuples, items = is_list_of_tuples(value)
341
342 if list_test(value) and not tuples:
343 if not explode:
344 join_str = ","
345
346 value = t.cast(t.Sequence[ScalarVariableValue], value)
347 fragments = [
348 self.operator.quote(v) for v in value if v is not None
349 ]
350 return join_str.join(fragments) if fragments else None
351
352 if dict_test(value) or tuples:
353 value = t.cast(t.Mapping[str, ScalarVariableValue], value)
354 items = items or sorted(value.items())
355 format_str = "%s=%s"
356 if not explode:
357 format_str = "%s,%s"
358 join_str = ","
359
360 expanded = join_str.join(
361 format_str % (quote(k, safe), self.operator.quote(v))
362 for k, v in items
363 if v is not None
364 )
365 return expanded if expanded else None
366
367 value = t.cast(t.Text, value)
368 value = value[:prefix] if prefix else value
369 return self.operator.quote(value)
370
371 def _semi_path_expansion(
372 self,
373 name: str,
374 value: VariableValue,
375 explode: bool,
376 prefix: t.Optional[int],
377 ) -> t.Optional[str]:
378 """Expansion method for ';' operator."""
379 join_str = self.operator.expansion_separator()
380 safe = self.operator.reserved_characters()
381
382 if value is None:
383 return None
384
385 tuples, items = is_list_of_tuples(value)
386
387 if list_test(value) and not tuples:
388 value = t.cast(t.Sequence[ScalarVariableValue], value)
389 if explode:
390 expanded = join_str.join(
391 f"{name}={quote(v, safe)}" for v in value if v is not None
392 )
393 return expanded if expanded else None
394 else:
395 value = ",".join(quote(v, safe) for v in value)
396 return f"{name}={value}"
397
398 if dict_test(value) or tuples:
399 value = t.cast(t.Mapping[str, ScalarVariableValue], value)
400 items = items or sorted(value.items())
401
402 if explode:
403 return join_str.join(
404 f"{quote(k, safe)}={self.operator.quote(v)}"
405 for k, v in items
406 if v is not None
407 )
408 else:
409 expanded = ",".join(
410 f"{quote(k, safe)},{self.operator.quote(v)}"
411 for k, v in items
412 if v is not None
413 )
414 return f"{name}={expanded}"
415
416 value = t.cast(t.Text, value)
417 value = value[:prefix] if prefix else value
418 if value:
419 return f"{name}={self.operator.quote(value)}"
420
421 return name
422
423 def _string_expansion(
424 self,
425 name: str,
426 value: VariableValue,
427 explode: bool,
428 prefix: t.Optional[int],
429 ) -> t.Optional[str]:
430 if value is None:
431 return None
432
433 tuples, items = is_list_of_tuples(value)
434
435 if list_test(value) and not tuples:
436 value = t.cast(t.Sequence[ScalarVariableValue], value)
437 return ",".join(self.operator.quote(v) for v in value)
438
439 if dict_test(value) or tuples:
440 value = t.cast(t.Mapping[str, ScalarVariableValue], value)
441 items = items or sorted(value.items())
442 format_str = "%s=%s" if explode else "%s,%s"
443
444 return ",".join(
445 format_str % (self.operator.quote(k), self.operator.quote(v))
446 for k, v in items
447 )
448
449 value = t.cast(t.Text, value)
450 value = value[:prefix] if prefix else value
451 return self.operator.quote(value)
452
453 def expand(
454 self, var_dict: t.Optional[VariableValueDict] = None
455 ) -> t.Mapping[str, str]:
456 """Expand the variable in question.
457
458 Using ``var_dict`` and the previously parsed defaults, expand this
459 variable and subvariables.
460
461 :param dict var_dict: dictionary of key-value pairs to be used during
462 expansion
463 :returns: dict(variable=value)
464
465 Examples::
466
467 # (1)
468 v = URIVariable('/var')
469 expansion = v.expand({'var': 'value'})
470 print(expansion)
471 # => {'/var': '/value'}
472
473 # (2)
474 v = URIVariable('?var,hello,x,y')
475 expansion = v.expand({'var': 'value', 'hello': 'Hello World!',
476 'x': '1024', 'y': '768'})
477 print(expansion)
478 # => {'?var,hello,x,y':
479 # '?var=value&hello=Hello%20World%21&x=1024&y=768'}
480
481 """
482 return_values = []
483 if var_dict is None:
484 return {self.original: self.original}
485
486 for name, opts in self.variables:
487 value = var_dict.get(name, None)
488 if not value and value != "" and name in self.defaults:
489 value = self.defaults[name]
490
491 if value is None:
492 continue
493
494 expanded = None
495 if (
496 self.operator == Operator.path_segment
497 or self.operator == Operator.label_with_dot_prefix
498 ):
499 expansion = self._label_path_expansion
500 elif (
501 self.operator == Operator.form_style_query
502 or self.operator == Operator.form_style_query_continuation
503 ):
504 expansion = self._query_expansion
505 elif self.operator == Operator.path_style_parameter:
506 expansion = self._semi_path_expansion
507 else:
508 expansion = self._string_expansion
509 # match self.operator:
510 # case Operator.path_segment | Operator.label_with_dot_prefix:
511 # expansion = self._label_path_expansion
512 # case (Operator.form_style_query |
513 # Operator.form_style_query_continuation):
514 # expansion = self._query_expansion
515 # case Operator.path_style_parameter:
516 # expansion = self._semi_path_expansion
517 # case _:
518 # expansion = self._string_expansion
519
520 expanded = expansion(name, value, opts["explode"], opts["prefix"])
521
522 if expanded is not None:
523 return_values.append(expanded)
524
525 value = ""
526 if return_values:
527 value = (
528 self.operator.variable_prefix()
529 + self.operator.expansion_separator().join(return_values)
530 )
531 return {self.original: value}
532
533
534def is_list_of_tuples(
535 value: t.Any,
536) -> t.Tuple[bool, t.Optional[t.Sequence[t.Tuple[str, ScalarVariableValue]]]]:
537 if (
538 not value
539 or not isinstance(value, (list, tuple))
540 or not all(isinstance(t, tuple) and len(t) == 2 for t in value)
541 ):
542 return False, None
543
544 return True, value
545
546
547def list_test(value: t.Any) -> bool:
548 return isinstance(value, (list, tuple))
549
550
551def dict_test(value: t.Any) -> bool:
552 return isinstance(value, (dict, collections.abc.MutableMapping))
553
554
555def _encode(value: t.AnyStr, encoding: str = "utf-8") -> bytes:
556 if isinstance(value, str):
557 return value.encode(encoding)
558 return value
559
560
561def quote(value: t.Any, safe: str) -> str:
562 if not isinstance(value, (str, bytes)):
563 value = str(value)
564 return urllib.parse.quote(_encode(value), safe)