codekingpro/portable-devtools
114k
1from __future__ import annotations2 3import codecs4import collections.abc as cabc5import re6import typing as t7 8from .structures import ImmutableList9 10 11class Accept(ImmutableList[tuple[str, float]]):12 """An :class:`Accept` object is just a list subclass for lists of13 ``(value, quality)`` tuples. It is automatically sorted by specificity14 and quality.15 16 All :class:`Accept` objects work similar to a list but provide extra17 functionality for working with the data. Containment checks are18 normalized to the rules of that header:19 20 >>> a = CharsetAccept([('ISO-8859-1', 1), ('utf-8', 0.7)])21 >>> a.best22 'ISO-8859-1'23 >>> 'iso-8859-1' in a24 True25 >>> 'UTF8' in a26 True27 >>> 'utf7' in a28 False29 30 To get the quality for an item you can use normal item lookup:31 32 >>> print a['utf-8']33 0.734 >>> a['utf7']35 036 37 .. versionchanged:: 0.538 :class:`Accept` objects are forced immutable now.39 40 .. versionchanged:: 1.0.041 :class:`Accept` internal values are no longer ordered42 alphabetically for equal quality tags. Instead the initial43 order is preserved.44 45 """46 47 def __init__(48 self, values: Accept | cabc.Iterable[tuple[str, float]] | None = ()49 ) -> None:50 if values is None:51 super().__init__()52 self.provided = False53 elif isinstance(values, Accept):54 self.provided = values.provided55 super().__init__(values)56 else:57 self.provided = True58 values = sorted(59 values, key=lambda x: (self._specificity(x[0]), x[1]), reverse=True60 )61 super().__init__(values)62 63 def _specificity(self, value: str) -> tuple[bool, ...]:64 """Returns a tuple describing the value's specificity."""65 return (value != "*",)66 67 def _value_matches(self, value: str, item: str) -> bool:68 """Check if a value matches a given accept item."""69 return item == "*" or item.lower() == value.lower()70 71 @t.overload72 def __getitem__(self, key: str) -> float: ...73 @t.overload74 def __getitem__(self, key: t.SupportsIndex) -> tuple[str, float]: ...75 @t.overload76 def __getitem__(self, key: slice) -> list[tuple[str, float]]: ...77 def __getitem__(78 self, key: str | t.SupportsIndex | slice79 ) -> float | tuple[str, float] | list[tuple[str, float]]:80 """Besides index lookup (getting item n) you can also pass it a string81 to get the quality for the item. If the item is not in the list, the82 returned quality is ``0``.83 """84 if isinstance(key, str):85 return self.quality(key)86 return list.__getitem__(self, key)87 88 def quality(self, key: str) -> float:89 """Returns the quality of the key.90 91 .. versionadded:: 0.692 In previous versions you had to use the item-lookup syntax93 (eg: ``obj[key]`` instead of ``obj.quality(key)``)94 """95 for item, quality in self:96 if self._value_matches(key, item):97 return quality98 return 099 100 def __contains__(self, value: str) -> bool: # type: ignore[override]101 for item, _quality in self:102 if self._value_matches(value, item):103 return True104 return False105 106 def __repr__(self) -> str:107 pairs_str = ", ".join(f"({x!r}, {y})" for x, y in self)108 return f"{type(self).__name__}([{pairs_str}])"109 110 def index(self, key: str | tuple[str, float]) -> int: # type: ignore[override]111 """Get the position of an entry or raise :exc:`ValueError`.112 113 :param key: The key to be looked up.114 115 .. versionchanged:: 0.5116 This used to raise :exc:`IndexError`, which was inconsistent117 with the list API.118 """119 if isinstance(key, str):120 for idx, (item, _quality) in enumerate(self):121 if self._value_matches(key, item):122 return idx123 raise ValueError(key)124 return list.index(self, key)125 126 def find(self, key: str | tuple[str, float]) -> int:127 """Get the position of an entry or return -1.128 129 :param key: The key to be looked up.130 """131 try:132 return self.index(key)133 except ValueError:134 return -1135 136 def values(self) -> cabc.Iterator[str]:137 """Iterate over all values."""138 for item in self:139 yield item[0]140 141 def to_header(self) -> str:142 """Convert the header set into an HTTP header string."""143 result = []144 for value, quality in self:145 if quality != 1:146 value = f"{value};q={quality}"147 result.append(value)148 return ",".join(result)149 150 def __str__(self) -> str:151 return self.to_header()152 153 def _best_single_match(self, match: str) -> tuple[str, float] | None:154 for client_item, quality in self:155 if self._value_matches(match, client_item):156 # self is sorted by specificity descending, we can exit157 return client_item, quality158 return None159 160 @t.overload161 def best_match(self, matches: cabc.Iterable[str]) -> str | None: ...162 @t.overload163 def best_match(self, matches: cabc.Iterable[str], default: str = ...) -> str: ...164 def best_match(165 self, matches: cabc.Iterable[str], default: str | None = None166 ) -> str | None:167 """Returns the best match from a list of possible matches based168 on the specificity and quality of the client. If two items have the169 same quality and specificity, the one is returned that comes first.170 171 :param matches: a list of matches to check for172 :param default: the value that is returned if none match173 """174 result = default175 best_quality: float = -1176 best_specificity: tuple[float, ...] = (-1,)177 for server_item in matches:178 match = self._best_single_match(server_item)179 if not match:180 continue181 client_item, quality = match182 specificity = self._specificity(client_item)183 if quality <= 0 or quality < best_quality:184 continue185 # better quality or same quality but more specific => better match186 if quality > best_quality or specificity > best_specificity:187 result = server_item188 best_quality = quality189 best_specificity = specificity190 return result191 192 @property193 def best(self) -> str | None:194 """The best match as value."""195 if self:196 return self[0][0]197 198 return None199 200 201_mime_split_re = re.compile(r"/|(?:\s*;\s*)")202 203 204def _normalize_mime(value: str) -> list[str]:205 return _mime_split_re.split(value.lower())206 207 208class MIMEAccept(Accept):209 """Like :class:`Accept` but with special methods and behavior for210 mimetypes.211 """212 213 def _specificity(self, value: str) -> tuple[bool, ...]:214 return tuple(x != "*" for x in _mime_split_re.split(value))215 216 def _value_matches(self, value: str, item: str) -> bool:217 # item comes from the client, can't match if it's invalid.218 if "/" not in item:219 return False220 221 # value comes from the application, tell the developer when it222 # doesn't look valid.223 if "/" not in value:224 raise ValueError(f"invalid mimetype {value!r}")225 226 # Split the match value into type, subtype, and a sorted list of parameters.227 normalized_value = _normalize_mime(value)228 value_type, value_subtype = normalized_value[:2]229 value_params = sorted(normalized_value[2:])230 231 # "*/*" is the only valid value that can start with "*".232 if value_type == "*" and value_subtype != "*":233 raise ValueError(f"invalid mimetype {value!r}")234 235 # Split the accept item into type, subtype, and parameters.236 normalized_item = _normalize_mime(item)237 item_type, item_subtype = normalized_item[:2]238 item_params = sorted(normalized_item[2:])239 240 # "*/not-*" from the client is invalid, can't match.241 if item_type == "*" and item_subtype != "*":242 return False243 244 return (245 (item_type == "*" and item_subtype == "*")246 or (value_type == "*" and value_subtype == "*")247 ) or (248 item_type == value_type249 and (250 item_subtype == "*"251 or value_subtype == "*"252 or (item_subtype == value_subtype and item_params == value_params)253 )254 )255 256 @property257 def accept_html(self) -> bool:258 """True if this object accepts HTML."""259 return "text/html" in self or self.accept_xhtml # type: ignore[comparison-overlap]260 261 @property262 def accept_xhtml(self) -> bool:263 """True if this object accepts XHTML."""264 return "application/xhtml+xml" in self or "application/xml" in self # type: ignore[comparison-overlap]265 266 @property267 def accept_json(self) -> bool:268 """True if this object accepts JSON."""269 return "application/json" in self # type: ignore[comparison-overlap]270 271 272_locale_delim_re = re.compile(r"[_-]")273 274 275def _normalize_lang(value: str) -> list[str]:276 """Process a language tag for matching."""277 return _locale_delim_re.split(value.lower())278 279 280class LanguageAccept(Accept):281 """Like :class:`Accept` but with normalization for language tags."""282 283 def _value_matches(self, value: str, item: str) -> bool:284 return item == "*" or _normalize_lang(value) == _normalize_lang(item)285 286 @t.overload287 def best_match(self, matches: cabc.Iterable[str]) -> str | None: ...288 @t.overload289 def best_match(self, matches: cabc.Iterable[str], default: str = ...) -> str: ...290 def best_match(291 self, matches: cabc.Iterable[str], default: str | None = None292 ) -> str | None:293 """Given a list of supported values, finds the best match from294 the list of accepted values.295 296 Language tags are normalized for the purpose of matching, but297 are returned unchanged.298 299 If no exact match is found, this will fall back to matching300 the first subtag (primary language only), first with the301 accepted values then with the match values. This partial is not302 applied to any other language subtags.303 304 The default is returned if no exact or fallback match is found.305 306 :param matches: A list of supported languages to find a match.307 :param default: The value that is returned if none match.308 """309 # Look for an exact match first. If a client accepts "en-US",310 # "en-US" is a valid match at this point.311 result = super().best_match(matches)312 313 if result is not None:314 return result315 316 # Fall back to accepting primary tags. If a client accepts317 # "en-US", "en" is a valid match at this point. Need to use318 # re.split to account for 2 or 3 letter codes.319 fallback = Accept(320 [(_locale_delim_re.split(item[0], 1)[0], item[1]) for item in self]321 )322 result = fallback.best_match(matches)323 324 if result is not None:325 return result326 327 # Fall back to matching primary tags. If the client accepts328 # "en", "en-US" is a valid match at this point.329 fallback_matches = [_locale_delim_re.split(item, 1)[0] for item in matches]330 result = super().best_match(fallback_matches)331 332 # Return a value from the original match list. Find the first333 # original value that starts with the matched primary tag.334 if result is not None:335 return next(item for item in matches if item.startswith(result))336 337 return default338 339 340class CharsetAccept(Accept):341 """Like :class:`Accept` but with normalization for charsets."""342 343 def _value_matches(self, value: str, item: str) -> bool:344 def _normalize(name: str) -> str:345 try:346 return codecs.lookup(name).name347 except LookupError:348 return name.lower()349 350 return item == "*" or _normalize(value) == _normalize(item)351 