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.
326 lines
10 KiB
326 lines
10 KiB
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)
|
|
|