1from __future__ import annotations
2
3import re
4import typing as t
5from datetime import datetime
6
7from .._internal import _dt_as_utc
8from ..http import generate_etag
9from ..http import parse_date
10from ..http import parse_etags
11from ..http import parse_if_range_header
12from ..http import unquote_etag
13
14_etag_re = re.compile(r'([Ww]/)?(?:"(.*?)"|(.*?))(?:\s*,\s*|$)')
15
16
17def is_resource_modified(
18 http_range: str | None = None,
19 http_if_range: str | None = None,
20 http_if_modified_since: str | None = None,
21 http_if_none_match: str | None = None,
22 http_if_match: str | None = None,
23 etag: str | None = None,
24 data: bytes | None = None,
25 last_modified: datetime | str | None = None,
26 ignore_if_range: bool = True,
27) -> bool:
28 """Convenience method for conditional requests.
29 :param http_range: Range HTTP header
30 :param http_if_range: If-Range HTTP header
31 :param http_if_modified_since: If-Modified-Since HTTP header
32 :param http_if_none_match: If-None-Match HTTP header
33 :param http_if_match: If-Match HTTP header
34 :param etag: the etag for the response for comparison.
35 :param data: or alternatively the data of the response to automatically
36 generate an etag using :func:`generate_etag`.
37 :param last_modified: an optional date of the last modification.
38 :param ignore_if_range: If `False`, `If-Range` header will be taken into
39 account.
40 :return: `True` if the resource was modified, otherwise `False`.
41
42 .. versionadded:: 2.2
43 """
44 if etag is None and data is not None:
45 etag = generate_etag(data)
46 elif data is not None:
47 raise TypeError("both data and etag given")
48
49 unmodified = False
50 if isinstance(last_modified, str):
51 last_modified = parse_date(last_modified)
52
53 # HTTP doesn't use microsecond, remove it to avoid false positive
54 # comparisons. Mark naive datetimes as UTC.
55 if last_modified is not None:
56 last_modified = _dt_as_utc(last_modified.replace(microsecond=0))
57
58 if_range = None
59 if not ignore_if_range and http_range is not None:
60 # https://tools.ietf.org/html/rfc7233#section-3.2
61 # A server MUST ignore an If-Range header field received in a request
62 # that does not contain a Range header field.
63 if_range = parse_if_range_header(http_if_range)
64
65 if if_range is not None and if_range.date is not None:
66 modified_since: datetime | None = if_range.date
67 else:
68 modified_since = parse_date(http_if_modified_since)
69
70 if modified_since and last_modified and last_modified <= modified_since:
71 unmodified = True
72
73 if etag:
74 etag, _ = unquote_etag(etag)
75
76 if if_range is not None and if_range.etag is not None:
77 unmodified = parse_etags(if_range.etag).contains(etag)
78 else:
79 if_none_match = parse_etags(http_if_none_match)
80 if if_none_match:
81 # https://tools.ietf.org/html/rfc7232#section-3.2
82 # "A recipient MUST use the weak comparison function when comparing
83 # entity-tags for If-None-Match"
84 unmodified = if_none_match.contains_weak(etag)
85
86 # https://tools.ietf.org/html/rfc7232#section-3.1
87 # "Origin server MUST use the strong comparison function when
88 # comparing entity-tags for If-Match"
89 if_match = parse_etags(http_if_match)
90 if if_match:
91 unmodified = not if_match.is_strong(etag)
92
93 return not unmodified
94
95
96_cookie_re = re.compile(
97 r"""
98 ([^=;]*)
99 (?:\s*=\s*
100 (
101 "(?:[^\\"]|\\.)*"
102 |
103 .*?
104 )
105 )?
106 \s*;\s*
107 """,
108 flags=re.ASCII | re.VERBOSE,
109)
110_cookie_unslash_re = re.compile(rb"\\([0-3][0-7]{2}|.)")
111
112
113def _cookie_unslash_replace(m: t.Match[bytes]) -> bytes:
114 v = m.group(1)
115
116 if len(v) == 1:
117 return v
118
119 return int(v, 8).to_bytes(1, "big")
120
121
122def parse_cookie(
123 cookie: str | None = None,
124 cls: type[ds.MultiDict[str, str]] | None = None,
125) -> ds.MultiDict[str, str]:
126 """Parse a cookie from a string.
127
128 The same key can be provided multiple times, the values are stored
129 in-order. The default :class:`MultiDict` will have the first value
130 first, and all values can be retrieved with
131 :meth:`MultiDict.getlist`.
132
133 :param cookie: The cookie header as a string.
134 :param cls: A dict-like class to store the parsed cookies in.
135 Defaults to :class:`MultiDict`.
136
137 .. versionchanged:: 3.0
138 Passing bytes, and the ``charset`` and ``errors`` parameters, were removed.
139
140 .. versionadded:: 2.2
141 """
142 if cls is None:
143 cls = t.cast("type[ds.MultiDict[str, str]]", ds.MultiDict)
144
145 if not cookie:
146 return cls()
147
148 cookie = f"{cookie};"
149 out = []
150
151 for ck, cv in _cookie_re.findall(cookie):
152 ck = ck.strip()
153 cv = cv.strip()
154
155 if not ck:
156 continue
157
158 if len(cv) >= 2 and cv[0] == cv[-1] == '"':
159 # Work with bytes here, since a UTF-8 character could be multiple bytes.
160 cv = _cookie_unslash_re.sub(
161 _cookie_unslash_replace, cv[1:-1].encode()
162 ).decode(errors="replace")
163
164 out.append((ck, cv))
165
166 return cls(out)
167
168
169# circular dependencies
170from .. import datastructures as ds