1from __future__ import annotations
2
3import codecs
4import collections.abc as cabc
5import re
6import typing as t
7
8from ..http import dump_options_header
9from ..http import parse_list_header
10from ..http import parse_options_header
11
12_q_value_re = re.compile(r"0(\.[0-9]{,3})?|1(\.0{,3})?")
13
14
15class Accept(cabc.Sequence[tuple[str, float]]):
16 """A parsed ``Accept-Encoding`` header.
17
18 Despite its name, this class is used for the ``Accept-Encoding`` header.
19 :class:`.MIMEAccept` is used for the ``Accept`` header.
20
21 .. versionchanged:: 3.2
22 Inherits ``Sequence`` instead of ``ImmutableList``.
23
24 .. versionchanged:: 1.0
25 Items with equal quality preserve initial order instead of being ordered
26 alphabetically.
27
28 .. versionchanged:: 0.5
29 Immutability is enforced.
30 """
31
32 def __init__(self, values: cabc.Iterable[tuple[str, float]] | None = None) -> None:
33 if values is None:
34 values = ()
35
36 self._items = tuple(
37 sorted(values, key=lambda x: (self._specificity(x[0]), x[1]), reverse=True)
38 )
39
40 def _specificity(self, value: str) -> tuple[bool, ...]:
41 """Returns a tuple describing the value's specificity."""
42 return (value != "*",)
43
44 def _value_matches(self, app_value: str, req_value: str) -> bool:
45 """Check if a value matches a specific value in the collection.
46
47 :param app_value: The value to check.
48 :param req_value: The value in the collection to check against.
49 """
50 return req_value == "*" or req_value.lower() == app_value.lower()
51
52 @t.overload
53 def __getitem__(self, key: str) -> float: ...
54 @t.overload
55 def __getitem__(self, key: t.SupportsIndex) -> tuple[str, float]: ...
56 @t.overload
57 def __getitem__(self, key: slice) -> tuple[tuple[str, float]]: ...
58 def __getitem__(
59 self, key: str | t.SupportsIndex | slice
60 ) -> float | tuple[str, float] | tuple[tuple[str, float]]:
61 """Get the quality for the given value, 0 if it is not in the
62 collection. Or the entry at the given index, or a slice of entries.
63 """
64 if isinstance(key, str):
65 return self.quality(key)
66
67 return self._items[key] # type: ignore[return-value]
68
69 def quality(self, value: str) -> float:
70 """Get the quality of the first value in the collection that matches the
71 given value.
72
73 :param value: The value to match.
74
75 .. versionadded:: 0.6
76 """
77 for req_value, q in self:
78 if self._value_matches(value, req_value):
79 return q
80
81 return 0
82
83 def __contains__(self, value: str) -> bool: # type: ignore[override]
84 for req_value, _ in self:
85 if self._value_matches(value, req_value):
86 return True
87
88 return False
89
90 def __len__(self) -> int:
91 return len(self._items)
92
93 def __iter__(self) -> cabc.Iterator[tuple[str, float]]:
94 return iter(self._items)
95
96 def __eq__(self, other: object) -> bool:
97 if not isinstance(other, self.__class__):
98 return NotImplemented
99
100 return self._items == other._items
101
102 def __repr__(self) -> str:
103 pairs_str = ", ".join(f"({value!r}, {q})" for value, q in self)
104 return f"{type(self).__name__}([{pairs_str}])"
105
106 def index(self, value: str | tuple[str, float]) -> int: # type: ignore[override]
107 """Return the index of the first value in the collection that matches
108 the given value.
109
110 :param value: A value to match, or a specific ``(value, quality)`` entry.
111 :raises ValueError: If the key is not in the collection.
112
113 .. versionchanged:: 0.5
114 Raises ``ValueError`` instead of ``IndexError``.
115 """
116 if (rv := self.find(value)) == -1:
117 raise ValueError(value)
118
119 return rv
120
121 def find(self, value: str | tuple[str, float]) -> int:
122 """Return the index of the first value in the collection that matches
123 the given value, or -1 if not found.
124
125 :param value: A value to match, or a specific ``(value, quality)`` entry.
126 """
127 if isinstance(value, str):
128 for idx, (req_value, _) in enumerate(self):
129 if self._value_matches(value, req_value):
130 return idx
131
132 return -1
133
134 return self._items.index(value)
135
136 def values(self) -> cabc.Iterator[str]:
137 """Iterate over values, omitting the quality part."""
138 for value, _ in self:
139 yield value
140
141 @classmethod
142 def from_header(cls, value: str | None) -> t.Self:
143 """Parse a header value and create an instance of this class.
144
145 .. versionadded:: 3.2
146 """
147 if not value:
148 return cls()
149
150 result = []
151
152 for item in parse_list_header(value):
153 item, options = parse_options_header(item)
154
155 if "q" in options:
156 # pop q, remaining options are reconstructed
157 q_str = options.pop("q").strip(" \t")
158
159 if _q_value_re.fullmatch(q_str) is None:
160 # ignore an invalid q
161 continue
162
163 q = float(q_str)
164 else:
165 q = 1
166
167 if options:
168 # reconstruct the value with remaining options
169 item = dump_options_header(item, options)
170
171 result.append((item, q))
172
173 return cls(result)
174
175 def to_header(self) -> str:
176 """Convert to a header value."""
177 result = []
178
179 for value, q in self:
180 if q != 1:
181 value = f"{value};q={q}"
182
183 result.append(value)
184
185 return ",".join(result)
186
187 def __str__(self) -> str:
188 return self.to_header()
189
190 def _best_single_match(self, app_value: str) -> tuple[str, float] | None:
191 for req_value, q in self:
192 if self._value_matches(app_value, req_value):
193 return req_value, q
194
195 return None
196
197 @t.overload
198 def best_match(self, within: cabc.Iterable[str]) -> str | None: ...
199 @t.overload
200 def best_match(self, within: cabc.Iterable[str], default: str) -> str: ...
201 def best_match(
202 self, within: cabc.Iterable[str], default: str | None = None
203 ) -> str | None:
204 """The best value within a list of potential values based on quality
205 and specificity. If multiple values have the same score, the first is
206 returned.
207
208 :param within: The potential values to pick from.
209 :param default: The value to return if there is no match.
210 """
211 result = default
212 best_quality: float = -1
213 best_specificity: tuple[float, ...] = (-1,)
214
215 for app_value in within:
216 if (match := self._best_single_match(app_value)) is None:
217 continue
218
219 req_value, q = match
220 specificity = self._specificity(req_value)
221
222 if q <= 0 or q < best_quality:
223 continue
224
225 # better quality or same quality but more specific => better match
226 if q > best_quality or specificity > best_specificity:
227 result = app_value
228 best_quality = q
229 best_specificity = specificity
230
231 return result
232
233 @property
234 def best(self) -> str | None:
235 """The best value based on quality and specificity. ``None`` if the
236 collection is empty.
237 """
238 if self:
239 return self[0][0]
240
241 return None
242
243
244_mime_split_re = re.compile(r"/|(?:[ \t]*;[ \t]*)")
245
246
247def _normalize_mime(value: str) -> list[str]:
248 return _mime_split_re.split(value.lower())
249
250
251class MIMEAccept(Accept):
252 """A parsed ``Accept`` header."""
253
254 def _specificity(self, value: str) -> tuple[bool, ...]:
255 return tuple(x != "*" for x in _mime_split_re.split(value))
256
257 def _value_matches(self, app_value: str, req_value: str) -> bool:
258 # from the client, can't match
259 if "/" not in req_value:
260 return False
261
262 req_type, req_sub, *req_params = _normalize_mime(req_value)
263 req_params.sort()
264
265 # */* is only value that starts with *
266 if req_type == "*" and req_sub != "*":
267 return False
268
269 # from the application, tell the developer it's not valid
270 if "/" not in app_value:
271 raise ValueError(f"invalid mimetype {app_value!r}")
272
273 app_type, app_sub, *app_params = _normalize_mime(app_value)
274 app_params.sort()
275
276 if app_type == "*" and app_sub != "*":
277 raise ValueError(f"invalid mimetype {app_value!r}")
278
279 return (
280 (req_type == "*" and req_sub == "*")
281 or (app_type == "*" and app_sub == "*")
282 or (
283 req_type == app_type
284 and (
285 req_sub == "*"
286 or app_sub == "*"
287 or (req_sub == app_sub and req_params == app_params)
288 )
289 )
290 )
291
292 @property
293 def accept_html(self) -> bool:
294 """True if ``text/html`` is accepted.
295
296 .. versionchanged:: 3.2
297 Does not match XHTML or XML.
298 """
299 return "text/html" in self # type: ignore[comparison-overlap]
300
301 @property
302 def accept_xhtml(self) -> bool:
303 """True if ``application/xhtml+xml`` or ``application/xml`` is accepted.
304
305 .. deprecated:: 3.2
306 Will be removed in Werkzeug 4.0. Use ``in`` or ``best_match`` instead.
307 """
308 import warnings
309
310 warnings.warn(
311 "'accept_xhtml' is deprecated and will be removed in Werkzeug 4.0. Use 'in'"
312 " or 'best_match' instead.",
313 DeprecationWarning,
314 stacklevel=2,
315 )
316 return "application/xhtml+xml" in self or "application/xml" in self # type: ignore[comparison-overlap]
317
318 @property
319 def accept_json(self) -> bool:
320 """True if ``application/json`` is accepted."""
321 return "application/json" in self # type: ignore[comparison-overlap]
322
323
324_locale_delim_re = re.compile(r"[_-]")
325
326
327def _normalize_lang(value: str) -> list[str]:
328 """Process a language tag for matching."""
329 return _locale_delim_re.split(value.lower())
330
331
332class LanguageAccept(Accept):
333 """A parsed ``Accept-Language`` header."""
334
335 def _value_matches(self, app_value: str, req_value: str) -> bool:
336 return req_value == "*" or _normalize_lang(app_value) == _normalize_lang(
337 req_value
338 )
339
340 @t.overload
341 def best_match(self, within: cabc.Iterable[str]) -> str | None: ...
342 @t.overload
343 def best_match(self, within: cabc.Iterable[str], default: str) -> str: ...
344 def best_match(
345 self, within: cabc.Iterable[str], default: str | None = None
346 ) -> str | None:
347 """The best value within a list of potential values based on quality
348 and specificity. If multiple values have the same score, the first is
349 returned.
350
351 Given a list of supported values, finds the best match from
352 the list of accepted values.
353
354 If no exact match is found, this will fall back to matching
355 the first subtag (primary language only), first with the
356 ``Accept`` values, then with the ``within`` values. This is not
357 applied to any other language subtags.
358
359 :param within: The potential values to pick from.
360 :param default: The value to return if there is no match.
361 """
362 # Look for an exact match first. If a client accepts "en-US",
363 # "en-US" is a valid match at this point.
364 if (result := super().best_match(within)) is not None:
365 return result
366
367 # Fall back to accepting primary tags. If a client accepts
368 # "en-US", "en" is a valid match at this point. Need to use
369 # re.split to account for 2 or 3 letter codes.
370 fallback = Accept(
371 [(_locale_delim_re.split(value, 1)[0], q) for value, q in self]
372 )
373
374 if (result := fallback.best_match(within)) is not None:
375 return result
376
377 # Fall back to matching primary tags. If the client accepts
378 # "en", "en-US" is a valid match at this point.
379 fallback_within = [_locale_delim_re.split(value, 1)[0] for value in within]
380
381 # Return a value from the original match list. Find the first
382 # original value that starts with the matched primary tag.
383 if (result := super().best_match(fallback_within)) is not None:
384 return next(item for item in within if item.startswith(result))
385
386 return default
387
388
389class _CharsetAccept(Accept):
390 """A parse ``Accept-Charset`` header."""
391
392 def _value_matches(self, app_value: str, req_value: str) -> bool:
393 def _normalize(name: str) -> str:
394 try:
395 return codecs.lookup(name).name
396 except LookupError:
397 return name.lower()
398
399 return req_value == "*" or _normalize(app_value) == _normalize(req_value)
400
401
402if not t.TYPE_CHECKING:
403
404 def __getattr__(name: str) -> t.Any:
405 if name == "CharsetAccept":
406 import warnings
407
408 warnings.warn(
409 "The 'CharsetAccept' class is deprecated and will be removed in"
410 " Werkzeug 4.0. The 'Accept-Charset' header is not sent by"
411 " browsers, and UTF-8 is assumed.",
412 DeprecationWarning,
413 stacklevel=2,
414 )
415 return _CharsetAccept
416
417 raise AttributeError(name)