Skip to content

Commit 351adda

Browse files
PierreQuentelserhiy-storchaka
authored andcommitted
bpo-29654 : Support If-Modified-Since HTTP header (browser cache) (#298)
Return 304 response if file was not modified.
1 parent efbd4ea commit 351adda

File tree

5 files changed

+112
-9
lines changed

5 files changed

+112
-9
lines changed

Doc/library/http.server.rst

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,11 +343,13 @@ of which this module provides three different variants:
343343
:func:`os.listdir` to scan the directory, and returns a ``404`` error
344344
response if the :func:`~os.listdir` fails.
345345

346-
If the request was mapped to a file, it is opened and the contents are
347-
returned. Any :exc:`OSError` exception in opening the requested file is
348-
mapped to a ``404``, ``'File not found'`` error. Otherwise, the content
346+
If the request was mapped to a file, it is opened. Any :exc:`OSError`
347+
exception in opening the requested file is mapped to a ``404``,
348+
``'File not found'`` error. If there was a ``'If-Modified-Since'``
349+
header in the request, and the file was not modified after this time,
350+
a ``304``, ``'Not Modified'`` response is sent. Otherwise, the content
349351
type is guessed by calling the :meth:`guess_type` method, which in turn
350-
uses the *extensions_map* variable.
352+
uses the *extensions_map* variable, and the file contents are returned.
351353

352354
A ``'Content-type:'`` header with the guessed content type is output,
353355
followed by a ``'Content-Length:'`` header with the file's size and a
@@ -360,6 +362,8 @@ of which this module provides three different variants:
360362
For example usage, see the implementation of the :func:`test` function
361363
invocation in the :mod:`http.server` module.
362364

365+
.. versionchanged:: 3.7
366+
Support of the ``'If-Modified-Since'`` header.
363367

364368
The :class:`SimpleHTTPRequestHandler` class can be used in the following
365369
manner in order to create a very basic webserver serving files relative to

Doc/whatsnew/3.7.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ New Modules
9595
Improved Modules
9696
================
9797

98+
http.server
99+
-----------
100+
101+
:class:`~http.server.SimpleHTTPRequestHandler` supports the HTTP
102+
If-Modified-Since header. The server returns the 304 response status if the
103+
target file was not modified after the time specified in the header.
104+
(Contributed by Pierre Quentel in :issue:`29654`.)
105+
98106
locale
99107
------
100108

Lib/http/server.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@
8787
"SimpleHTTPRequestHandler", "CGIHTTPRequestHandler",
8888
]
8989

90+
import argparse
91+
import copy
92+
import datetime
9093
import email.utils
9194
import html
9295
import http.client
@@ -101,8 +104,6 @@
101104
import sys
102105
import time
103106
import urllib.parse
104-
import copy
105-
import argparse
106107

107108
from http import HTTPStatus
108109

@@ -686,12 +687,42 @@ def send_head(self):
686687
except OSError:
687688
self.send_error(HTTPStatus.NOT_FOUND, "File not found")
688689
return None
690+
689691
try:
692+
fs = os.fstat(f.fileno())
693+
# Use browser cache if possible
694+
if ("If-Modified-Since" in self.headers
695+
and "If-None-Match" not in self.headers):
696+
# compare If-Modified-Since and time of last file modification
697+
try:
698+
ims = email.utils.parsedate_to_datetime(
699+
self.headers["If-Modified-Since"])
700+
except (TypeError, IndexError, OverflowError, ValueError):
701+
# ignore ill-formed values
702+
pass
703+
else:
704+
if ims.tzinfo is None:
705+
# obsolete format with no timezone, cf.
706+
# https://tools.ietf.org/html/rfc7231#section-7.1.1.1
707+
ims = ims.replace(tzinfo=datetime.timezone.utc)
708+
if ims.tzinfo is datetime.timezone.utc:
709+
# compare to UTC datetime of last modification
710+
last_modif = datetime.datetime.fromtimestamp(
711+
fs.st_mtime, datetime.timezone.utc)
712+
# remove microseconds, like in If-Modified-Since
713+
last_modif = last_modif.replace(microsecond=0)
714+
715+
if last_modif <= ims:
716+
self.send_response(HTTPStatus.NOT_MODIFIED)
717+
self.end_headers()
718+
f.close()
719+
return None
720+
690721
self.send_response(HTTPStatus.OK)
691722
self.send_header("Content-type", ctype)
692-
fs = os.fstat(f.fileno())
693723
self.send_header("Content-Length", str(fs[6]))
694-
self.send_header("Last-Modified", self.date_time_string(fs.st_mtime))
724+
self.send_header("Last-Modified",
725+
self.date_time_string(fs.st_mtime))
695726
self.end_headers()
696727
return f
697728
except:

Lib/test/test_httpservers.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
import base64
1515
import ntpath
1616
import shutil
17-
import urllib.parse
17+
import email.message
18+
import email.utils
1819
import html
1920
import http.client
21+
import urllib.parse
2022
import tempfile
2123
import time
24+
import datetime
2225
from io import BytesIO
2326

2427
import unittest
@@ -333,6 +336,13 @@ def setUp(self):
333336
self.base_url = '/' + self.tempdir_name
334337
with open(os.path.join(self.tempdir, 'test'), 'wb') as temp:
335338
temp.write(self.data)
339+
mtime = os.fstat(temp.fileno()).st_mtime
340+
# compute last modification datetime for browser cache tests
341+
last_modif = datetime.datetime.fromtimestamp(mtime,
342+
datetime.timezone.utc)
343+
self.last_modif_datetime = last_modif.replace(microsecond=0)
344+
self.last_modif_header = email.utils.formatdate(
345+
last_modif.timestamp(), usegmt=True)
336346

337347
def tearDown(self):
338348
try:
@@ -444,6 +454,44 @@ def test_head(self):
444454
self.assertEqual(response.getheader('content-type'),
445455
'application/octet-stream')
446456

457+
def test_browser_cache(self):
458+
"""Check that when a request to /test is sent with the request header
459+
If-Modified-Since set to date of last modification, the server returns
460+
status code 304, not 200
461+
"""
462+
headers = email.message.Message()
463+
headers['If-Modified-Since'] = self.last_modif_header
464+
response = self.request(self.base_url + '/test', headers=headers)
465+
self.check_status_and_reason(response, HTTPStatus.NOT_MODIFIED)
466+
467+
# one hour after last modification : must return 304
468+
new_dt = self.last_modif_datetime + datetime.timedelta(hours=1)
469+
headers = email.message.Message()
470+
headers['If-Modified-Since'] = email.utils.format_datetime(new_dt,
471+
usegmt=True)
472+
response = self.request(self.base_url + '/test', headers=headers)
473+
self.check_status_and_reason(response, HTTPStatus.NOT_MODIFIED)
474+
475+
def test_browser_cache_file_changed(self):
476+
# with If-Modified-Since earlier than Last-Modified, must return 200
477+
dt = self.last_modif_datetime
478+
# build datetime object : 365 days before last modification
479+
old_dt = dt - datetime.timedelta(days=365)
480+
headers = email.message.Message()
481+
headers['If-Modified-Since'] = email.utils.format_datetime(old_dt,
482+
usegmt=True)
483+
response = self.request(self.base_url + '/test', headers=headers)
484+
self.check_status_and_reason(response, HTTPStatus.OK)
485+
486+
def test_browser_cache_with_If_None_Match_header(self):
487+
# if If-None-Match header is present, ignore If-Modified-Since
488+
489+
headers = email.message.Message()
490+
headers['If-Modified-Since'] = self.last_modif_header
491+
headers['If-None-Match'] = "*"
492+
response = self.request(self.base_url + '/test', headers=headers)
493+
self.check_status_and_reason(response, HTTPStatus.OK)
494+
447495
def test_invalid_requests(self):
448496
response = self.request('/', method='FOO')
449497
self.check_status_and_reason(response, HTTPStatus.NOT_IMPLEMENTED)
@@ -453,6 +501,15 @@ def test_invalid_requests(self):
453501
response = self.request('/', method='GETs')
454502
self.check_status_and_reason(response, HTTPStatus.NOT_IMPLEMENTED)
455503

504+
def test_last_modified(self):
505+
"""Checks that the datetime returned in Last-Modified response header
506+
is the actual datetime of last modification, rounded to the second
507+
"""
508+
response = self.request(self.base_url + '/test')
509+
self.check_status_and_reason(response, HTTPStatus.OK, data=self.data)
510+
last_modif_header = response.headers['Last-modified']
511+
self.assertEqual(last_modif_header, self.last_modif_header)
512+
456513
def test_path_without_leading_slash(self):
457514
response = self.request(self.tempdir_name + '/test')
458515
self.check_status_and_reason(response, HTTPStatus.OK, data=self.data)

Misc/NEWS

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,9 @@ Extension Modules
303303
Library
304304
-------
305305

306+
- bpo-29654: Support If-Modified-Since HTTP header (browser cache). Patch
307+
by Pierre Quentel.
308+
306309
- bpo-29931: Fixed comparison check for ipaddress.ip_interface objects.
307310
Patch by Sanjay Sundaresan.
308311

0 commit comments

Comments
 (0)