172 lines
5.2 KiB
Python
172 lines
5.2 KiB
Python
|
from __future__ import annotations
|
||
|
|
||
|
import re
|
||
|
import typing as t
|
||
|
from datetime import datetime
|
||
|
|
||
|
from .._internal import _dt_as_utc
|
||
|
from ..http import generate_etag
|
||
|
from ..http import parse_date
|
||
|
from ..http import parse_etags
|
||
|
from ..http import parse_if_range_header
|
||
|
from ..http import unquote_etag
|
||
|
|
||
|
_etag_re = re.compile(r'([Ww]/)?(?:"(.*?)"|(.*?))(?:\s*,\s*|$)')
|
||
|
|
||
|
|
||
|
def is_resource_modified(
|
||
|
http_range: str | None = None,
|
||
|
http_if_range: str | None = None,
|
||
|
http_if_modified_since: str | None = None,
|
||
|
http_if_none_match: str | None = None,
|
||
|
http_if_match: str | None = None,
|
||
|
etag: str | None = None,
|
||
|
data: bytes | None = None,
|
||
|
last_modified: datetime | str | None = None,
|
||
|
ignore_if_range: bool = True,
|
||
|
) -> bool:
|
||
|
"""Convenience method for conditional requests.
|
||
|
:param http_range: Range HTTP header
|
||
|
:param http_if_range: If-Range HTTP header
|
||
|
:param http_if_modified_since: If-Modified-Since HTTP header
|
||
|
:param http_if_none_match: If-None-Match HTTP header
|
||
|
:param http_if_match: If-Match HTTP header
|
||
|
:param etag: the etag for the response for comparison.
|
||
|
:param data: or alternatively the data of the response to automatically
|
||
|
generate an etag using :func:`generate_etag`.
|
||
|
:param last_modified: an optional date of the last modification.
|
||
|
:param ignore_if_range: If `False`, `If-Range` header will be taken into
|
||
|
account.
|
||
|
:return: `True` if the resource was modified, otherwise `False`.
|
||
|
|
||
|
.. versionadded:: 2.2
|
||
|
"""
|
||
|
if etag is None and data is not None:
|
||
|
etag = generate_etag(data)
|
||
|
elif data is not None:
|
||
|
raise TypeError("both data and etag given")
|
||
|
|
||
|
unmodified = False
|
||
|
if isinstance(last_modified, str):
|
||
|
last_modified = parse_date(last_modified)
|
||
|
|
||
|
# HTTP doesn't use microsecond, remove it to avoid false positive
|
||
|
# comparisons. Mark naive datetimes as UTC.
|
||
|
if last_modified is not None:
|
||
|
last_modified = _dt_as_utc(last_modified.replace(microsecond=0))
|
||
|
|
||
|
if_range = None
|
||
|
if not ignore_if_range and http_range is not None:
|
||
|
# https://tools.ietf.org/html/rfc7233#section-3.2
|
||
|
# A server MUST ignore an If-Range header field received in a request
|
||
|
# that does not contain a Range header field.
|
||
|
if_range = parse_if_range_header(http_if_range)
|
||
|
|
||
|
if if_range is not None and if_range.date is not None:
|
||
|
modified_since: datetime | None = if_range.date
|
||
|
else:
|
||
|
modified_since = parse_date(http_if_modified_since)
|
||
|
|
||
|
if modified_since and last_modified and last_modified <= modified_since:
|
||
|
unmodified = True
|
||
|
|
||
|
if etag:
|
||
|
etag, _ = unquote_etag(etag)
|
||
|
etag = t.cast(str, etag)
|
||
|
|
||
|
if if_range is not None and if_range.etag is not None:
|
||
|
unmodified = parse_etags(if_range.etag).contains(etag)
|
||
|
else:
|
||
|
if_none_match = parse_etags(http_if_none_match)
|
||
|
if if_none_match:
|
||
|
# https://tools.ietf.org/html/rfc7232#section-3.2
|
||
|
# "A recipient MUST use the weak comparison function when comparing
|
||
|
# entity-tags for If-None-Match"
|
||
|
unmodified = if_none_match.contains_weak(etag)
|
||
|
|
||
|
# https://tools.ietf.org/html/rfc7232#section-3.1
|
||
|
# "Origin server MUST use the strong comparison function when
|
||
|
# comparing entity-tags for If-Match"
|
||
|
if_match = parse_etags(http_if_match)
|
||
|
if if_match:
|
||
|
unmodified = not if_match.is_strong(etag)
|
||
|
|
||
|
return not unmodified
|
||
|
|
||
|
|
||
|
_cookie_re = re.compile(
|
||
|
r"""
|
||
|
([^=;]*)
|
||
|
(?:\s*=\s*
|
||
|
(
|
||
|
"(?:[^\\"]|\\.)*"
|
||
|
|
|
||
|
.*?
|
||
|
)
|
||
|
)?
|
||
|
\s*;\s*
|
||
|
""",
|
||
|
flags=re.ASCII | re.VERBOSE,
|
||
|
)
|
||
|
_cookie_unslash_re = re.compile(rb"\\([0-3][0-7]{2}|.)")
|
||
|
|
||
|
|
||
|
def _cookie_unslash_replace(m: t.Match[bytes]) -> bytes:
|
||
|
v = m.group(1)
|
||
|
|
||
|
if len(v) == 1:
|
||
|
return v
|
||
|
|
||
|
return int(v, 8).to_bytes(1, "big")
|
||
|
|
||
|
|
||
|
def parse_cookie(
|
||
|
cookie: str | None = None,
|
||
|
cls: type[ds.MultiDict] | None = None,
|
||
|
) -> ds.MultiDict[str, str]:
|
||
|
"""Parse a cookie from a string.
|
||
|
|
||
|
The same key can be provided multiple times, the values are stored
|
||
|
in-order. The default :class:`MultiDict` will have the first value
|
||
|
first, and all values can be retrieved with
|
||
|
:meth:`MultiDict.getlist`.
|
||
|
|
||
|
:param cookie: The cookie header as a string.
|
||
|
:param cls: A dict-like class to store the parsed cookies in.
|
||
|
Defaults to :class:`MultiDict`.
|
||
|
|
||
|
.. versionchanged:: 3.0
|
||
|
Passing bytes, and the ``charset`` and ``errors`` parameters, were removed.
|
||
|
|
||
|
.. versionadded:: 2.2
|
||
|
"""
|
||
|
if cls is None:
|
||
|
cls = ds.MultiDict
|
||
|
|
||
|
if not cookie:
|
||
|
return cls()
|
||
|
|
||
|
cookie = f"{cookie};"
|
||
|
out = []
|
||
|
|
||
|
for ck, cv in _cookie_re.findall(cookie):
|
||
|
ck = ck.strip()
|
||
|
cv = cv.strip()
|
||
|
|
||
|
if not ck:
|
||
|
continue
|
||
|
|
||
|
if len(cv) >= 2 and cv[0] == cv[-1] == '"':
|
||
|
# Work with bytes here, since a UTF-8 character could be multiple bytes.
|
||
|
cv = _cookie_unslash_re.sub(
|
||
|
_cookie_unslash_replace, cv[1:-1].encode()
|
||
|
).decode(errors="replace")
|
||
|
|
||
|
out.append((ck, cv))
|
||
|
|
||
|
return cls(out)
|
||
|
|
||
|
|
||
|
# circular dependencies
|
||
|
from .. import datastructures as ds
|