1from __future__ import annotations
2
3import codecs
4import collections.abc as cabc
5import re
6import typing as t
7
8from .structures import ImmutableList
9
10
11class Accept(ImmutableList[tuple[str, float]]):
12 """An :class:`Accept` object is just a list subclass for lists of
13 ``(value, quality)`` tuples. It is automatically sorted by specificity
14 and quality.
15
16 All :class:`Accept` objects work similar to a list but provide extra
17 functionality for working with the data. Containment checks are
18 normalized to the rules of that header:
19
20 >>> a = CharsetAccept([('ISO-8859-1', 1), ('utf-8', 0.7)])
21 >>> a.best
22 'ISO-8859-1'
23 >>> 'iso-8859-1' in a
24 True
25 >>> 'UTF8' in a
26 True
27 >>> 'utf7' in a
28 False
29
30 To get the quality for an item you can use normal item lookup:
31
32 >>> print a['utf-8']
33 0.7
34 >>> a['utf7']
35 0
36
37 .. versionchanged:: 0.5
38 :class:`Accept` objects are forced immutable now.
39
40 .. versionchanged:: 1.0.0
41 :class:`Accept` internal values are no longer ordered
42 alphabetically for equal quality tags. Instead the initial
43 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 = False
53 elif isinstance(values, Accept):
54 self.provided = values.provided
55 super().__init__(values)
56 else:
57 self.provided = True
58 values = sorted(
59 values, key=lambda x: (self._specificity(x[0]), x[1]), reverse=True
60 )
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.overload
72 def __getitem__(self, key: str) -> float: ...
73 @t.overload
74 def __getitem__(self, key: t.SupportsIndex) -> tuple[str, float]: ...
75 @t.overload
76 def __getitem__(self, key: slice) -> list[tuple[str, float]]: ...
77 def __getitem__(
78 self, key: str | t.SupportsIndex | slice
79 ) -> float | tuple[str, float] | list[tuple[str, float]]:
80 """Besides index lookup (getting item n) you can also pass it a string
81 to get the quality for the item. If the item is not in the list, the
82 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.6
92 In previous versions you had to use the item-lookup syntax
93 (eg: ``obj[key]`` instead of ``obj.quality(key)``)
94 """
95 for item, quality in self:
96 if self._value_matches(key, item):
97 return quality
98 return 0
99
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 True
104 return False
105
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.5
116 This used to raise :exc:`IndexError`, which was inconsistent
117 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 idx
123 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 -1
135
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 exit
157 return client_item, quality
158 return None
159
160 @t.overload
161 def best_match(self, matches: cabc.Iterable[str]) -> str | None: ...
162 @t.overload
163 def best_match(self, matches: cabc.Iterable[str], default: str = ...) -> str: ...
164 def best_match(
165 self, matches: cabc.Iterable[str], default: str | None = None
166 ) -> str | None:
167 """Returns the best match from a list of possible matches based
168 on the specificity and quality of the client. If two items have the
169 same quality and specificity, the one is returned that comes first.
170
171 :param matches: a list of matches to check for
172 :param default: the value that is returned if none match
173 """
174 result = default
175 best_quality: float = -1
176 best_specificity: tuple[float, ...] = (-1,)
177 for server_item in matches:
178 match = self._best_single_match(server_item)
179 if not match:
180 continue
181 client_item, quality = match
182 specificity = self._specificity(client_item)
183 if quality <= 0 or quality < best_quality:
184 continue
185 # better quality or same quality but more specific => better match
186 if quality > best_quality or specificity > best_specificity:
187 result = server_item
188 best_quality = quality
189 best_specificity = specificity
190 return result
191
192 @property
193 def best(self) -> str | None:
194 """The best match as value."""
195 if self:
196 return self[0][0]
197
198 return None
199
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 for
210 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 False
220
221 # value comes from the application, tell the developer when it
222 # 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 False
243
244 return (
245 (item_type == "*" and item_subtype == "*")
246 or (value_type == "*" and value_subtype == "*")
247 ) or (
248 item_type == value_type
249 and (
250 item_subtype == "*"
251 or value_subtype == "*"
252 or (item_subtype == value_subtype and item_params == value_params)
253 )
254 )
255
256 @property
257 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 @property
262 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 @property
267 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.overload
287 def best_match(self, matches: cabc.Iterable[str]) -> str | None: ...
288 @t.overload
289 def best_match(self, matches: cabc.Iterable[str], default: str = ...) -> str: ...
290 def best_match(
291 self, matches: cabc.Iterable[str], default: str | None = None
292 ) -> str | None:
293 """Given a list of supported values, finds the best match from
294 the list of accepted values.
295
296 Language tags are normalized for the purpose of matching, but
297 are returned unchanged.
298
299 If no exact match is found, this will fall back to matching
300 the first subtag (primary language only), first with the
301 accepted values then with the match values. This partial is not
302 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 result
315
316 # Fall back to accepting primary tags. If a client accepts
317 # "en-US", "en" is a valid match at this point. Need to use
318 # 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 result
326
327 # Fall back to matching primary tags. If the client accepts
328 # "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 first
333 # 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 default
338
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).name
347 except LookupError:
348 return name.lower()
349
350 return item == "*" or _normalize(value) == _normalize(item)