1from __future__ import annotations
2
3import codecs
4import re
5
6from .structures import ImmutableList
7
8
9class Accept(ImmutableList):
10 """An :class:`Accept` object is just a list subclass for lists of
11 ``(value, quality)`` tuples. It is automatically sorted by specificity
12 and quality.
13
14 All :class:`Accept` objects work similar to a list but provide extra
15 functionality for working with the data. Containment checks are
16 normalized to the rules of that header:
17
18 >>> a = CharsetAccept([('ISO-8859-1', 1), ('utf-8', 0.7)])
19 >>> a.best
20 'ISO-8859-1'
21 >>> 'iso-8859-1' in a
22 True
23 >>> 'UTF8' in a
24 True
25 >>> 'utf7' in a
26 False
27
28 To get the quality for an item you can use normal item lookup:
29
30 >>> print a['utf-8']
31 0.7
32 >>> a['utf7']
33 0
34
35 .. versionchanged:: 0.5
36 :class:`Accept` objects are forced immutable now.
37
38 .. versionchanged:: 1.0.0
39 :class:`Accept` internal values are no longer ordered
40 alphabetically for equal quality tags. Instead the initial
41 order is preserved.
42
43 """
44
45 def __init__(self, values=()):
46 if values is None:
47 list.__init__(self)
48 self.provided = False
49 elif isinstance(values, Accept):
50 self.provided = values.provided
51 list.__init__(self, values)
52 else:
53 self.provided = True
54 values = sorted(
55 values, key=lambda x: (self._specificity(x[0]), x[1]), reverse=True
56 )
57 list.__init__(self, values)
58
59 def _specificity(self, value):
60 """Returns a tuple describing the value's specificity."""
61 return (value != "*",)
62
63 def _value_matches(self, value, item):
64 """Check if a value matches a given accept item."""
65 return item == "*" or item.lower() == value.lower()
66
67 def __getitem__(self, key):
68 """Besides index lookup (getting item n) you can also pass it a string
69 to get the quality for the item. If the item is not in the list, the
70 returned quality is ``0``.
71 """
72 if isinstance(key, str):
73 return self.quality(key)
74 return list.__getitem__(self, key)
75
76 def quality(self, key):
77 """Returns the quality of the key.
78
79 .. versionadded:: 0.6
80 In previous versions you had to use the item-lookup syntax
81 (eg: ``obj[key]`` instead of ``obj.quality(key)``)
82 """
83 for item, quality in self:
84 if self._value_matches(key, item):
85 return quality
86 return 0
87
88 def __contains__(self, value):
89 for item, _quality in self:
90 if self._value_matches(value, item):
91 return True
92 return False
93
94 def __repr__(self):
95 pairs_str = ", ".join(f"({x!r}, {y})" for x, y in self)
96 return f"{type(self).__name__}([{pairs_str}])"
97
98 def index(self, key):
99 """Get the position of an entry or raise :exc:`ValueError`.
100
101 :param key: The key to be looked up.
102
103 .. versionchanged:: 0.5
104 This used to raise :exc:`IndexError`, which was inconsistent
105 with the list API.
106 """
107 if isinstance(key, str):
108 for idx, (item, _quality) in enumerate(self):
109 if self._value_matches(key, item):
110 return idx
111 raise ValueError(key)
112 return list.index(self, key)
113
114 def find(self, key):
115 """Get the position of an entry or return -1.
116
117 :param key: The key to be looked up.
118 """
119 try:
120 return self.index(key)
121 except ValueError:
122 return -1
123
124 def values(self):
125 """Iterate over all values."""
126 for item in self:
127 yield item[0]
128
129 def to_header(self):
130 """Convert the header set into an HTTP header string."""
131 result = []
132 for value, quality in self:
133 if quality != 1:
134 value = f"{value};q={quality}"
135 result.append(value)
136 return ",".join(result)
137
138 def __str__(self):
139 return self.to_header()
140
141 def _best_single_match(self, match):
142 for client_item, quality in self:
143 if self._value_matches(match, client_item):
144 # self is sorted by specificity descending, we can exit
145 return client_item, quality
146 return None
147
148 def best_match(self, matches, default=None):
149 """Returns the best match from a list of possible matches based
150 on the specificity and quality of the client. If two items have the
151 same quality and specificity, the one is returned that comes first.
152
153 :param matches: a list of matches to check for
154 :param default: the value that is returned if none match
155 """
156 result = default
157 best_quality = -1
158 best_specificity = (-1,)
159 for server_item in matches:
160 match = self._best_single_match(server_item)
161 if not match:
162 continue
163 client_item, quality = match
164 specificity = self._specificity(client_item)
165 if quality <= 0 or quality < best_quality:
166 continue
167 # better quality or same quality but more specific => better match
168 if quality > best_quality or specificity > best_specificity:
169 result = server_item
170 best_quality = quality
171 best_specificity = specificity
172 return result
173
174 @property
175 def best(self):
176 """The best match as value."""
177 if self:
178 return self[0][0]
179
180
181_mime_split_re = re.compile(r"/|(?:\s*;\s*)")
182
183
184def _normalize_mime(value):
185 return _mime_split_re.split(value.lower())
186
187
188class MIMEAccept(Accept):
189 """Like :class:`Accept` but with special methods and behavior for
190 mimetypes.
191 """
192
193 def _specificity(self, value):
194 return tuple(x != "*" for x in _mime_split_re.split(value))
195
196 def _value_matches(self, value, item):
197 # item comes from the client, can't match if it's invalid.
198 if "/" not in item:
199 return False
200
201 # value comes from the application, tell the developer when it
202 # doesn't look valid.
203 if "/" not in value:
204 raise ValueError(f"invalid mimetype {value!r}")
205
206 # Split the match value into type, subtype, and a sorted list of parameters.
207 normalized_value = _normalize_mime(value)
208 value_type, value_subtype = normalized_value[:2]
209 value_params = sorted(normalized_value[2:])
210
211 # "*/*" is the only valid value that can start with "*".
212 if value_type == "*" and value_subtype != "*":
213 raise ValueError(f"invalid mimetype {value!r}")
214
215 # Split the accept item into type, subtype, and parameters.
216 normalized_item = _normalize_mime(item)
217 item_type, item_subtype = normalized_item[:2]
218 item_params = sorted(normalized_item[2:])
219
220 # "*/not-*" from the client is invalid, can't match.
221 if item_type == "*" and item_subtype != "*":
222 return False
223
224 return (
225 (item_type == "*" and item_subtype == "*")
226 or (value_type == "*" and value_subtype == "*")
227 ) or (
228 item_type == value_type
229 and (
230 item_subtype == "*"
231 or value_subtype == "*"
232 or (item_subtype == value_subtype and item_params == value_params)
233 )
234 )
235
236 @property
237 def accept_html(self):
238 """True if this object accepts HTML."""
239 return (
240 "text/html" in self or "application/xhtml+xml" in self or self.accept_xhtml
241 )
242
243 @property
244 def accept_xhtml(self):
245 """True if this object accepts XHTML."""
246 return "application/xhtml+xml" in self or "application/xml" in self
247
248 @property
249 def accept_json(self):
250 """True if this object accepts JSON."""
251 return "application/json" in self
252
253
254_locale_delim_re = re.compile(r"[_-]")
255
256
257def _normalize_lang(value):
258 """Process a language tag for matching."""
259 return _locale_delim_re.split(value.lower())
260
261
262class LanguageAccept(Accept):
263 """Like :class:`Accept` but with normalization for language tags."""
264
265 def _value_matches(self, value, item):
266 return item == "*" or _normalize_lang(value) == _normalize_lang(item)
267
268 def best_match(self, matches, default=None):
269 """Given a list of supported values, finds the best match from
270 the list of accepted values.
271
272 Language tags are normalized for the purpose of matching, but
273 are returned unchanged.
274
275 If no exact match is found, this will fall back to matching
276 the first subtag (primary language only), first with the
277 accepted values then with the match values. This partial is not
278 applied to any other language subtags.
279
280 The default is returned if no exact or fallback match is found.
281
282 :param matches: A list of supported languages to find a match.
283 :param default: The value that is returned if none match.
284 """
285 # Look for an exact match first. If a client accepts "en-US",
286 # "en-US" is a valid match at this point.
287 result = super().best_match(matches)
288
289 if result is not None:
290 return result
291
292 # Fall back to accepting primary tags. If a client accepts
293 # "en-US", "en" is a valid match at this point. Need to use
294 # re.split to account for 2 or 3 letter codes.
295 fallback = Accept(
296 [(_locale_delim_re.split(item[0], 1)[0], item[1]) for item in self]
297 )
298 result = fallback.best_match(matches)
299
300 if result is not None:
301 return result
302
303 # Fall back to matching primary tags. If the client accepts
304 # "en", "en-US" is a valid match at this point.
305 fallback_matches = [_locale_delim_re.split(item, 1)[0] for item in matches]
306 result = super().best_match(fallback_matches)
307
308 # Return a value from the original match list. Find the first
309 # original value that starts with the matched primary tag.
310 if result is not None:
311 return next(item for item in matches if item.startswith(result))
312
313 return default
314
315
316class CharsetAccept(Accept):
317 """Like :class:`Accept` but with normalization for charsets."""
318
319 def _value_matches(self, value, item):
320 def _normalize(name):
321 try:
322 return codecs.lookup(name).name
323 except LookupError:
324 return name.lower()
325
326 return item == "*" or _normalize(value) == _normalize(item)