123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- 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)
|