565 lines
18 KiB
Python
565 lines
18 KiB
Python
"""
|
|
|
|
uritemplate.variable
|
|
====================
|
|
|
|
This module contains the URIVariable class which powers the URITemplate class.
|
|
|
|
What treasures await you:
|
|
|
|
- URIVariable class
|
|
|
|
You see a hammer in front of you.
|
|
What do you do?
|
|
>
|
|
|
|
"""
|
|
|
|
import collections.abc
|
|
import enum
|
|
import string
|
|
import typing as t
|
|
import urllib.parse
|
|
|
|
ScalarVariableValue = t.Union[int, float, complex, str, None]
|
|
VariableValue = t.Union[
|
|
t.Sequence[ScalarVariableValue],
|
|
t.List[ScalarVariableValue],
|
|
t.Mapping[str, ScalarVariableValue],
|
|
t.Tuple[str, ScalarVariableValue],
|
|
ScalarVariableValue,
|
|
]
|
|
VariableValueDict = t.Dict[str, VariableValue]
|
|
|
|
|
|
_UNRESERVED_CHARACTERS: t.Final[str] = (
|
|
f"{string.ascii_letters}{string.digits}~-_."
|
|
)
|
|
_GEN_DELIMS: t.Final[str] = ":/?#[]@"
|
|
_SUB_DELIMS: t.Final[str] = "!$&'()*+,;="
|
|
_RESERVED_CHARACTERS: t.Final[str] = f"{_GEN_DELIMS}{_SUB_DELIMS}"
|
|
|
|
|
|
class Operator(enum.Enum):
|
|
# Section 2.2. Expressions
|
|
# expression = "{" [ operator ] variable-list "}"
|
|
# operator = op-level2 / op-level3 / op-reserve
|
|
# op-level2 = "+" / "#"
|
|
# op-level3 = "." / "/" / ";" / "?" / "&"
|
|
# op-reserve = "=" / "," / "!" / "@" / "|"
|
|
default = "" # 3.2.2. Simple String Expansiona: {var}
|
|
# Operator Level 2 (op-level2)
|
|
reserved = "+" # 3.2.3. Reserved Expansion: {+var}
|
|
fragment = "#" # 3.2.4. Fragment Expansion: {#var}
|
|
# Operator Level 3 (op-level3)
|
|
# 3.2.5. Label Expansion with Dot-Prefix: {.var}
|
|
label_with_dot_prefix = "."
|
|
path_segment = "/" # 3.2.6. Path Segment Expansion: {/var}
|
|
path_style_parameter = (
|
|
";" # 3.2.7. Path-Style Parameter Expansion: {;var}
|
|
)
|
|
form_style_query = "?" # 3.2.8. Form-Style Query Expansion: {?var}
|
|
# 3.2.9. Form-Style Query Continuation: {&var}
|
|
form_style_query_continuation = "&"
|
|
# Reserved Operators (op-reserve)
|
|
reserved_eq = "="
|
|
reserved_comma = ","
|
|
reserved_bang = "!"
|
|
reserved_at = "@"
|
|
reserved_pipe = "|"
|
|
|
|
def reserved_characters(self) -> str:
|
|
# TODO: Re-enable after un-commenting 3.9
|
|
# match self:
|
|
# case Operator.reserved:
|
|
# return _RESERVED_CHARACTERS + "%"
|
|
# # case Operator.default | Operator.reserved | Operator.fragment:
|
|
# case Operator.fragment:
|
|
# return _RESERVED_CHARACTERS
|
|
# case _:
|
|
# return ""
|
|
if self == Operator.reserved:
|
|
return _RESERVED_CHARACTERS + "%"
|
|
if self == Operator.fragment:
|
|
return _RESERVED_CHARACTERS
|
|
return ""
|
|
|
|
def expansion_separator(self) -> str:
|
|
"""Identify the separator used during expansion.
|
|
|
|
Per `Section 3.2.1. Variable Expansion`_:
|
|
|
|
====== =========== =========
|
|
Type Separator
|
|
====== =========== =========
|
|
``","`` (default)
|
|
``+`` ``","``
|
|
``#`` ``","``
|
|
``.`` ``"."``
|
|
``/`` ``"/"``
|
|
``;`` ``";"``
|
|
``?`` ``"&"``
|
|
``&`` ``"&"``
|
|
====== =========== =========
|
|
|
|
.. _`Section 3.2.1. Variable Expansion`:
|
|
https://www.rfc-editor.org/rfc/rfc6570#section-3.2.1
|
|
"""
|
|
if self == Operator.label_with_dot_prefix:
|
|
return "."
|
|
if self == Operator.path_segment:
|
|
return "/"
|
|
if self == Operator.path_style_parameter:
|
|
return ";"
|
|
if (
|
|
self == Operator.form_style_query
|
|
or self == Operator.form_style_query_continuation
|
|
):
|
|
return "&"
|
|
# if self == Operator.reserved or self == Operator.fragment:
|
|
# return ","
|
|
return ","
|
|
# match self:
|
|
# case Operator.label_with_dot_prefix:
|
|
# return "."
|
|
# case Operator.path_segment:
|
|
# return "/"
|
|
# case Operator.path_style_parameter:
|
|
# return ";"
|
|
# case (
|
|
# Operator.form_style_query |
|
|
# Operator.form_style_query_continuation
|
|
# ):
|
|
# return "&"
|
|
# case Operator.reserved | Operator.fragment:
|
|
# return ","
|
|
# case _:
|
|
# return ","
|
|
|
|
def variable_prefix(self) -> str:
|
|
if self == Operator.reserved:
|
|
return ""
|
|
return t.cast(str, self.value)
|
|
# match self:
|
|
# case Operator.reserved:
|
|
# return ""
|
|
# case _:
|
|
# return t.cast(str, self.value)
|
|
|
|
def _always_quote(self, value: str) -> str:
|
|
return quote(value, "")
|
|
|
|
def _only_quote_unquoted_characters(self, value: str) -> str:
|
|
if urllib.parse.unquote(value) == value:
|
|
return quote(value, _RESERVED_CHARACTERS)
|
|
return value
|
|
|
|
def quote(self, value: t.Any) -> str:
|
|
if not isinstance(value, (str, bytes)):
|
|
value = str(value)
|
|
if isinstance(value, bytes):
|
|
value = value.decode()
|
|
|
|
if self == Operator.reserved or self == Operator.fragment:
|
|
return self._only_quote_unquoted_characters(value)
|
|
return self._always_quote(value)
|
|
|
|
@staticmethod
|
|
def from_string(s: str) -> "Operator":
|
|
return _operators.get(s, Operator.default)
|
|
|
|
|
|
_operators: t.Final[t.Dict[str, Operator]] = {
|
|
"+": Operator.reserved,
|
|
"#": Operator.fragment,
|
|
".": Operator.label_with_dot_prefix,
|
|
"/": Operator.path_segment,
|
|
";": Operator.path_style_parameter,
|
|
"?": Operator.form_style_query,
|
|
"&": Operator.form_style_query_continuation,
|
|
"!": Operator.reserved_bang,
|
|
"|": Operator.reserved_pipe,
|
|
"@": Operator.reserved_at,
|
|
"=": Operator.reserved_eq,
|
|
",": Operator.reserved_comma,
|
|
}
|
|
|
|
|
|
class URIVariable:
|
|
"""This object validates everything inside the URITemplate object.
|
|
|
|
It validates template expansions and will truncate length as decided by
|
|
the template.
|
|
|
|
Please note that just like the :class:`URITemplate <URITemplate>`, this
|
|
object's ``__str__`` and ``__repr__`` methods do not return the same
|
|
information. Calling ``str(var)`` will return the original variable.
|
|
|
|
This object does the majority of the heavy lifting. The ``URITemplate``
|
|
object finds the variables in the URI and then creates ``URIVariable``
|
|
objects. Expansions of the URI are handled by each ``URIVariable``
|
|
object. ``URIVariable.expand()`` returns a dictionary of the original
|
|
variable and the expanded value. Check that method's documentation for
|
|
more information.
|
|
|
|
"""
|
|
|
|
def __init__(self, var: str):
|
|
#: The original string that comes through with the variable
|
|
self.original: str = var
|
|
#: The operator for the variable
|
|
self.operator: Operator = Operator.default
|
|
#: List of variables in this variable
|
|
self.variables: t.List[t.Tuple[str, t.MutableMapping[str, t.Any]]] = (
|
|
[]
|
|
)
|
|
#: List of variable names
|
|
self.variable_names: t.List[str] = []
|
|
#: List of defaults passed in
|
|
self.defaults: t.MutableMapping[str, ScalarVariableValue] = {}
|
|
# Parse the variable itself.
|
|
self.parse()
|
|
|
|
def __repr__(self) -> str:
|
|
return "URIVariable(%s)" % self
|
|
|
|
def __str__(self) -> str:
|
|
return self.original
|
|
|
|
def parse(self) -> None:
|
|
"""Parse the variable.
|
|
|
|
This finds the:
|
|
- operator,
|
|
- set of safe characters,
|
|
- variables, and
|
|
- defaults.
|
|
|
|
"""
|
|
var_list_str = self.original
|
|
if (operator_str := self.original[0]) in _operators:
|
|
self.operator = Operator.from_string(operator_str)
|
|
var_list_str = self.original[1:]
|
|
|
|
var_list = var_list_str.split(",")
|
|
|
|
for var in var_list:
|
|
default_val = None
|
|
name = var
|
|
# NOTE(sigmavirus24): This is from an earlier draft but is not in
|
|
# the specification
|
|
if "=" in var:
|
|
name, default_val = tuple(var.split("=", 1))
|
|
|
|
explode = name.endswith("*")
|
|
name = name.rstrip("*")
|
|
|
|
prefix: t.Optional[int] = None
|
|
if ":" in name:
|
|
name, prefix_str = tuple(name.split(":", 1))
|
|
prefix = int(prefix_str, 10)
|
|
|
|
if default_val:
|
|
self.defaults[name] = default_val
|
|
|
|
self.variables.append(
|
|
(name, {"explode": explode, "prefix": prefix})
|
|
)
|
|
|
|
self.variable_names = [varname for (varname, _) in self.variables]
|
|
|
|
def _query_expansion(
|
|
self,
|
|
name: str,
|
|
value: VariableValue,
|
|
explode: bool,
|
|
prefix: t.Optional[int],
|
|
) -> t.Optional[str]:
|
|
"""Expansion method for the '?' and '&' operators."""
|
|
if value is None:
|
|
return None
|
|
|
|
tuples, items = is_list_of_tuples(value)
|
|
|
|
safe = self.operator.reserved_characters()
|
|
_quote = self.operator.quote
|
|
if list_test(value) and not tuples:
|
|
if not value:
|
|
return None
|
|
value = t.cast(t.Sequence[ScalarVariableValue], value)
|
|
if explode:
|
|
return self.operator.expansion_separator().join(
|
|
f"{name}={_quote(v)}" for v in value
|
|
)
|
|
else:
|
|
value = ",".join(_quote(v) for v in value)
|
|
return f"{name}={value}"
|
|
|
|
if dict_test(value) or tuples:
|
|
if not value:
|
|
return None
|
|
value = t.cast(t.Mapping[str, ScalarVariableValue], value)
|
|
items = items or sorted(value.items())
|
|
if explode:
|
|
return self.operator.expansion_separator().join(
|
|
f"{quote(k, safe)}={_quote(v)}" for k, v in items
|
|
)
|
|
else:
|
|
value = ",".join(
|
|
f"{quote(k, safe)},{_quote(v)}" for k, v in items
|
|
)
|
|
return f"{name}={value}"
|
|
|
|
if value:
|
|
value = t.cast(t.Text, value)
|
|
value = value[:prefix] if prefix else value
|
|
return f"{name}={_quote(value)}"
|
|
return name + "="
|
|
|
|
def _label_path_expansion(
|
|
self,
|
|
name: str,
|
|
value: VariableValue,
|
|
explode: bool,
|
|
prefix: t.Optional[int],
|
|
) -> t.Optional[str]:
|
|
"""Label and path expansion method.
|
|
|
|
Expands for operators: '/', '.'
|
|
|
|
"""
|
|
join_str = self.operator.expansion_separator()
|
|
safe = self.operator.reserved_characters()
|
|
|
|
if value is None or (
|
|
not isinstance(value, (str, int, float, complex))
|
|
and len(value) == 0
|
|
):
|
|
return None
|
|
|
|
tuples, items = is_list_of_tuples(value)
|
|
|
|
if list_test(value) and not tuples:
|
|
if not explode:
|
|
join_str = ","
|
|
|
|
value = t.cast(t.Sequence[ScalarVariableValue], value)
|
|
fragments = [
|
|
self.operator.quote(v) for v in value if v is not None
|
|
]
|
|
return join_str.join(fragments) if fragments else None
|
|
|
|
if dict_test(value) or tuples:
|
|
value = t.cast(t.Mapping[str, ScalarVariableValue], value)
|
|
items = items or sorted(value.items())
|
|
format_str = "%s=%s"
|
|
if not explode:
|
|
format_str = "%s,%s"
|
|
join_str = ","
|
|
|
|
expanded = join_str.join(
|
|
format_str % (quote(k, safe), self.operator.quote(v))
|
|
for k, v in items
|
|
if v is not None
|
|
)
|
|
return expanded if expanded else None
|
|
|
|
value = t.cast(t.Text, value)
|
|
value = value[:prefix] if prefix else value
|
|
return self.operator.quote(value)
|
|
|
|
def _semi_path_expansion(
|
|
self,
|
|
name: str,
|
|
value: VariableValue,
|
|
explode: bool,
|
|
prefix: t.Optional[int],
|
|
) -> t.Optional[str]:
|
|
"""Expansion method for ';' operator."""
|
|
join_str = self.operator.expansion_separator()
|
|
safe = self.operator.reserved_characters()
|
|
|
|
if value is None:
|
|
return None
|
|
|
|
tuples, items = is_list_of_tuples(value)
|
|
|
|
if list_test(value) and not tuples:
|
|
value = t.cast(t.Sequence[ScalarVariableValue], value)
|
|
if explode:
|
|
expanded = join_str.join(
|
|
f"{name}={quote(v, safe)}" for v in value if v is not None
|
|
)
|
|
return expanded if expanded else None
|
|
else:
|
|
value = ",".join(quote(v, safe) for v in value)
|
|
return f"{name}={value}"
|
|
|
|
if dict_test(value) or tuples:
|
|
value = t.cast(t.Mapping[str, ScalarVariableValue], value)
|
|
items = items or sorted(value.items())
|
|
|
|
if explode:
|
|
return join_str.join(
|
|
f"{quote(k, safe)}={self.operator.quote(v)}"
|
|
for k, v in items
|
|
if v is not None
|
|
)
|
|
else:
|
|
expanded = ",".join(
|
|
f"{quote(k, safe)},{self.operator.quote(v)}"
|
|
for k, v in items
|
|
if v is not None
|
|
)
|
|
return f"{name}={expanded}"
|
|
|
|
value = t.cast(t.Text, value)
|
|
value = value[:prefix] if prefix else value
|
|
if value:
|
|
return f"{name}={self.operator.quote(value)}"
|
|
|
|
return name
|
|
|
|
def _string_expansion(
|
|
self,
|
|
name: str,
|
|
value: VariableValue,
|
|
explode: bool,
|
|
prefix: t.Optional[int],
|
|
) -> t.Optional[str]:
|
|
if value is None:
|
|
return None
|
|
|
|
tuples, items = is_list_of_tuples(value)
|
|
|
|
if list_test(value) and not tuples:
|
|
value = t.cast(t.Sequence[ScalarVariableValue], value)
|
|
return ",".join(self.operator.quote(v) for v in value)
|
|
|
|
if dict_test(value) or tuples:
|
|
value = t.cast(t.Mapping[str, ScalarVariableValue], value)
|
|
items = items or sorted(value.items())
|
|
format_str = "%s=%s" if explode else "%s,%s"
|
|
|
|
return ",".join(
|
|
format_str % (self.operator.quote(k), self.operator.quote(v))
|
|
for k, v in items
|
|
)
|
|
|
|
value = t.cast(t.Text, value)
|
|
value = value[:prefix] if prefix else value
|
|
return self.operator.quote(value)
|
|
|
|
def expand(
|
|
self, var_dict: t.Optional[VariableValueDict] = None
|
|
) -> t.Mapping[str, str]:
|
|
"""Expand the variable in question.
|
|
|
|
Using ``var_dict`` and the previously parsed defaults, expand this
|
|
variable and subvariables.
|
|
|
|
:param dict var_dict: dictionary of key-value pairs to be used during
|
|
expansion
|
|
:returns: dict(variable=value)
|
|
|
|
Examples::
|
|
|
|
# (1)
|
|
v = URIVariable('/var')
|
|
expansion = v.expand({'var': 'value'})
|
|
print(expansion)
|
|
# => {'/var': '/value'}
|
|
|
|
# (2)
|
|
v = URIVariable('?var,hello,x,y')
|
|
expansion = v.expand({'var': 'value', 'hello': 'Hello World!',
|
|
'x': '1024', 'y': '768'})
|
|
print(expansion)
|
|
# => {'?var,hello,x,y':
|
|
# '?var=value&hello=Hello%20World%21&x=1024&y=768'}
|
|
|
|
"""
|
|
return_values = []
|
|
if var_dict is None:
|
|
return {self.original: self.original}
|
|
|
|
for name, opts in self.variables:
|
|
value = var_dict.get(name, None)
|
|
if not value and value != "" and name in self.defaults:
|
|
value = self.defaults[name]
|
|
|
|
if value is None:
|
|
continue
|
|
|
|
expanded = None
|
|
if (
|
|
self.operator == Operator.path_segment
|
|
or self.operator == Operator.label_with_dot_prefix
|
|
):
|
|
expansion = self._label_path_expansion
|
|
elif (
|
|
self.operator == Operator.form_style_query
|
|
or self.operator == Operator.form_style_query_continuation
|
|
):
|
|
expansion = self._query_expansion
|
|
elif self.operator == Operator.path_style_parameter:
|
|
expansion = self._semi_path_expansion
|
|
else:
|
|
expansion = self._string_expansion
|
|
# match self.operator:
|
|
# case Operator.path_segment | Operator.label_with_dot_prefix:
|
|
# expansion = self._label_path_expansion
|
|
# case (Operator.form_style_query |
|
|
# Operator.form_style_query_continuation):
|
|
# expansion = self._query_expansion
|
|
# case Operator.path_style_parameter:
|
|
# expansion = self._semi_path_expansion
|
|
# case _:
|
|
# expansion = self._string_expansion
|
|
|
|
expanded = expansion(name, value, opts["explode"], opts["prefix"])
|
|
|
|
if expanded is not None:
|
|
return_values.append(expanded)
|
|
|
|
value = ""
|
|
if return_values:
|
|
value = (
|
|
self.operator.variable_prefix()
|
|
+ self.operator.expansion_separator().join(return_values)
|
|
)
|
|
return {self.original: value}
|
|
|
|
|
|
def is_list_of_tuples(
|
|
value: t.Any,
|
|
) -> t.Tuple[bool, t.Optional[t.Sequence[t.Tuple[str, ScalarVariableValue]]]]:
|
|
if (
|
|
not value
|
|
or not isinstance(value, (list, tuple))
|
|
or not all(isinstance(t, tuple) and len(t) == 2 for t in value)
|
|
):
|
|
return False, None
|
|
|
|
return True, value
|
|
|
|
|
|
def list_test(value: t.Any) -> bool:
|
|
return isinstance(value, (list, tuple))
|
|
|
|
|
|
def dict_test(value: t.Any) -> bool:
|
|
return isinstance(value, (dict, collections.abc.MutableMapping))
|
|
|
|
|
|
def _encode(value: t.AnyStr, encoding: str = "utf-8") -> bytes:
|
|
if isinstance(value, str):
|
|
return value.encode(encoding)
|
|
return value
|
|
|
|
|
|
def quote(value: t.Any, safe: str) -> str:
|
|
if not isinstance(value, (str, bytes)):
|
|
value = str(value)
|
|
return urllib.parse.quote(_encode(value), safe)
|