You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
327 lines
10 KiB
327 lines
10 KiB
9 months ago
|
from __future__ import annotations
|
||
|
|
||
|
import codecs
|
||
|
import re
|
||
|
|
||
|
from .structures import ImmutableList
|
||
|
|
||
|
|
||
|
class Accept(ImmutableList):
|
||
|
"""An :class:`Accept` object is just a list subclass for lists of
|
||
|
``(value, quality)`` tuples. It is automatically sorted by specificity
|
||
|
and quality.
|
||
|
|
||
|
All :class:`Accept` objects work similar to a list but provide extra
|
||
|
functionality for working with the data. Containment checks are
|
||
|
normalized to the rules of that header:
|
||
|
|
||
|
>>> a = CharsetAccept([('ISO-8859-1', 1), ('utf-8', 0.7)])
|
||
|
>>> a.best
|
||
|
'ISO-8859-1'
|
||
|
>>> 'iso-8859-1' in a
|
||
|
True
|
||
|
>>> 'UTF8' in a
|
||
|
True
|
||
|
>>> 'utf7' in a
|
||
|
False
|
||
|
|
||
|
To get the quality for an item you can use normal item lookup:
|
||
|
|
||
|
>>> print a['utf-8']
|
||
|
0.7
|
||
|
>>> a['utf7']
|
||
|
0
|
||
|
|
||
|
.. versionchanged:: 0.5
|
||
|
:class:`Accept` objects are forced immutable now.
|
||
|
|
||
|
.. versionchanged:: 1.0.0
|
||
|
:class:`Accept` internal values are no longer ordered
|
||
|
alphabetically for equal quality tags. Instead the initial
|
||
|
order is preserved.
|
||
|
|
||
|
"""
|
||
|
|
||
|
def __init__(self, values=()):
|
||
|
if values is None:
|
||
|
list.__init__(self)
|
||
|
self.provided = False
|
||
|
elif isinstance(values, Accept):
|
||
|
self.provided = values.provided
|
||
|
list.__init__(self, values)
|
||
|
else:
|
||
|
self.provided = True
|
||
|
values = sorted(
|
||
|
values, key=lambda x: (self._specificity(x[0]), x[1]), reverse=True
|
||
|
)
|
||
|
list.__init__(self, values)
|
||
|
|
||
|
def _specificity(self, value):
|
||
|
"""Returns a tuple describing the value's specificity."""
|
||
|
return (value != "*",)
|
||
|
|
||
|
def _value_matches(self, value, item):
|
||
|
"""Check if a value matches a given accept item."""
|
||
|
return item == "*" or item.lower() == value.lower()
|
||
|
|
||
|
def __getitem__(self, key):
|
||
|
"""Besides index lookup (getting item n) you can also pass it a string
|
||
|
to get the quality for the item. If the item is not in the list, the
|
||
|
returned quality is ``0``.
|
||
|
"""
|
||
|
if isinstance(key, str):
|
||
|
return self.quality(key)
|
||
|
return list.__getitem__(self, key)
|
||
|
|
||
|
def quality(self, key):
|
||
|
"""Returns the quality of the key.
|
||
|
|
||
|
.. versionadded:: 0.6
|
||
|
In previous versions you had to use the item-lookup syntax
|
||
|
(eg: ``obj[key]`` instead of ``obj.quality(key)``)
|
||
|
"""
|
||
|
for item, quality in self:
|
||
|
if self._value_matches(key, item):
|
||
|
return quality
|
||
|
return 0
|
||
|
|
||
|
def __contains__(self, value):
|
||
|
for item, _quality in self:
|
||
|
if self._value_matches(value, item):
|
||
|
return True
|
||
|
return False
|
||
|
|
||
|
def __repr__(self):
|
||
|
pairs_str = ", ".join(f"({x!r}, {y})" for x, y in self)
|
||
|
return f"{type(self).__name__}([{pairs_str}])"
|
||
|
|
||
|
def index(self, key):
|
||
|
"""Get the position of an entry or raise :exc:`ValueError`.
|
||
|
|
||
|
:param key: The key to be looked up.
|
||
|
|
||
|
.. versionchanged:: 0.5
|
||
|
This used to raise :exc:`IndexError`, which was inconsistent
|
||
|
with the list API.
|
||
|
"""
|
||
|
if isinstance(key, str):
|
||
|
for idx, (item, _quality) in enumerate(self):
|
||
|
if self._value_matches(key, item):
|
||
|
return idx
|
||
|
raise ValueError(key)
|
||
|
return list.index(self, key)
|
||
|
|
||
|
def find(self, key):
|
||
|
"""Get the position of an entry or return -1.
|
||
|
|
||
|
:param key: The key to be looked up.
|
||
|
"""
|
||
|
try:
|
||
|
return self.index(key)
|
||
|
except ValueError:
|
||
|
return -1
|
||
|
|
||
|
def values(self):
|
||
|
"""Iterate over all values."""
|
||
|
for item in self:
|
||
|
yield item[0]
|
||
|
|
||
|
def to_header(self):
|
||
|
"""Convert the header set into an HTTP header string."""
|
||
|
result = []
|
||
|
for value, quality in self:
|
||
|
if quality != 1:
|
||
|
value = f"{value};q={quality}"
|
||
|
result.append(value)
|
||
|
return ",".join(result)
|
||
|
|
||
|
def __str__(self):
|
||
|
return self.to_header()
|
||
|
|
||
|
def _best_single_match(self, match):
|
||
|
for client_item, quality in self:
|
||
|
if self._value_matches(match, client_item):
|
||
|
# self is sorted by specificity descending, we can exit
|
||
|
return client_item, quality
|
||
|
return None
|
||
|
|
||
|
def best_match(self, matches, default=None):
|
||
|
"""Returns the best match from a list of possible matches based
|
||
|
on the specificity and quality of the client. If two items have the
|
||
|
same quality and specificity, the one is returned that comes first.
|
||
|
|
||
|
:param matches: a list of matches to check for
|
||
|
:param default: the value that is returned if none match
|
||
|
"""
|
||
|
result = default
|
||
|
best_quality = -1
|
||
|
best_specificity = (-1,)
|
||
|
for server_item in matches:
|
||
|
match = self._best_single_match(server_item)
|
||
|
if not match:
|
||
|
continue
|
||
|
client_item, quality = match
|
||
|
specificity = self._specificity(client_item)
|
||
|
if quality <= 0 or quality < best_quality:
|
||
|
continue
|
||
|
# better quality or same quality but more specific => better match
|
||
|
if quality > best_quality or specificity > best_specificity:
|
||
|
result = server_item
|
||
|
best_quality = quality
|
||
|
best_specificity = specificity
|
||
|
return result
|
||
|
|
||
|
@property
|
||
|
def best(self):
|
||
|
"""The best match as value."""
|
||
|
if self:
|
||
|
return self[0][0]
|
||
|
|
||
|
|
||
|
_mime_split_re = re.compile(r"/|(?:\s*;\s*)")
|
||
|
|
||
|
|
||
|
def _normalize_mime(value):
|
||
|
return _mime_split_re.split(value.lower())
|
||
|
|
||
|
|
||
|
class MIMEAccept(Accept):
|
||
|
"""Like :class:`Accept` but with special methods and behavior for
|
||
|
mimetypes.
|
||
|
"""
|
||
|
|
||
|
def _specificity(self, value):
|
||
|
return tuple(x != "*" for x in _mime_split_re.split(value))
|
||
|
|
||
|
def _value_matches(self, value, item):
|
||
|
# item comes from the client, can't match if it's invalid.
|
||
|
if "/" not in item:
|
||
|
return False
|
||
|
|
||
|
# value comes from the application, tell the developer when it
|
||
|
# doesn't look valid.
|
||
|
if "/" not in value:
|
||
|
raise ValueError(f"invalid mimetype {value!r}")
|
||
|
|
||
|
# Split the match value into type, subtype, and a sorted list of parameters.
|
||
|
normalized_value = _normalize_mime(value)
|
||
|
value_type, value_subtype = normalized_value[:2]
|
||
|
value_params = sorted(normalized_value[2:])
|
||
|
|
||
|
# "*/*" is the only valid value that can start with "*".
|
||
|
if value_type == "*" and value_subtype != "*":
|
||
|
raise ValueError(f"invalid mimetype {value!r}")
|
||
|
|
||
|
# Split the accept item into type, subtype, and parameters.
|
||
|
normalized_item = _normalize_mime(item)
|
||
|
item_type, item_subtype = normalized_item[:2]
|
||
|
item_params = sorted(normalized_item[2:])
|
||
|
|
||
|
# "*/not-*" from the client is invalid, can't match.
|
||
|
if item_type == "*" and item_subtype != "*":
|
||
|
return False
|
||
|
|
||
|
return (
|
||
|
(item_type == "*" and item_subtype == "*")
|
||
|
or (value_type == "*" and value_subtype == "*")
|
||
|
) or (
|
||
|
item_type == value_type
|
||
|
and (
|
||
|
item_subtype == "*"
|
||
|
or value_subtype == "*"
|
||
|
or (item_subtype == value_subtype and item_params == value_params)
|
||
|
)
|
||
|
)
|
||
|
|
||
|
@property
|
||
|
def accept_html(self):
|
||
|
"""True if this object accepts HTML."""
|
||
|
return (
|
||
|
"text/html" in self or "application/xhtml+xml" in self or self.accept_xhtml
|
||
|
)
|
||
|
|
||
|
@property
|
||
|
def accept_xhtml(self):
|
||
|
"""True if this object accepts XHTML."""
|
||
|
return "application/xhtml+xml" in self or "application/xml" in self
|
||
|
|
||
|
@property
|
||
|
def accept_json(self):
|
||
|
"""True if this object accepts JSON."""
|
||
|
return "application/json" in self
|
||
|
|
||
|
|
||
|
_locale_delim_re = re.compile(r"[_-]")
|
||
|
|
||
|
|
||
|
def _normalize_lang(value):
|
||
|
"""Process a language tag for matching."""
|
||
|
return _locale_delim_re.split(value.lower())
|
||
|
|
||
|
|
||
|
class LanguageAccept(Accept):
|
||
|
"""Like :class:`Accept` but with normalization for language tags."""
|
||
|
|
||
|
def _value_matches(self, value, item):
|
||
|
return item == "*" or _normalize_lang(value) == _normalize_lang(item)
|
||
|
|
||
|
def best_match(self, matches, default=None):
|
||
|
"""Given a list of supported values, finds the best match from
|
||
|
the list of accepted values.
|
||
|
|
||
|
Language tags are normalized for the purpose of matching, but
|
||
|
are returned unchanged.
|
||
|
|
||
|
If no exact match is found, this will fall back to matching
|
||
|
the first subtag (primary language only), first with the
|
||
|
accepted values then with the match values. This partial is not
|
||
|
applied to any other language subtags.
|
||
|
|
||
|
The default is returned if no exact or fallback match is found.
|
||
|
|
||
|
:param matches: A list of supported languages to find a match.
|
||
|
:param default: The value that is returned if none match.
|
||
|
"""
|
||
|
# Look for an exact match first. If a client accepts "en-US",
|
||
|
# "en-US" is a valid match at this point.
|
||
|
result = super().best_match(matches)
|
||
|
|
||
|
if result is not None:
|
||
|
return result
|
||
|
|
||
|
# Fall back to accepting primary tags. If a client accepts
|
||
|
# "en-US", "en" is a valid match at this point. Need to use
|
||
|
# re.split to account for 2 or 3 letter codes.
|
||
|
fallback = Accept(
|
||
|
[(_locale_delim_re.split(item[0], 1)[0], item[1]) for item in self]
|
||
|
)
|
||
|
result = fallback.best_match(matches)
|
||
|
|
||
|
if result is not None:
|
||
|
return result
|
||
|
|
||
|
# Fall back to matching primary tags. If the client accepts
|
||
|
# "en", "en-US" is a valid match at this point.
|
||
|
fallback_matches = [_locale_delim_re.split(item, 1)[0] for item in matches]
|
||
|
result = super().best_match(fallback_matches)
|
||
|
|
||
|
# Return a value from the original match list. Find the first
|
||
|
# original value that starts with the matched primary tag.
|
||
|
if result is not None:
|
||
|
return next(item for item in matches if item.startswith(result))
|
||
|
|
||
|
return default
|
||
|
|
||
|
|
||
|
class CharsetAccept(Accept):
|
||
|
"""Like :class:`Accept` but with normalization for charsets."""
|
||
|
|
||
|
def _value_matches(self, value, item):
|
||
|
def _normalize(name):
|
||
|
try:
|
||
|
return codecs.lookup(name).name
|
||
|
except LookupError:
|
||
|
return name.lower()
|
||
|
|
||
|
return item == "*" or _normalize(value) == _normalize(item)
|