Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/werkzeug/datastructures/accept.py: 36%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

185 statements  

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)