Skip to content

Commit 903726b

Browse files
committed
Add 'last_modified' and 'if_modified_since' properties
1 parent 748860b commit 903726b

5 files changed

+125
-8
lines changed

aiohttp/web_reqrep.py

+51-1
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44
import binascii
55
import cgi
66
import collections
7+
import datetime
78
import http.cookies
89
import io
910
import json
11+
import math
12+
import time
1013
import warnings
1114

12-
from urllib.parse import urlsplit, parse_qsl, unquote
15+
from email.utils import parsedate
1316
from types import MappingProxyType
17+
from urllib.parse import urlsplit, parse_qsl, unquote
1418

1519
from . import hdrs
1620
from .helpers import reify
@@ -65,6 +69,33 @@ def content_length(self, _CONTENT_LENGTH=hdrs.CONTENT_LENGTH):
6569
else:
6670
return int(l)
6771

72+
@property
73+
def if_modified_since(self, _IF_MODIFIED_SINCE=hdrs.IF_MODIFIED_SINCE):
74+
"""The value of If-Modified-Since HTTP header, or None.
75+
76+
This header is represented as a `datetime` object.
77+
"""
78+
httpdate = self.headers.get(_IF_MODIFIED_SINCE)
79+
if httpdate is not None:
80+
timetuple = parsedate(httpdate)
81+
if timetuple is not None:
82+
return datetime.datetime(*timetuple[:6],
83+
tzinfo=datetime.timezone.utc)
84+
return None
85+
86+
@property
87+
def last_modified(self, _LAST_MODIFIED=hdrs.LAST_MODIFIED):
88+
"""The value of Last-Modified HTTP header, or None.
89+
90+
This header is represented as a `datetime` object.
91+
"""
92+
httpdate = self.headers.get(_LAST_MODIFIED)
93+
if httpdate is not None:
94+
timetuple = parsedate(httpdate)
95+
if timetuple is not None:
96+
return datetime.datetime(*timetuple[:6],
97+
tzinfo=datetime.timezone.utc)
98+
return None
6899

69100
FileField = collections.namedtuple('Field', 'name filename file content_type')
70101

@@ -513,6 +544,25 @@ def charset(self, value):
513544
self._content_dict['charset'] = str(value).lower()
514545
self._generate_content_type_header()
515546

547+
@property
548+
def last_modified(self):
549+
# Just a placeholder for adding setter
550+
return super().last_modified
551+
552+
@last_modified.setter
553+
def last_modified(self, value):
554+
if value is None:
555+
if hdrs.LAST_MODIFIED in self.headers:
556+
del self.headers[hdrs.LAST_MODIFIED]
557+
elif isinstance(value, (int, float)):
558+
self.headers[hdrs.LAST_MODIFIED] = time.strftime(
559+
"%a, %d %b %Y %H:%M:%S GMT", time.gmtime(math.ceil(value)))
560+
elif isinstance(value, datetime.datetime):
561+
self.headers[hdrs.LAST_MODIFIED] = time.strftime(
562+
"%a, %d %b %Y %H:%M:%S GMT", value.utctimetuple())
563+
elif isinstance(value, str):
564+
self.headers[hdrs.LAST_MODIFIED] = value
565+
516566
def _generate_content_type_header(self, CONTENT_TYPE=hdrs.CONTENT_TYPE):
517567
params = '; '.join("%s=%s" % i for i in self._content_dict.items())
518568
if params:

aiohttp/web_urldispatcher.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import inspect
1212

1313
from urllib.parse import urlencode
14-
from wsgiref.handlers import format_date_time
1514

1615
from . import hdrs
1716
from .abc import AbstractRouter, AbstractMatchInfo
@@ -178,9 +177,9 @@ def handle(self, request):
178177
raise HTTPNotFound()
179178

180179
st = os.stat(filepath)
181-
mtime = format_date_time(st.st_mtime)
182180

183-
if request.headers.get(hdrs.IF_MODIFIED_SINCE) == mtime:
181+
modsince = request.if_modified_since
182+
if modsince is not None and st.st_mtime <= modsince.timestamp():
184183
raise HTTPNotModified()
185184

186185
ct, encoding = mimetypes.guess_type(filepath)
@@ -191,7 +190,7 @@ def handle(self, request):
191190
resp.content_type = ct
192191
if encoding:
193192
resp.headers[hdrs.CONTENT_ENCODING] = encoding
194-
resp.headers[hdrs.LAST_MODIFIED] = mtime
193+
resp.last_modified = st.st_mtime
195194

196195
file_size = st.st_size
197196
single_chunk = file_size < self._chunk_size

docs/web_reference.rst

+18
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,15 @@ first positional parameter.
198198

199199
Returns :class:`int` or ``None`` if *Content-Length* is absent.
200200

201+
.. attribute:: if_modified_since
202+
203+
Read-only property that returns the date specified in the
204+
*If-Modified-Since* header.
205+
206+
Returns :class:`datetime.datetime` or ``None`` if
207+
*If-Modified-Since* header is absent or is not a valid
208+
HTTP date.
209+
201210
.. coroutinemethod:: read()
202211

203212
Read request body, returns :class:`bytes` object with body content.
@@ -503,6 +512,15 @@ StreamResponse
503512

504513
The value converted to lower-case on attribute assigning.
505514

515+
.. attribute:: last_modified
516+
517+
*Last-Modified* header for outgoing response.
518+
519+
This property accepts raw :class:`str` values,
520+
:class:`datetime.datetime` objects, Unix timestamps specified
521+
as an :class:`int` or a :class:`float` object, and the
522+
value ``None`` to unset the header.
523+
506524
.. method:: start(request)
507525

508526
:param aiohttp.web.Request request: HTTP request object, that the

tests/test_web_functional.py

+16-3
Original file line numberDiff line numberDiff line change
@@ -410,14 +410,14 @@ def go(dirname, filename):
410410
lastmod = 'Mon, 1 Jan 1990 01:01:01 GMT'
411411
resp = yield from request('GET', url, loop=self.loop,
412412
headers={'If-Modified-Since': lastmod})
413-
self.assertIn(resp.status, (200, 304))
413+
self.assertEqual(200, resp.status)
414414
resp.close()
415415

416416
here = os.path.dirname(__file__)
417417
filename = 'data.unknown_mime_type'
418418
self.loop.run_until_complete(go(here, filename))
419419

420-
def test_static_file_not_modified_since(self):
420+
def test_static_file_if_modified_since_future_date(self):
421421

422422
@asyncio.coroutine
423423
def go(dirname, filename):
@@ -429,9 +429,22 @@ def go(dirname, filename):
429429
lastmod = 'Fri, 31 Dec 9999 23:59:59 GMT'
430430
resp = yield from request('GET', url, loop=self.loop,
431431
headers={'If-Modified-Since': lastmod})
432-
self.assertEqual(200, resp.status)
432+
self.assertEqual(304, resp.status)
433433
resp.close()
434434

435+
here = os.path.dirname(__file__)
436+
filename = 'data.unknown_mime_type'
437+
self.loop.run_until_complete(go(here, filename))
438+
439+
def test_static_file_if_modified_since_invalid_date(self):
440+
441+
@asyncio.coroutine
442+
def go(dirname, filename):
443+
app, _, url = yield from self.create_server(
444+
'GET', '/static/' + filename
445+
)
446+
app.router.add_static('/static', dirname)
447+
435448
lastmod = 'not a valid HTTP-date'
436449
resp = yield from request('GET', url, loop=self.loop,
437450
headers={'If-Modified-Since': lastmod})

tests/test_web_response.py

+37
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import datetime
23
import unittest
34
from unittest import mock
45
from aiohttp import hdrs
@@ -103,6 +104,42 @@ def test_charset_without_content_type(self):
103104
with self.assertRaises(RuntimeError):
104105
resp.charset = 'koi8-r'
105106

107+
def test_last_modified_initial(self):
108+
resp = StreamResponse()
109+
self.assertIsNone(resp.last_modified)
110+
111+
def test_last_modified_string(self):
112+
resp = StreamResponse()
113+
114+
dt = datetime.datetime(1990, 1, 2, 3, 4, 5, 0, datetime.timezone.utc)
115+
resp.last_modified = 'Mon, 2 Jan 1990 03:04:05 GMT'
116+
self.assertEqual(resp.last_modified, dt)
117+
118+
def test_last_modified_timestamp(self):
119+
resp = StreamResponse()
120+
121+
dt = datetime.datetime(1970, 1, 1, 0, 0, 0, 0, datetime.timezone.utc)
122+
123+
resp.last_modified = 0
124+
self.assertEqual(resp.last_modified, dt)
125+
126+
resp.last_modified = 0.0
127+
self.assertEqual(resp.last_modified, dt)
128+
129+
def test_last_modified_datetime(self):
130+
resp = StreamResponse()
131+
132+
dt = datetime.datetime(2001, 2, 3, 4, 5, 6, 0, datetime.timezone.utc)
133+
resp.last_modified = dt
134+
self.assertEqual(resp.last_modified, dt)
135+
136+
def test_last_modified_reset(self):
137+
resp = StreamResponse()
138+
139+
resp.last_modified = 0
140+
resp.last_modified = None
141+
self.assertEqual(resp.last_modified, None)
142+
106143
@mock.patch('aiohttp.web_reqrep.ResponseImpl')
107144
def test_start(self, ResponseImpl):
108145
req = self.make_request('GET', '/')

0 commit comments

Comments
 (0)