-
-
Notifications
You must be signed in to change notification settings - Fork 2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement ETag support #5298
Implement ETag support #5298
Changes from all commits
ef014b1
76b35d5
e7ac04b
25a3cd6
0fc7a59
23aa15b
0762cfc
1ecac1c
699e959
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
FileResponse now supports ETag. |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -9,8 +9,10 @@ | |||||||
Any, | ||||||||
Awaitable, | ||||||||
Callable, | ||||||||
Iterator, | ||||||||
List, | ||||||||
Optional, | ||||||||
Tuple, | ||||||||
Union, | ||||||||
cast, | ||||||||
) | ||||||||
|
@@ -19,6 +21,7 @@ | |||||||
|
||||||||
from . import hdrs | ||||||||
from .abc import AbstractStreamWriter | ||||||||
from .helpers import ETAG_ANY, ETag | ||||||||
from .typedefs import LooseHeaders | ||||||||
from .web_exceptions import ( | ||||||||
HTTPNotModified, | ||||||||
|
@@ -102,6 +105,31 @@ async def _sendfile( | |||||||
await super().write_eof() | ||||||||
return writer | ||||||||
|
||||||||
@staticmethod | ||||||||
def _strong_etag_match(etag_value: str, etags: Tuple[ETag, ...]) -> bool: | ||||||||
if len(etags) == 1 and etags[0].value == ETAG_ANY: | ||||||||
return True | ||||||||
else: | ||||||||
return any(etag.value == etag_value for etag in etags if not etag.is_weak) | ||||||||
Comment on lines
+112
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick, this could be less nested:
Suggested change
|
||||||||
|
||||||||
async def _not_modified( | ||||||||
self, request: "BaseRequest", etag_value: str, last_modified: float | ||||||||
) -> Optional[AbstractStreamWriter]: | ||||||||
self.set_status(HTTPNotModified.status_code) | ||||||||
self._length_check = False | ||||||||
self.etag = etag_value # type: ignore | ||||||||
self.last_modified = last_modified # type: ignore | ||||||||
Comment on lines
+120
to
+121
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It was agreed in other PRs to add specific error codes in brackets. |
||||||||
# Delete any Content-Length headers provided by user. HTTP 304 | ||||||||
# should always have empty response body | ||||||||
return await super().prepare(request) | ||||||||
|
||||||||
async def _precondition_failed( | ||||||||
self, request: "BaseRequest" | ||||||||
) -> Optional[AbstractStreamWriter]: | ||||||||
self.set_status(HTTPPreconditionFailed.status_code) | ||||||||
self.content_length = 0 | ||||||||
return await super().prepare(request) | ||||||||
|
||||||||
async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]: | ||||||||
filepath = self._path | ||||||||
|
||||||||
|
@@ -114,20 +142,35 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter | |||||||
gzip = True | ||||||||
|
||||||||
loop = asyncio.get_event_loop() | ||||||||
st = await loop.run_in_executor(None, filepath.stat) | ||||||||
st: os.stat_result = await loop.run_in_executor(None, filepath.stat) | ||||||||
|
||||||||
modsince = request.if_modified_since | ||||||||
if modsince is not None and st.st_mtime <= modsince.timestamp(): | ||||||||
self.set_status(HTTPNotModified.status_code) | ||||||||
self._length_check = False | ||||||||
# Delete any Content-Length headers provided by user. HTTP 304 | ||||||||
# should always have empty response body | ||||||||
return await super().prepare(request) | ||||||||
etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}" | ||||||||
last_modified = st.st_mtime | ||||||||
|
||||||||
# https://tools.ietf.org/html/rfc7232#section-6 | ||||||||
ifmatch = request.if_match | ||||||||
if ifmatch is not None and not self._strong_etag_match(etag_value, ifmatch): | ||||||||
return await self._precondition_failed(request) | ||||||||
|
||||||||
unmodsince = request.if_unmodified_since | ||||||||
if unmodsince is not None and st.st_mtime > unmodsince.timestamp(): | ||||||||
self.set_status(HTTPPreconditionFailed.status_code) | ||||||||
return await super().prepare(request) | ||||||||
if ( | ||||||||
unmodsince is not None | ||||||||
and ifmatch is None | ||||||||
and st.st_mtime > unmodsince.timestamp() | ||||||||
): | ||||||||
return await self._precondition_failed(request) | ||||||||
|
||||||||
ifnonematch = request.if_none_match | ||||||||
if ifnonematch is not None and self._strong_etag_match(etag_value, ifnonematch): | ||||||||
return await self._not_modified(request, etag_value, last_modified) | ||||||||
|
||||||||
modsince = request.if_modified_since | ||||||||
if ( | ||||||||
modsince is not None | ||||||||
and ifnonematch is None | ||||||||
and st.st_mtime <= modsince.timestamp() | ||||||||
): | ||||||||
return await self._not_modified(request, etag_value, last_modified) | ||||||||
|
||||||||
if hdrs.CONTENT_TYPE not in self.headers: | ||||||||
ct, encoding = mimetypes.guess_type(str(filepath)) | ||||||||
|
@@ -218,6 +261,8 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter | |||||||
self.headers[hdrs.CONTENT_ENCODING] = encoding | ||||||||
if gzip: | ||||||||
self.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING | ||||||||
|
||||||||
self.etag = etag_value # type: ignore[assignment] | ||||||||
self.last_modified = st.st_mtime # type: ignore[assignment] | ||||||||
self.content_length = count | ||||||||
|
||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -34,7 +34,10 @@ | |
from .abc import AbstractStreamWriter | ||
from .helpers import ( | ||
_SENTINEL, | ||
ETAG_ANY, | ||
LIST_QUOTED_ETAG_RE, | ||
ChainMapProxy, | ||
ETag, | ||
HeadersMixin, | ||
is_expected_content_type, | ||
reify, | ||
|
@@ -500,6 +503,52 @@ def if_unmodified_since(self) -> Optional[datetime.datetime]: | |
""" | ||
return self._http_date(self.headers.get(hdrs.IF_UNMODIFIED_SINCE)) | ||
|
||
@staticmethod | ||
def _etag_values(etag_header: str) -> Iterator[ETag]: | ||
"""Extract `ETag` objects from raw header.""" | ||
if etag_header == ETAG_ANY: | ||
yield ETag( | ||
is_weak=False, | ||
value=ETAG_ANY, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is some inconsistency here. If ETag is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not quite. Unfortunatly
Maybe it's better to create separate type
@asvetlov what do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right. |
||
) | ||
else: | ||
for match in LIST_QUOTED_ETAG_RE.finditer(etag_header): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It accepts also garbage like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, you're right. To solve this problem I rejected the Main disadvantage is that memory consumption will probably increase though, but I don't think that really long |
||
is_weak, value, garbage = match.group(2, 3, 4) | ||
# Any symbol captured by 4th group means | ||
# that the following sequence is invalid. | ||
if garbage: | ||
break | ||
|
||
yield ETag( | ||
is_weak=bool(is_weak), | ||
value=value, | ||
) | ||
|
||
@classmethod | ||
def _if_match_or_none_impl( | ||
cls, header_value: Optional[str] | ||
) -> Optional[Tuple[ETag, ...]]: | ||
if not header_value: | ||
return None | ||
|
||
return tuple(cls._etag_values(header_value)) | ||
|
||
@reify | ||
def if_match(self) -> Optional[Tuple[ETag, ...]]: | ||
"""The value of If-Match HTTP header, or None. | ||
|
||
This header is represented as a `tuple` of `ETag` objects. | ||
""" | ||
return self._if_match_or_none_impl(self.headers.get(hdrs.IF_MATCH)) | ||
|
||
@reify | ||
def if_none_match(self) -> Optional[Tuple[ETag, ...]]: | ||
"""The value of If-None-Match HTTP header, or None. | ||
|
||
This header is represented as a `tuple` of `ETag` objects. | ||
""" | ||
return self._if_match_or_none_impl(self.headers.get(hdrs.IF_NONE_MATCH)) | ||
|
||
@reify | ||
def if_range(self) -> Optional[datetime.datetime]: | ||
"""The value of If-Range HTTP header, or None. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -29,6 +29,7 @@ Dict | |
Discord | ||
Django | ||
Dup | ||
ETag | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm almost sure that the spellchecker is case-insensitive. |
||
HTTPException | ||
HttpProcessingError | ||
|
@@ -155,6 +156,7 @@ env | |
environ | ||
eof | ||
epoll | ||
etag | ||
facto | ||
fallback | ||
fallbacks | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are the imports changed here?
I'm assuming the style in this file follows the no implicit imports approach from Mypy:
https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-no-implicit-reexport
Although a little odd that it's not enforced in the Mypy tests if that is the case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Those classes are already presented in the
__all__
variable.I agree about inconsistency with
mypy
style flags.This change was previously discussed, I'll tag you there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a great example of why style/formatting changes should be separated from the functional ones: a tiny disagreement can block a whole lot of legitimate changes, plus this makes it harder to notice important behavior changes.
I'll just leave this here: https://mtlynch.io/code-review-love/.