This commit is contained in:
Iliyan Angelov
2025-09-19 11:58:53 +03:00
parent 306b20e24a
commit 6b247e5b9f
11423 changed files with 1500615 additions and 778 deletions

View File

@@ -0,0 +1,33 @@
"""
uritemplate
===========
URI templates implemented as close to :rfc:`6570` as possible
See http://uritemplate.rtfd.org/ for documentation
:copyright:
(c) 2013 Ian Stapleton Cordasco
:license:
Modified BSD Apache License (Version 2.0), see LICENSE for more details
and either LICENSE.BSD or LICENSE.APACHE for the details of those specific
licenses
"""
__title__ = "uritemplate"
__author__ = "Ian Stapleton Cordasco"
__license__ = "Modified BSD or Apache License, Version 2.0"
__copyright__ = "Copyright 2013 Ian Stapleton Cordasco"
__version__ = "4.2.0"
__version_info__ = tuple(
int(i) for i in __version__.split(".") if i.isdigit()
)
from uritemplate.api import URITemplate
from uritemplate.api import expand
from uritemplate.api import partial
from uritemplate.api import variables
__all__ = ("URITemplate", "expand", "partial", "variables")

View File

@@ -0,0 +1,86 @@
"""
uritemplate.api
===============
This module contains the very simple API provided by uritemplate.
"""
import typing as t
from uritemplate import variable
from uritemplate.orderedset import OrderedSet
from uritemplate.template import URITemplate
__all__ = ("OrderedSet", "URITemplate", "expand", "partial", "variables")
def expand(
uri: str,
var_dict: t.Optional[variable.VariableValueDict] = None,
**kwargs: variable.VariableValue,
) -> str:
"""Expand the template with the given parameters.
:param str uri: The templated URI to expand
:param dict var_dict: Optional dictionary with variables and values
:param kwargs: Alternative way to pass arguments
:returns: str
Example::
expand('https://api.github.com{/end}', {'end': 'users'})
expand('https://api.github.com{/end}', end='gists')
.. note:: Passing values by both parts, may override values in
``var_dict``. For example::
expand('https://{var}', {'var': 'val1'}, var='val2')
``val2`` will be used instead of ``val1``.
"""
return URITemplate(uri).expand(var_dict, **kwargs)
def partial(
uri: str,
var_dict: t.Optional[variable.VariableValueDict] = None,
**kwargs: variable.VariableValue,
) -> URITemplate:
"""Partially expand the template with the given parameters.
If all of the parameters for the template are not given, return a
partially expanded template.
:param dict var_dict: Optional dictionary with variables and values
:param kwargs: Alternative way to pass arguments
:returns: :class:`URITemplate`
Example::
t = URITemplate('https://api.github.com{/end}')
t.partial() # => URITemplate('https://api.github.com{/end}')
"""
return URITemplate(uri).partial(var_dict, **kwargs)
def variables(uri: str) -> OrderedSet:
"""Parse the variables of the template.
This returns all of the variable names in the URI Template.
:returns: Set of variable names
:rtype: set
Example::
variables('https://api.github.com{/end})
# => {'end'}
variables('https://api.github.com/repos{/username}{/repository}')
# => {'username', 'repository'}
"""
return OrderedSet(URITemplate(uri).variable_names)

View File

@@ -0,0 +1,92 @@
# From: https://github.com/ActiveState/code/blob/master/recipes/Python/576696_OrderedSet_with_Weakrefs/ # noqa
import typing as t
import weakref
class Link:
"""Representation of one item in a doubly-linked list."""
__slots__ = ("prev", "next", "key", "__weakref__")
prev: "Link"
next: "Link"
key: str
class OrderedSet(t.MutableSet[str]):
"""A set that remembers the order in which items were added."""
# Big-O running times for all methods are the same as for regular sets.
# The internal self.__map dictionary maps keys to links in a doubly linked
# list. The circular doubly linked list starts and ends with a sentinel
# element. The sentinel element never gets deleted (this simplifies the
# algorithm). The prev/next links are weakref proxies (to prevent circular
# references). Individual links are kept alive by the hard reference in
# self.__map. Those hard references disappear when a key is deleted from
# an OrderedSet.
def __init__(self, iterable: t.Optional[t.Iterable[str]] = None):
self.__root = root = Link() # sentinel node for doubly linked list
root.prev = root.next = root
self.__map: t.MutableMapping[str, Link] = {} # key --> link
if iterable is not None:
self |= iterable # type: ignore
def __len__(self) -> int:
return len(self.__map)
def __contains__(self, key: object) -> bool:
return key in self.__map
def add(self, key: str) -> None:
# Store new key in a new link at the end of the linked list
if key not in self.__map:
self.__map[key] = link = Link()
root = self.__root
last = root.prev
link.prev, link.next, link.key = last, root, key
last.next = root.prev = weakref.proxy(link)
def discard(self, key: str) -> None:
# Remove an existing item using self.__map to find the link which is
# then removed by updating the links in the predecessor and successors.
if key in self.__map:
link = self.__map.pop(key)
link.prev.next = link.next
link.next.prev = link.prev
def __iter__(self) -> t.Generator[str, None, None]:
# Traverse the linked list in order.
root = self.__root
curr = root.next
while curr is not root:
yield curr.key
curr = curr.next
def __reversed__(self) -> t.Generator[str, None, None]:
# Traverse the linked list in reverse order.
root = self.__root
curr = root.prev
while curr is not root:
yield curr.key
curr = curr.prev
def pop(self, last: bool = True) -> str:
if not self:
raise KeyError("set is empty")
key = next(reversed(self)) if last else next(iter(self))
self.discard(key)
return key
def __repr__(self) -> str:
if not self:
return f"{self.__class__.__name__}()"
return f"{self.__class__.__name__}({list(self)!r})"
def __str__(self) -> str:
return self.__repr__()
def __eq__(self, other: object) -> bool:
if isinstance(other, OrderedSet):
return len(self) == len(other) and list(self) == list(other)
other = t.cast(t.Iterable[str], other)
return not self.isdisjoint(other)

View File

@@ -0,0 +1,169 @@
"""
uritemplate.template
====================
This module contains the essential inner workings of uritemplate.
What treasures await you:
- URITemplate class
You see a treasure chest of knowledge in front of you.
What do you do?
>
"""
import re
import typing as t
from uritemplate import orderedset
from uritemplate import variable
template_re = re.compile("{([^}]+)}")
def _merge(
var_dict: t.Optional[variable.VariableValueDict],
overrides: variable.VariableValueDict,
) -> variable.VariableValueDict:
if var_dict:
opts = var_dict.copy()
opts.update(overrides)
return opts
return overrides
class URITemplate:
"""This parses the template and will be used to expand it.
This is the most important object as the center of the API.
Example::
from uritemplate import URITemplate
import requests
t = URITemplate(
'https://api.github.com/users/sigmavirus24/gists{/gist_id}'
)
uri = t.expand(gist_id=123456)
resp = requests.get(uri)
for gist in resp.json():
print(gist['html_url'])
Please note::
str(t)
# 'https://api.github.com/users/sigmavirus24/gists{/gistid}'
repr(t) # is equivalent to
# URITemplate(str(t))
# Where str(t) is interpreted as the URI string.
Also, ``URITemplates`` are hashable so they can be used as keys in
dictionaries.
"""
def __init__(self, uri: str):
#: The original URI to be parsed.
self.uri: str = uri
#: A list of the variables in the URI. They are stored as
#: :class:`~uritemplate.variable.URIVariable`\ s
self.variables: t.List[variable.URIVariable] = [
variable.URIVariable(m.groups()[0])
for m in template_re.finditer(self.uri)
]
#: A set of variable names in the URI.
self.variable_names = orderedset.OrderedSet()
for var in self.variables:
for name in var.variable_names:
self.variable_names.add(name)
def __repr__(self) -> str:
return 'URITemplate("%s")' % self
def __str__(self) -> str:
return self.uri
def __eq__(self, other: object) -> bool:
if not isinstance(other, URITemplate):
return NotImplemented
return self.uri == other.uri
def __hash__(self) -> int:
return hash(self.uri)
def _expand(
self, var_dict: variable.VariableValueDict, replace: bool
) -> str:
if not self.variables:
return self.uri
expansion = var_dict
expanded: t.Dict[str, str] = {}
for v in self.variables:
expanded.update(v.expand(expansion))
def replace_all(match: "re.Match[str]") -> str:
return expanded.get(match.groups()[0], "")
def replace_partial(match: "re.Match[str]") -> str:
match_group = match.groups()[0]
var = "{%s}" % match_group
return expanded.get(match_group) or var
replace_func = replace_partial if replace else replace_all
return template_re.sub(replace_func, self.uri)
def expand(
self,
var_dict: t.Optional[variable.VariableValueDict] = None,
**kwargs: variable.VariableValue,
) -> str:
"""Expand the template with the given parameters.
:param dict var_dict: Optional dictionary with variables and values
:param kwargs: Alternative way to pass arguments
:returns: str
Example::
t = URITemplate('https://api.github.com{/end}')
t.expand({'end': 'users'})
t.expand(end='gists')
.. note:: Passing values by both parts, may override values in
``var_dict``. For example::
expand('https://{var}', {'var': 'val1'}, var='val2')
``val2`` will be used instead of ``val1``.
"""
return self._expand(_merge(var_dict, kwargs), False)
def partial(
self,
var_dict: t.Optional[variable.VariableValueDict] = None,
**kwargs: variable.VariableValue,
) -> "URITemplate":
"""Partially expand the template with the given parameters.
If all of the parameters for the template are not given, return a
partially expanded template.
:param dict var_dict: Optional dictionary with variables and values
:param kwargs: Alternative way to pass arguments
:returns: :class:`URITemplate`
Example::
t = URITemplate('https://api.github.com{/end}')
t.partial() # => URITemplate('https://api.github.com{/end}')
"""
return URITemplate(self._expand(_merge(var_dict, kwargs), True))

View File

@@ -0,0 +1,564 @@
"""
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)