]> granicus.if.org Git - python/commitdiff
bpo-29654 : Support If-Modified-Since HTTP header (browser cache) (#298)
authorPierre Quentel <pierre.quentel@gmail.com>
Sun, 2 Apr 2017 10:26:12 +0000 (12:26 +0200)
committerSerhiy Storchaka <storchaka@gmail.com>
Sun, 2 Apr 2017 10:26:12 +0000 (13:26 +0300)
Return 304 response if file was not modified.

Doc/library/http.server.rst
Doc/whatsnew/3.7.rst
Lib/http/server.py
Lib/test/test_httpservers.py
Misc/NEWS

index fb5c1df611d8f29664a39657346ecfb1aa83a98b..ee1c37c631941b800dee3694dbe3e0e069e90ad1 100644 (file)
@@ -343,11 +343,13 @@ of which this module provides three different variants:
       :func:`os.listdir` to scan the directory, and returns a ``404`` error
       response if the :func:`~os.listdir` fails.
 
-      If the request was mapped to a file, it is opened and the contents are
-      returned.  Any :exc:`OSError` exception in opening the requested file is
-      mapped to a ``404``, ``'File not found'`` error. Otherwise, the content
+      If the request was mapped to a file, it is opened. Any :exc:`OSError`
+      exception in opening the requested file is mapped to a ``404``,
+      ``'File not found'`` error. If there was a ``'If-Modified-Since'``
+      header in the request, and the file was not modified after this time,
+      a ``304``, ``'Not Modified'`` response is sent. Otherwise, the content
       type is guessed by calling the :meth:`guess_type` method, which in turn
-      uses the *extensions_map* variable.
+      uses the *extensions_map* variable, and the file contents are returned.
 
       A ``'Content-type:'`` header with the guessed content type is output,
       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:
       For example usage, see the implementation of the :func:`test` function
       invocation in the :mod:`http.server` module.
 
+      .. versionchanged:: 3.7
+         Support of the ``'If-Modified-Since'`` header.
 
 The :class:`SimpleHTTPRequestHandler` class can be used in the following
 manner in order to create a very basic webserver serving files relative to
index 19e04bb19efc5e9ea3648c6e9991ccf065cf6cdb..12f65ff9a594235d9a99b09103c996bf6fe78177 100644 (file)
@@ -95,6 +95,14 @@ New Modules
 Improved Modules
 ================
 
+http.server
+-----------
+
+:class:`~http.server.SimpleHTTPRequestHandler` supports the HTTP
+If-Modified-Since header. The server returns the 304 response status if the
+target file was not modified after the time specified in the header.
+(Contributed by Pierre Quentel in :issue:`29654`.)
+
 locale
 ------
 
index 61ddecc7efe4e34cb030c3534ccb7d25b8479a93..429490b73a88b7c1440334a2246fd046d23a190a 100644 (file)
@@ -87,6 +87,9 @@ __all__ = [
     "SimpleHTTPRequestHandler", "CGIHTTPRequestHandler",
 ]
 
+import argparse
+import copy
+import datetime
 import email.utils
 import html
 import http.client
@@ -101,8 +104,6 @@ import socketserver
 import sys
 import time
 import urllib.parse
-import copy
-import argparse
 
 from http import HTTPStatus
 
@@ -686,12 +687,42 @@ class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
         except OSError:
             self.send_error(HTTPStatus.NOT_FOUND, "File not found")
             return None
+
         try:
+            fs = os.fstat(f.fileno())
+            # Use browser cache if possible
+            if ("If-Modified-Since" in self.headers
+                    and "If-None-Match" not in self.headers):
+                # compare If-Modified-Since and time of last file modification
+                try:
+                    ims = email.utils.parsedate_to_datetime(
+                        self.headers["If-Modified-Since"])
+                except (TypeError, IndexError, OverflowError, ValueError):
+                    # ignore ill-formed values
+                    pass
+                else:
+                    if ims.tzinfo is None:
+                        # obsolete format with no timezone, cf.
+                        # https://tools.ietf.org/html/rfc7231#section-7.1.1.1
+                        ims = ims.replace(tzinfo=datetime.timezone.utc)
+                    if ims.tzinfo is datetime.timezone.utc:
+                        # compare to UTC datetime of last modification
+                        last_modif = datetime.datetime.fromtimestamp(
+                            fs.st_mtime, datetime.timezone.utc)
+                        # remove microseconds, like in If-Modified-Since
+                        last_modif = last_modif.replace(microsecond=0)
+                        
+                        if last_modif <= ims:
+                            self.send_response(HTTPStatus.NOT_MODIFIED)
+                            self.end_headers()
+                            f.close()
+                            return None
+
             self.send_response(HTTPStatus.OK)
             self.send_header("Content-type", ctype)
-            fs = os.fstat(f.fileno())
             self.send_header("Content-Length", str(fs[6]))
-            self.send_header("Last-Modified", self.date_time_string(fs.st_mtime))
+            self.send_header("Last-Modified", 
+                self.date_time_string(fs.st_mtime))
             self.end_headers()
             return f
         except:
index 5049538e66418170da4d7d3a07d2d08fc5a2af1b..dafcb0cbd56ba33a741c8357d392bc87acfabfd1 100644 (file)
@@ -14,11 +14,14 @@ import re
 import base64
 import ntpath
 import shutil
-import urllib.parse
+import email.message
+import email.utils
 import html
 import http.client
+import urllib.parse
 import tempfile
 import time
+import datetime
 from io import BytesIO
 
 import unittest
@@ -333,6 +336,13 @@ class SimpleHTTPServerTestCase(BaseTestCase):
         self.base_url = '/' + self.tempdir_name
         with open(os.path.join(self.tempdir, 'test'), 'wb') as temp:
             temp.write(self.data)
+            mtime = os.fstat(temp.fileno()).st_mtime
+        # compute last modification datetime for browser cache tests
+        last_modif = datetime.datetime.fromtimestamp(mtime,
+            datetime.timezone.utc)
+        self.last_modif_datetime = last_modif.replace(microsecond=0)
+        self.last_modif_header = email.utils.formatdate(
+            last_modif.timestamp(), usegmt=True)
 
     def tearDown(self):
         try:
@@ -444,6 +454,44 @@ class SimpleHTTPServerTestCase(BaseTestCase):
         self.assertEqual(response.getheader('content-type'),
                          'application/octet-stream')
 
+    def test_browser_cache(self):
+        """Check that when a request to /test is sent with the request header
+        If-Modified-Since set to date of last modification, the server returns
+        status code 304, not 200
+        """
+        headers = email.message.Message()
+        headers['If-Modified-Since'] = self.last_modif_header
+        response = self.request(self.base_url + '/test', headers=headers)
+        self.check_status_and_reason(response, HTTPStatus.NOT_MODIFIED)
+
+        # one hour after last modification : must return 304
+        new_dt = self.last_modif_datetime + datetime.timedelta(hours=1)
+        headers = email.message.Message()
+        headers['If-Modified-Since'] = email.utils.format_datetime(new_dt,
+            usegmt=True)
+        response = self.request(self.base_url + '/test', headers=headers)
+        self.check_status_and_reason(response, HTTPStatus.NOT_MODIFIED)        
+
+    def test_browser_cache_file_changed(self):
+        # with If-Modified-Since earlier than Last-Modified, must return 200
+        dt = self.last_modif_datetime
+        # build datetime object : 365 days before last modification
+        old_dt = dt - datetime.timedelta(days=365)
+        headers = email.message.Message()
+        headers['If-Modified-Since'] = email.utils.format_datetime(old_dt,
+            usegmt=True)
+        response = self.request(self.base_url + '/test', headers=headers)
+        self.check_status_and_reason(response, HTTPStatus.OK)
+
+    def test_browser_cache_with_If_None_Match_header(self):
+        # if If-None-Match header is present, ignore If-Modified-Since
+
+        headers = email.message.Message()
+        headers['If-Modified-Since'] = self.last_modif_header
+        headers['If-None-Match'] = "*"
+        response = self.request(self.base_url + '/test', headers=headers)
+        self.check_status_and_reason(response, HTTPStatus.OK)        
+
     def test_invalid_requests(self):
         response = self.request('/', method='FOO')
         self.check_status_and_reason(response, HTTPStatus.NOT_IMPLEMENTED)
@@ -453,6 +501,15 @@ class SimpleHTTPServerTestCase(BaseTestCase):
         response = self.request('/', method='GETs')
         self.check_status_and_reason(response, HTTPStatus.NOT_IMPLEMENTED)
 
+    def test_last_modified(self):
+        """Checks that the datetime returned in Last-Modified response header
+        is the actual datetime of last modification, rounded to the second
+        """
+        response = self.request(self.base_url + '/test')
+        self.check_status_and_reason(response, HTTPStatus.OK, data=self.data)
+        last_modif_header = response.headers['Last-modified']
+        self.assertEqual(last_modif_header, self.last_modif_header)
+
     def test_path_without_leading_slash(self):
         response = self.request(self.tempdir_name + '/test')
         self.check_status_and_reason(response, HTTPStatus.OK, data=self.data)
index a9acaf8e62f695b2329331d31cec7d6ca809759b..2e9cce6f12c63f2eb5a2db8f627e08ed79001972 100644 (file)
--- a/Misc/NEWS
+++ b/Misc/NEWS
@@ -303,6 +303,9 @@ Extension Modules
 Library
 -------
 
+- bpo-29654: Support If-Modified-Since HTTP header (browser cache).  Patch
+  by Pierre Quentel.
+
 - bpo-29931: Fixed comparison check for ipaddress.ip_interface objects.
   Patch by Sanjay Sundaresan.