From 0d02fbe1a3566985f2d17934988a9fe7b62135f4 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 26 May 2025 21:15:56 -0700 Subject: [PATCH 01/41] gh-135056: Add a --cors CLI argument to http.server Add a --cors command line argument to the stdlib http.server module, which will add an `Access-Control-Allow-Origin: *` header to all responses. As part of this implementation, add a `response_headers` argument to SimpleHTTPRequestHandler and HttpServer, which allows callers to add addition headers to the response. --- Doc/library/http.server.rst | 20 ++++++++- Lib/http/server.py | 42 +++++++++++++++---- Lib/test/test_httpservers.py | 32 ++++++++++++-- ...-06-02-22-23-38.gh-issue-135056.yz3dSs.rst | 2 + 4 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 063344e0284258..0f40214b771d2f 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -362,7 +362,7 @@ instantiation, of which this module provides three different variants: delays, it now always returns the IP address. -.. class:: SimpleHTTPRequestHandler(request, client_address, server, directory=None) +.. class:: SimpleHTTPRequestHandler(request, client_address, server, directory=None, response_headers=None) This class serves files from the directory *directory* and below, or the current directory if *directory* is not provided, directly @@ -374,6 +374,10 @@ instantiation, of which this module provides three different variants: .. versionchanged:: 3.9 The *directory* parameter accepts a :term:`path-like object`. + .. versionchanged:: 3.15 + The *response_headers* parameter accepts an optional dictionary of + additional HTTP headers to add to each response. + A lot of the work, such as parsing the request, is done by the base class :class:`BaseHTTPRequestHandler`. This class implements the :func:`do_GET` and :func:`do_HEAD` functions. @@ -428,6 +432,9 @@ instantiation, of which this module provides three different variants: followed by a ``'Content-Length:'`` header with the file's size and a ``'Last-Modified:'`` header with the file's modification time. + The headers specified in the dictionary instance argument + ``response_headers`` are each individually sent in the response. + Then follows a blank line signifying the end of the headers, and then the contents of the file are output. @@ -437,6 +444,9 @@ instantiation, of which this module provides three different variants: .. versionchanged:: 3.7 Support of the ``'If-Modified-Since'`` header. + .. versionchanged:: 3.15 + Support ``response_headers`` as an instance argument. + The :class:`SimpleHTTPRequestHandler` class can be used in the following manner in order to create a very basic webserver serving files relative to the current directory:: @@ -543,6 +553,14 @@ The following options are accepted: .. versionadded:: 3.14 +.. option:: --cors + + Adds an additional CORS (Cross-Origin Resource sharing) header to each response:: + + Access-Control-Allow-Origin: * + + .. versionadded:: 3.15 + .. _http.server-security: diff --git a/Lib/http/server.py b/Lib/http/server.py index ef10d185932633..152a4275d32ab5 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -117,6 +117,10 @@ class HTTPServer(socketserver.TCPServer): allow_reuse_address = True # Seems to make sense in testing environment allow_reuse_port = True + def __init__(self, *args, response_headers=None, **kwargs): + self.response_headers = response_headers if response_headers is not None else {} + super().__init__(*args, **kwargs) + def server_bind(self): """Override server_bind to store the server name.""" socketserver.TCPServer.server_bind(self) @@ -124,6 +128,11 @@ def server_bind(self): self.server_name = socket.getfqdn(host) self.server_port = port + def finish_request(self, request, client_address): + """Finish one request by instantiating RequestHandlerClass.""" + args = (request, client_address, self) + kwargs = dict(response_headers=self.response_headers) if self.response_headers else dict() + self.RequestHandlerClass(*args, **kwargs) class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): daemon_threads = True @@ -132,7 +141,7 @@ class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): class HTTPSServer(HTTPServer): def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True, *, certfile, keyfile=None, - password=None, alpn_protocols=None): + password=None, alpn_protocols=None, response_headers=None): try: import ssl except ImportError: @@ -150,7 +159,8 @@ def __init__(self, server_address, RequestHandlerClass, super().__init__(server_address, RequestHandlerClass, - bind_and_activate) + bind_and_activate, + response_headers=response_headers) def server_activate(self): """Wrap the socket in SSLSocket.""" @@ -692,10 +702,11 @@ class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): '.xz': 'application/x-xz', } - def __init__(self, *args, directory=None, **kwargs): + def __init__(self, *args, directory=None, response_headers=None, **kwargs): if directory is None: directory = os.getcwd() self.directory = os.fspath(directory) + self.response_headers = response_headers or {} super().__init__(*args, **kwargs) def do_GET(self): @@ -736,6 +747,10 @@ def send_head(self): new_url = urllib.parse.urlunsplit(new_parts) self.send_header("Location", new_url) self.send_header("Content-Length", "0") + # User specified response_headers + if self.response_headers is not None: + for header, value in self.response_headers.items(): + self.send_header(header, value) self.end_headers() return None for index in self.index_pages: @@ -795,6 +810,9 @@ def send_head(self): self.send_header("Content-Length", str(fs[6])) self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) + if self.response_headers is not None: + for header, value in self.response_headers.items(): + self.send_header(header, value) self.end_headers() return f except: @@ -970,7 +988,7 @@ def _get_best_family(*address): def test(HandlerClass=BaseHTTPRequestHandler, ServerClass=ThreadingHTTPServer, protocol="HTTP/1.0", port=8000, bind=None, - tls_cert=None, tls_key=None, tls_password=None): + tls_cert=None, tls_key=None, tls_password=None, response_headers=None): """Test the HTTP request handler class. This runs an HTTP server on port 8000 (or the port argument). @@ -981,9 +999,10 @@ def test(HandlerClass=BaseHTTPRequestHandler, if tls_cert: server = ServerClass(addr, HandlerClass, certfile=tls_cert, - keyfile=tls_key, password=tls_password) + keyfile=tls_key, password=tls_password, + response_headers=response_headers) else: - server = ServerClass(addr, HandlerClass) + server = ServerClass(addr, HandlerClass, response_headers=response_headers) with server as httpd: host, port = httpd.socket.getsockname()[:2] @@ -1024,6 +1043,8 @@ def _main(args=None): parser.add_argument('port', default=8000, type=int, nargs='?', help='bind to this port ' '(default: %(default)s)') + parser.add_argument('--cors', action='store_true', + help='Enable Access-Control-Allow-Origin: * header') args = parser.parse_args(args) if not args.tls_cert and args.tls_key: @@ -1051,8 +1072,11 @@ def server_bind(self): return super().server_bind() def finish_request(self, request, client_address): - self.RequestHandlerClass(request, client_address, self, - directory=args.directory) + handler_args = (request, client_address, self) + handler_kwargs = dict(directory=args.directory) + if self.response_headers: + handler_kwargs['response_headers'] = self.response_headers + self.RequestHandlerClass(*handler_args, **handler_kwargs) class HTTPDualStackServer(DualStackServerMixin, ThreadingHTTPServer): pass @@ -1060,6 +1084,7 @@ class HTTPSDualStackServer(DualStackServerMixin, ThreadingHTTPSServer): pass ServerClass = HTTPSDualStackServer if args.tls_cert else HTTPDualStackServer + response_headers = {'Access-Control-Allow-Origin': '*'} if args.cors else None test( HandlerClass=SimpleHTTPRequestHandler, @@ -1070,6 +1095,7 @@ class HTTPSDualStackServer(DualStackServerMixin, ThreadingHTTPSServer): tls_cert=args.tls_cert, tls_key=args.tls_key, tls_password=tls_key_password, + response_headers=response_headers ) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 2548a7c5f292f0..b1b711b0387db0 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -81,11 +81,12 @@ def test_https_server_raises_runtime_error(self): class TestServerThread(threading.Thread): - def __init__(self, test_object, request_handler, tls=None): + def __init__(self, test_object, request_handler, tls=None, server_kwargs=None): threading.Thread.__init__(self) self.request_handler = request_handler self.test_object = test_object self.tls = tls + self.server_kwargs = server_kwargs or {} def run(self): if self.tls: @@ -95,7 +96,8 @@ def run(self): request_handler=self.request_handler, ) else: - self.server = HTTPServer(('localhost', 0), self.request_handler) + self.server = HTTPServer(('localhost', 0), self.request_handler, + **self.server_kwargs) self.test_object.HOST, self.test_object.PORT = self.server.socket.getsockname() self.test_object.server_started.set() self.test_object = None @@ -113,12 +115,14 @@ class BaseTestCase(unittest.TestCase): # Optional tuple (certfile, keyfile, password) to use for HTTPS servers. tls = None + server_kwargs = None def setUp(self): self._threads = threading_helper.threading_setup() os.environ = os_helper.EnvironmentVarGuard() self.server_started = threading.Event() - self.thread = TestServerThread(self, self.request_handler, self.tls) + self.thread = TestServerThread(self, self.request_handler, self.tls, + self.server_kwargs) self.thread.start() self.server_started.wait() @@ -824,6 +828,16 @@ def test_path_without_leading_slash(self): self.tempdir_name + "/?hi=1") +class CorsHTTPServerTestCase(SimpleHTTPServerTestCase): + server_kwargs = dict( + response_headers = {'Access-Control-Allow-Origin': '*'} + ) + def test_cors(self): + response = self.request(self.base_url + '/test') + self.check_status_and_reason(response, HTTPStatus.OK) + self.assertEqual(response.getheader('Access-Control-Allow-Origin'), '*') + + class SocketlessRequestHandler(SimpleHTTPRequestHandler): def __init__(self, directory=None): request = mock.Mock() @@ -1306,6 +1320,7 @@ class CommandLineTestCase(unittest.TestCase): 'tls_cert': None, 'tls_key': None, 'tls_password': None, + 'response_headers': None, } def setUp(self): @@ -1371,6 +1386,17 @@ def test_protocol_flag(self, mock_func): mock_func.assert_called_once_with(**call_args) mock_func.reset_mock() + @mock.patch('http.server.test') + def test_cors_flag(self, mock_func): + self.invoke_httpd('--cors') + call_args = self.args | dict( + response_headers={ + 'Access-Control-Allow-Origin': '*' + } + ) + mock_func.assert_called_once_with(**call_args) + mock_func.reset_mock() + @unittest.skipIf(ssl is None, "requires ssl") @mock.patch('http.server.test') def test_tls_cert_and_key_flags(self, mock_func): diff --git a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst new file mode 100644 index 00000000000000..d6fa033573e25b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst @@ -0,0 +1,2 @@ +Add a ``--cors`` cli option to ``python -m http.server``. Contributed by +Anton I. Sipos From 1838da7f9bf7ab91f7699120a9fc0d24c8501edd Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 2 Jun 2025 22:43:23 -0700 Subject: [PATCH 02/41] gh-issue-135056: Fix doc versionchanged and NEWS entries. --- Doc/library/http.server.rst | 6 +++--- .../Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 0f40214b771d2f..ab53f71c030c96 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -374,7 +374,7 @@ instantiation, of which this module provides three different variants: .. versionchanged:: 3.9 The *directory* parameter accepts a :term:`path-like object`. - .. versionchanged:: 3.15 + .. versionchanged:: next The *response_headers* parameter accepts an optional dictionary of additional HTTP headers to add to each response. @@ -444,7 +444,7 @@ instantiation, of which this module provides three different variants: .. versionchanged:: 3.7 Support of the ``'If-Modified-Since'`` header. - .. versionchanged:: 3.15 + .. versionchanged:: next Support ``response_headers`` as an instance argument. The :class:`SimpleHTTPRequestHandler` class can be used in the following @@ -559,7 +559,7 @@ The following options are accepted: Access-Control-Allow-Origin: * - .. versionadded:: 3.15 + .. versionadded:: next .. _http.server-security: diff --git a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst index d6fa033573e25b..929a4d08d19834 100644 --- a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst +++ b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst @@ -1,2 +1,2 @@ -Add a ``--cors`` cli option to ``python -m http.server``. Contributed by -Anton I. Sipos +Add a ``--cors`` cli option to :program:`python -m http.server`. Contributed by +Anton I. Sipos. From a3256fd21ba1d1c420e77a0007bea186a2f1f6a2 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 2 Jun 2025 23:09:49 -0700 Subject: [PATCH 03/41] gh-13056: Allow unspecified response_headers in HTTPServer. This fixes the breakage to HttpServer as used by wsgiref. --- Lib/http/server.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 152a4275d32ab5..1dfe735e688276 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -118,7 +118,7 @@ class HTTPServer(socketserver.TCPServer): allow_reuse_port = True def __init__(self, *args, response_headers=None, **kwargs): - self.response_headers = response_headers if response_headers is not None else {} + self.response_headers = response_headers super().__init__(*args, **kwargs) def server_bind(self): @@ -131,7 +131,10 @@ def server_bind(self): def finish_request(self, request, client_address): """Finish one request by instantiating RequestHandlerClass.""" args = (request, client_address, self) - kwargs = dict(response_headers=self.response_headers) if self.response_headers else dict() + kwargs = {} + response_headers = getattr(self, 'response_headers', None) + if response_headers: + kwargs['response_headers'] = self.response_headers self.RequestHandlerClass(*args, **kwargs) class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): From 77b5fff86aa309120c49c206e795765161753dab Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 19 Jun 2025 16:40:42 -0700 Subject: [PATCH 04/41] gh-135056: Simplifications and cleanups to http cors changes. --- Lib/http/server.py | 22 ++++++++++------------ Lib/socketserver.py | 2 +- Lib/test/test_httpservers.py | 8 +++++--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 1dfe735e688276..2e156919248e98 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -132,10 +132,9 @@ def finish_request(self, request, client_address): """Finish one request by instantiating RequestHandlerClass.""" args = (request, client_address, self) kwargs = {} - response_headers = getattr(self, 'response_headers', None) - if response_headers: + if hasattr(self, 'response_headers'): kwargs['response_headers'] = self.response_headers - self.RequestHandlerClass(*args, **kwargs) + self.RequestHandlerClass(request, client_address, self, **kwargs) class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): daemon_threads = True @@ -144,7 +143,7 @@ class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): class HTTPSServer(HTTPServer): def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True, *, certfile, keyfile=None, - password=None, alpn_protocols=None, response_headers=None): + password=None, alpn_protocols=None, **http_server_kwargs): try: import ssl except ImportError: @@ -163,7 +162,7 @@ def __init__(self, server_address, RequestHandlerClass, super().__init__(server_address, RequestHandlerClass, bind_and_activate, - response_headers=response_headers) + **http_server_kwargs) def server_activate(self): """Wrap the socket in SSLSocket.""" @@ -709,7 +708,7 @@ def __init__(self, *args, directory=None, response_headers=None, **kwargs): if directory is None: directory = os.getcwd() self.directory = os.fspath(directory) - self.response_headers = response_headers or {} + self.response_headers = response_headers super().__init__(*args, **kwargs) def do_GET(self): @@ -991,7 +990,8 @@ def _get_best_family(*address): def test(HandlerClass=BaseHTTPRequestHandler, ServerClass=ThreadingHTTPServer, protocol="HTTP/1.0", port=8000, bind=None, - tls_cert=None, tls_key=None, tls_password=None, response_headers=None): + tls_cert=None, tls_key=None, tls_password=None, + response_headers=None): """Test the HTTP request handler class. This runs an HTTP server on port 8000 (or the port argument). @@ -1075,11 +1075,9 @@ def server_bind(self): return super().server_bind() def finish_request(self, request, client_address): - handler_args = (request, client_address, self) - handler_kwargs = dict(directory=args.directory) - if self.response_headers: - handler_kwargs['response_headers'] = self.response_headers - self.RequestHandlerClass(*handler_args, **handler_kwargs) + self.RequestHandlerClass(request, client_address, self, + directory=args.directory, + response_headers=self.response_headers) class HTTPDualStackServer(DualStackServerMixin, ThreadingHTTPServer): pass diff --git a/Lib/socketserver.py b/Lib/socketserver.py index 93b0a23be27f68..fdb312e7c38c1c 100644 --- a/Lib/socketserver.py +++ b/Lib/socketserver.py @@ -757,7 +757,7 @@ class BaseRequestHandler: """ - def __init__(self, request, client_address, server): + def __init__(self, request, client_address, server, **kwargs): self.request = request self.client_address = client_address self.server = server diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index b1b711b0387db0..60b75d5fa21faf 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -94,6 +94,7 @@ def run(self): self.server = create_https_server( certfile, keyfile, password, request_handler=self.request_handler, + **self.server_kwargs ) else: self.server = HTTPServer(('localhost', 0), self.request_handler, @@ -829,9 +830,10 @@ def test_path_without_leading_slash(self): class CorsHTTPServerTestCase(SimpleHTTPServerTestCase): - server_kwargs = dict( - response_headers = {'Access-Control-Allow-Origin': '*'} - ) + server_kwargs = { + 'response_headers': {'Access-Control-Allow-Origin': '*'} + } + def test_cors(self): response = self.request(self.base_url + '/test') self.check_status_and_reason(response, HTTPStatus.OK) From 5f89c97c15f7cd240cec2ba4fc2eec55d0d19f54 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 19 Jun 2025 18:43:38 -0700 Subject: [PATCH 05/41] gh-135056: Add a --header argument to http.server cli. --- Lib/http/server.py | 14 ++++++++++++-- Lib/test/test_httpservers.py | 12 ++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 2e156919248e98..ebc82bfd8e99d3 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1048,6 +1048,11 @@ def _main(args=None): '(default: %(default)s)') parser.add_argument('--cors', action='store_true', help='Enable Access-Control-Allow-Origin: * header') + parser.add_argument('-H', '--header', nargs=2, action='append', + # metavar='HEADER VALUE', + metavar=('HEADER', 'VALUE'), + help='Add a custom response header ' + '(can be used multiple times)') args = parser.parse_args(args) if not args.tls_cert and args.tls_key: @@ -1085,7 +1090,12 @@ class HTTPSDualStackServer(DualStackServerMixin, ThreadingHTTPSServer): pass ServerClass = HTTPSDualStackServer if args.tls_cert else HTTPDualStackServer - response_headers = {'Access-Control-Allow-Origin': '*'} if args.cors else None + response_headers = {} + if args.cors: + response_headers['Access-Control-Allow-Origin'] = '*' + for header, value in args.header or []: + response_headers[header] = value + test( HandlerClass=SimpleHTTPRequestHandler, @@ -1096,7 +1106,7 @@ class HTTPSDualStackServer(DualStackServerMixin, ThreadingHTTPSServer): tls_cert=args.tls_cert, tls_key=args.tls_key, tls_password=tls_key_password, - response_headers=response_headers + response_headers=response_headers or None ) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 60b75d5fa21faf..3bfa14a6112400 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -1399,6 +1399,18 @@ def test_cors_flag(self, mock_func): mock_func.assert_called_once_with(**call_args) mock_func.reset_mock() + @mock.patch('http.server.test') + def test_header_flag(self, mock_func): + self.invoke_httpd('--header', 'h1', 'v1', '-H', 'h2', 'v2') + call_args = self.args | dict( + response_headers={ + 'h1': 'v1', + 'h2': 'v2' + } + ) + mock_func.assert_called_once_with(**call_args) + mock_func.reset_mock() + @unittest.skipIf(ssl is None, "requires ssl") @mock.patch('http.server.test') def test_tls_cert_and_key_flags(self, mock_func): From a3243fe679166211ab1db3ea57cadeb50a3fbbdb Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Sun, 6 Jul 2025 10:48:10 -0700 Subject: [PATCH 06/41] gh-135056: Remove --cors opt from http.server in favor of --header --- Doc/library/http.server.rst | 9 -------- Lib/http/server.py | 4 ---- Lib/test/test_httpservers.py | 22 ------------------- ...-06-02-22-23-38.gh-issue-135056.yz3dSs.rst | 2 +- 4 files changed, 1 insertion(+), 36 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index ab53f71c030c96..36033be34f2042 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -553,15 +553,6 @@ The following options are accepted: .. versionadded:: 3.14 -.. option:: --cors - - Adds an additional CORS (Cross-Origin Resource sharing) header to each response:: - - Access-Control-Allow-Origin: * - - .. versionadded:: next - - .. _http.server-security: Security considerations diff --git a/Lib/http/server.py b/Lib/http/server.py index ebc82bfd8e99d3..b95d6094725112 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1046,8 +1046,6 @@ def _main(args=None): parser.add_argument('port', default=8000, type=int, nargs='?', help='bind to this port ' '(default: %(default)s)') - parser.add_argument('--cors', action='store_true', - help='Enable Access-Control-Allow-Origin: * header') parser.add_argument('-H', '--header', nargs=2, action='append', # metavar='HEADER VALUE', metavar=('HEADER', 'VALUE'), @@ -1091,8 +1089,6 @@ class HTTPSDualStackServer(DualStackServerMixin, ThreadingHTTPSServer): ServerClass = HTTPSDualStackServer if args.tls_cert else HTTPDualStackServer response_headers = {} - if args.cors: - response_headers['Access-Control-Allow-Origin'] = '*' for header, value in args.header or []: response_headers[header] = value diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 3bfa14a6112400..e3aa8bb9e53ef9 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -829,17 +829,6 @@ def test_path_without_leading_slash(self): self.tempdir_name + "/?hi=1") -class CorsHTTPServerTestCase(SimpleHTTPServerTestCase): - server_kwargs = { - 'response_headers': {'Access-Control-Allow-Origin': '*'} - } - - def test_cors(self): - response = self.request(self.base_url + '/test') - self.check_status_and_reason(response, HTTPStatus.OK) - self.assertEqual(response.getheader('Access-Control-Allow-Origin'), '*') - - class SocketlessRequestHandler(SimpleHTTPRequestHandler): def __init__(self, directory=None): request = mock.Mock() @@ -1388,17 +1377,6 @@ def test_protocol_flag(self, mock_func): mock_func.assert_called_once_with(**call_args) mock_func.reset_mock() - @mock.patch('http.server.test') - def test_cors_flag(self, mock_func): - self.invoke_httpd('--cors') - call_args = self.args | dict( - response_headers={ - 'Access-Control-Allow-Origin': '*' - } - ) - mock_func.assert_called_once_with(**call_args) - mock_func.reset_mock() - @mock.patch('http.server.test') def test_header_flag(self, mock_func): self.invoke_httpd('--header', 'h1', 'v1', '-H', 'h2', 'v2') diff --git a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst index 929a4d08d19834..8afb6307d49ef9 100644 --- a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst +++ b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst @@ -1,2 +1,2 @@ -Add a ``--cors`` cli option to :program:`python -m http.server`. Contributed by +Add a ``--header`` cli option to :program:`python -m http.server`. Contributed by Anton I. Sipos. From b1026d2a1faeec3c323467f5d7ca354f0712604a Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Sun, 6 Jul 2025 18:27:16 -0700 Subject: [PATCH 07/41] gh-135056: Use response_headers only in SimpleHTTPRequestHandler --- Doc/library/http.server.rst | 5 ++-- Lib/http/server.py | 51 ++++++++++++++---------------------- Lib/socketserver.py | 2 +- Lib/test/test_httpservers.py | 47 ++++++++++++++++++++++----------- 4 files changed, 54 insertions(+), 51 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 36033be34f2042..8382b72856af6f 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -375,8 +375,9 @@ instantiation, of which this module provides three different variants: The *directory* parameter accepts a :term:`path-like object`. .. versionchanged:: next - The *response_headers* parameter accepts an optional dictionary of - additional HTTP headers to add to each response. + Added *response_headers*, which accepts an optional dictionary of + additional HTTP headers to add to each successful HTTP status 200 + response. All other status code responses will not include these headers. A lot of the work, such as parsing the request, is done by the base class :class:`BaseHTTPRequestHandler`. This class implements the :func:`do_GET` diff --git a/Lib/http/server.py b/Lib/http/server.py index b95d6094725112..ffaf176c395974 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -117,10 +117,6 @@ class HTTPServer(socketserver.TCPServer): allow_reuse_address = True # Seems to make sense in testing environment allow_reuse_port = True - def __init__(self, *args, response_headers=None, **kwargs): - self.response_headers = response_headers - super().__init__(*args, **kwargs) - def server_bind(self): """Override server_bind to store the server name.""" socketserver.TCPServer.server_bind(self) @@ -128,13 +124,6 @@ def server_bind(self): self.server_name = socket.getfqdn(host) self.server_port = port - def finish_request(self, request, client_address): - """Finish one request by instantiating RequestHandlerClass.""" - args = (request, client_address, self) - kwargs = {} - if hasattr(self, 'response_headers'): - kwargs['response_headers'] = self.response_headers - self.RequestHandlerClass(request, client_address, self, **kwargs) class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): daemon_threads = True @@ -143,7 +132,7 @@ class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer): class HTTPSServer(HTTPServer): def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True, *, certfile, keyfile=None, - password=None, alpn_protocols=None, **http_server_kwargs): + password=None, alpn_protocols=None): try: import ssl except ImportError: @@ -161,8 +150,7 @@ def __init__(self, server_address, RequestHandlerClass, super().__init__(server_address, RequestHandlerClass, - bind_and_activate, - **http_server_kwargs) + bind_and_activate) def server_activate(self): """Wrap the socket in SSLSocket.""" @@ -726,6 +714,13 @@ def do_HEAD(self): if f: f.close() + def send_custom_response_headers(self): + """Send the headers stored in self.response_headers""" + # User specified response_headers + if self.response_headers is not None: + for header, value in self.response_headers.items(): + self.send_header(header, value) + def send_head(self): """Common code for GET and HEAD commands. @@ -749,10 +744,6 @@ def send_head(self): new_url = urllib.parse.urlunsplit(new_parts) self.send_header("Location", new_url) self.send_header("Content-Length", "0") - # User specified response_headers - if self.response_headers is not None: - for header, value in self.response_headers.items(): - self.send_header(header, value) self.end_headers() return None for index in self.index_pages: @@ -812,9 +803,7 @@ def send_head(self): self.send_header("Content-Length", str(fs[6])) self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) - if self.response_headers is not None: - for header, value in self.response_headers.items(): - self.send_header(header, value) + self.send_custom_response_headers() self.end_headers() return f except: @@ -879,6 +868,7 @@ def list_directory(self, path): self.send_response(HTTPStatus.OK) self.send_header("Content-type", "text/html; charset=%s" % enc) self.send_header("Content-Length", str(len(encoded))) + self.send_custom_response_headers() self.end_headers() return f @@ -990,8 +980,7 @@ def _get_best_family(*address): def test(HandlerClass=BaseHTTPRequestHandler, ServerClass=ThreadingHTTPServer, protocol="HTTP/1.0", port=8000, bind=None, - tls_cert=None, tls_key=None, tls_password=None, - response_headers=None): + tls_cert=None, tls_key=None, tls_password=None): """Test the HTTP request handler class. This runs an HTTP server on port 8000 (or the port argument). @@ -1002,10 +991,9 @@ def test(HandlerClass=BaseHTTPRequestHandler, if tls_cert: server = ServerClass(addr, HandlerClass, certfile=tls_cert, - keyfile=tls_key, password=tls_password, - response_headers=response_headers) + keyfile=tls_key, password=tls_password) else: - server = ServerClass(addr, HandlerClass, response_headers=response_headers) + server = ServerClass(addr, HandlerClass) with server as httpd: host, port = httpd.socket.getsockname()[:2] @@ -1067,6 +1055,10 @@ def _main(args=None): except OSError as e: parser.error(f"Failed to read TLS password file: {e}") + response_headers = {} + for header, value in args.header or []: + response_headers[header] = value + # ensure dual-stack is not disabled; ref #38907 class DualStackServerMixin: @@ -1080,7 +1072,7 @@ def server_bind(self): def finish_request(self, request, client_address): self.RequestHandlerClass(request, client_address, self, directory=args.directory, - response_headers=self.response_headers) + response_headers=response_headers) class HTTPDualStackServer(DualStackServerMixin, ThreadingHTTPServer): pass @@ -1088,10 +1080,6 @@ class HTTPSDualStackServer(DualStackServerMixin, ThreadingHTTPSServer): pass ServerClass = HTTPSDualStackServer if args.tls_cert else HTTPDualStackServer - response_headers = {} - for header, value in args.header or []: - response_headers[header] = value - test( HandlerClass=SimpleHTTPRequestHandler, @@ -1102,7 +1090,6 @@ class HTTPSDualStackServer(DualStackServerMixin, ThreadingHTTPSServer): tls_cert=args.tls_cert, tls_key=args.tls_key, tls_password=tls_key_password, - response_headers=response_headers or None ) diff --git a/Lib/socketserver.py b/Lib/socketserver.py index fdb312e7c38c1c..93b0a23be27f68 100644 --- a/Lib/socketserver.py +++ b/Lib/socketserver.py @@ -757,7 +757,7 @@ class BaseRequestHandler: """ - def __init__(self, request, client_address, server, **kwargs): + def __init__(self, request, client_address, server): self.request = request self.client_address = client_address self.server = server diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index e3aa8bb9e53ef9..b55d4025a668c1 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -81,12 +81,11 @@ def test_https_server_raises_runtime_error(self): class TestServerThread(threading.Thread): - def __init__(self, test_object, request_handler, tls=None, server_kwargs=None): + def __init__(self, test_object, request_handler, tls=None): threading.Thread.__init__(self) self.request_handler = request_handler self.test_object = test_object self.tls = tls - self.server_kwargs = server_kwargs or {} def run(self): if self.tls: @@ -94,11 +93,9 @@ def run(self): self.server = create_https_server( certfile, keyfile, password, request_handler=self.request_handler, - **self.server_kwargs ) else: - self.server = HTTPServer(('localhost', 0), self.request_handler, - **self.server_kwargs) + self.server = HTTPServer(('localhost', 0), self.request_handler) self.test_object.HOST, self.test_object.PORT = self.server.socket.getsockname() self.test_object.server_started.set() self.test_object = None @@ -116,14 +113,12 @@ class BaseTestCase(unittest.TestCase): # Optional tuple (certfile, keyfile, password) to use for HTTPS servers. tls = None - server_kwargs = None def setUp(self): self._threads = threading_helper.threading_setup() os.environ = os_helper.EnvironmentVarGuard() self.server_started = threading.Event() - self.thread = TestServerThread(self, self.request_handler, self.tls, - self.server_kwargs) + self.thread = TestServerThread(self, self.request_handler, self.tls) self.thread.start() self.server_started.wait() @@ -470,8 +465,14 @@ def test_err(self): self.assertEndsWith(lines[1], '"ERROR / HTTP/1.1" 404 -') +class CustomHeaderSimpleHTTPRequestHandler(SimpleHTTPRequestHandler): + custom_headers = None + def __init__(self, *args, directory=None, response_headers=None, **kwargs): + super().__init__(*args, response_headers=self.custom_headers, **kwargs) + + class SimpleHTTPServerTestCase(BaseTestCase): - class request_handler(NoLogRequestHandler, SimpleHTTPRequestHandler): + class request_handler(NoLogRequestHandler, CustomHeaderSimpleHTTPRequestHandler): pass def setUp(self): @@ -828,6 +829,26 @@ def test_path_without_leading_slash(self): self.assertEqual(response.getheader("Location"), self.tempdir_name + "/?hi=1") + def test_custom_headers_list_dir(self): + with mock.patch.object(self.request_handler, 'custom_headers', new={ + 'X-Test1': 'test1', + 'X-Test2': 'test2', + }): + response = self.request(self.base_url + '/') + self.assertEqual(response.getheader("X-Test1"), 'test1') + self.assertEqual(response.getheader("X-Test2"), 'test2') + + def test_custom_headers_get_file(self): + with mock.patch.object(self.request_handler, 'custom_headers', new={ + 'X-Test1': 'test1', + 'X-Test2': 'test2', + }): + data = b"Dummy index file\r\n" + with open(os.path.join(self.tempdir_name, 'index.html'), 'wb') as f: + f.write(data) + response = self.request(self.base_url + '/') + self.assertEqual(response.getheader("X-Test1"), 'test1') + self.assertEqual(response.getheader("X-Test2"), 'test2') class SocketlessRequestHandler(SimpleHTTPRequestHandler): def __init__(self, directory=None): @@ -1311,7 +1332,6 @@ class CommandLineTestCase(unittest.TestCase): 'tls_cert': None, 'tls_key': None, 'tls_password': None, - 'response_headers': None, } def setUp(self): @@ -1379,13 +1399,8 @@ def test_protocol_flag(self, mock_func): @mock.patch('http.server.test') def test_header_flag(self, mock_func): + call_args = self.args self.invoke_httpd('--header', 'h1', 'v1', '-H', 'h2', 'v2') - call_args = self.args | dict( - response_headers={ - 'h1': 'v1', - 'h2': 'v2' - } - ) mock_func.assert_called_once_with(**call_args) mock_func.reset_mock() From 6f88c13a4338dd33827e7cd929e7744ef63d3aca Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Wed, 9 Jul 2025 17:18:32 -0700 Subject: [PATCH 08/41] gh-135056: Add test for http.server cli --header argument --- Lib/http/server.py | 3 ++- Lib/test/test_httpservers.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index ffaf176c395974..bc1984b0262b51 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1008,6 +1008,7 @@ def test(HandlerClass=BaseHTTPRequestHandler, except KeyboardInterrupt: print("\nKeyboard interrupt received, exiting.") sys.exit(0) + return server def _main(args=None): @@ -1081,7 +1082,7 @@ class HTTPSDualStackServer(DualStackServerMixin, ThreadingHTTPSServer): ServerClass = HTTPSDualStackServer if args.tls_cert else HTTPDualStackServer - test( + return test( HandlerClass=SimpleHTTPRequestHandler, ServerClass=ServerClass, port=args.port, diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index b55d4025a668c1..bdc0c0ff8b50c2 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -1487,6 +1487,34 @@ def test_unknown_flag(self, _): self.assertEqual(stdout.getvalue(), '') self.assertIn('error', stderr.getvalue()) + def test_response_headers_arg(self): + # with mock.patch.object( + # SimpleHTTPRequestHandler, '__init__' + # ) as mock_handler, \ + # mock.patch.object( + # HTTPServer, 'serve_forever' + # ) as mock_serve_forever: + with mock.patch.object( + HTTPServer, 'serve_forever' + ) as mock_serve_forever: + httpd = server._main( + ['-H', 'X-Test1', 'Test1', '-H', 'X-Test2', 'Test2', '8080'] + ) + request_handler_class = httpd.RequestHandlerClass + with mock.patch.object( + request_handler_class, '__init__' + ) as mock_handler_init: + mock_handler_init.return_value = None + # finish_request instantiates a request handler class, + # ensure response_headers are passed to it + httpd.finish_request(mock.Mock(), '127.0.0.1') + mock_handler_init.assert_called_once_with( + mock.ANY, mock.ANY, mock.ANY, directory=mock.ANY, + response_headers={ + 'X-Test1': 'Test1', 'X-Test2': 'Test2' + } + ) + class CommandLineRunTimeTestCase(unittest.TestCase): served_data = os.urandom(32) From 7a793f2d4be5394645b3da17c16a4468f640a875 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Wed, 9 Jul 2025 22:57:23 -0700 Subject: [PATCH 09/41] gh-135056: Support multiple headers of the same name. --- Doc/library/http.server.rst | 8 ++++---- Lib/http/server.py | 6 +++--- Lib/test/test_httpservers.py | 30 ++++++++++++++++-------------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 8382b72856af6f..62df260e8f5eb2 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -375,8 +375,8 @@ instantiation, of which this module provides three different variants: The *directory* parameter accepts a :term:`path-like object`. .. versionchanged:: next - Added *response_headers*, which accepts an optional dictionary of - additional HTTP headers to add to each successful HTTP status 200 + Added *response_headers*, which accepts an optional iterable of + name/value pairs of HTTP headers to add to each successful HTTP status 200 response. All other status code responses will not include these headers. A lot of the work, such as parsing the request, is done by the base class @@ -433,8 +433,8 @@ instantiation, of which this module provides three different variants: followed by a ``'Content-Length:'`` header with the file's size and a ``'Last-Modified:'`` header with the file's modification time. - The headers specified in the dictionary instance argument - ``response_headers`` are each individually sent in the response. + The instance attribute ``response_headers`` is used as an iterable of + name/value pairs to set user specified custom response headers. Then follows a blank line signifying the end of the headers, and then the contents of the file are output. diff --git a/Lib/http/server.py b/Lib/http/server.py index bc1984b0262b51..b0787f59362c15 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -718,7 +718,7 @@ def send_custom_response_headers(self): """Send the headers stored in self.response_headers""" # User specified response_headers if self.response_headers is not None: - for header, value in self.response_headers.items(): + for header, value in self.response_headers: self.send_header(header, value) def send_head(self): @@ -1056,9 +1056,9 @@ def _main(args=None): except OSError as e: parser.error(f"Failed to read TLS password file: {e}") - response_headers = {} + response_headers = [] for header, value in args.header or []: - response_headers[header] = value + response_headers.append((header, value)) # ensure dual-stack is not disabled; ref #38907 class DualStackServerMixin: diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index bdc0c0ff8b50c2..d65aad6099d68b 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -830,25 +830,27 @@ def test_path_without_leading_slash(self): self.tempdir_name + "/?hi=1") def test_custom_headers_list_dir(self): - with mock.patch.object(self.request_handler, 'custom_headers', new={ - 'X-Test1': 'test1', - 'X-Test2': 'test2', - }): + with mock.patch.object(self.request_handler, 'custom_headers', new=[ + ('X-Test1', 'test1'), + ('X-Test2', 'test2'), + ]): response = self.request(self.base_url + '/') self.assertEqual(response.getheader("X-Test1"), 'test1') self.assertEqual(response.getheader("X-Test2"), 'test2') def test_custom_headers_get_file(self): - with mock.patch.object(self.request_handler, 'custom_headers', new={ - 'X-Test1': 'test1', - 'X-Test2': 'test2', - }): + with mock.patch.object(self.request_handler, 'custom_headers', new=[ + ('Set-Cookie', 'test1=value1'), + ('Set-Cookie', 'test2=value2'), + ('X-Test1', 'value3'), + ]): data = b"Dummy index file\r\n" with open(os.path.join(self.tempdir_name, 'index.html'), 'wb') as f: f.write(data) response = self.request(self.base_url + '/') - self.assertEqual(response.getheader("X-Test1"), 'test1') - self.assertEqual(response.getheader("X-Test2"), 'test2') + self.assertEqual(response.getheader("Set-Cookie"), + 'test1=value1, test2=value2') + self.assertEqual(response.getheader("X-Test1"), 'value3') class SocketlessRequestHandler(SimpleHTTPRequestHandler): def __init__(self, directory=None): @@ -1498,7 +1500,7 @@ def test_response_headers_arg(self): HTTPServer, 'serve_forever' ) as mock_serve_forever: httpd = server._main( - ['-H', 'X-Test1', 'Test1', '-H', 'X-Test2', 'Test2', '8080'] + ['-H', 'Set-Cookie', 'k=v', '-H', 'Set-Cookie', 'k2=v2', '8080'] ) request_handler_class = httpd.RequestHandlerClass with mock.patch.object( @@ -1510,9 +1512,9 @@ def test_response_headers_arg(self): httpd.finish_request(mock.Mock(), '127.0.0.1') mock_handler_init.assert_called_once_with( mock.ANY, mock.ANY, mock.ANY, directory=mock.ANY, - response_headers={ - 'X-Test1': 'Test1', 'X-Test2': 'Test2' - } + response_headers=[ + ('Set-Cookie', 'k=v'), ('Set-Cookie', 'k2=v2') + ] ) From 9450b868516e80d775449b7f496795e127ef3e42 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 14 Jul 2025 20:50:56 -0700 Subject: [PATCH 10/41] gh-135056: Remove some commented out and unused code. --- Lib/http/server.py | 1 - Lib/test/test_httpservers.py | 10 +--------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index b0787f59362c15..4aaa8c1fd4646d 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1036,7 +1036,6 @@ def _main(args=None): help='bind to this port ' '(default: %(default)s)') parser.add_argument('-H', '--header', nargs=2, action='append', - # metavar='HEADER VALUE', metavar=('HEADER', 'VALUE'), help='Add a custom response header ' '(can be used multiple times)') diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index d65aad6099d68b..dfc2a2a02dee07 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -1490,15 +1490,7 @@ def test_unknown_flag(self, _): self.assertIn('error', stderr.getvalue()) def test_response_headers_arg(self): - # with mock.patch.object( - # SimpleHTTPRequestHandler, '__init__' - # ) as mock_handler, \ - # mock.patch.object( - # HTTPServer, 'serve_forever' - # ) as mock_serve_forever: - with mock.patch.object( - HTTPServer, 'serve_forever' - ) as mock_serve_forever: + with mock.patch.object(HTTPServer, 'serve_forever'): httpd = server._main( ['-H', 'Set-Cookie', 'k=v', '-H', 'Set-Cookie', 'k2=v2', '8080'] ) From 5a30d914ad194bfac400bbaa5e699bd77f9785ed Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 14 Jul 2025 20:55:23 -0700 Subject: [PATCH 11/41] gh-135056: Capitalize CLI acronym in the docs. --- .../next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst index 8afb6307d49ef9..0565260dc443ec 100644 --- a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst +++ b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst @@ -1,2 +1,2 @@ -Add a ``--header`` cli option to :program:`python -m http.server`. Contributed by +Add a ``--header`` CLI option to :program:`python -m http.server`. Contributed by Anton I. Sipos. From d317cc2d333f7b8b340e5133378cdc64b5b377e7 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 14 Jul 2025 20:59:57 -0700 Subject: [PATCH 12/41] gh-135056: Simplify args.header processing. --- Lib/http/server.py | 6 +----- Lib/test/test_httpservers.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 4aaa8c1fd4646d..ac22c6a76cdba3 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1055,10 +1055,6 @@ def _main(args=None): except OSError as e: parser.error(f"Failed to read TLS password file: {e}") - response_headers = [] - for header, value in args.header or []: - response_headers.append((header, value)) - # ensure dual-stack is not disabled; ref #38907 class DualStackServerMixin: @@ -1072,7 +1068,7 @@ def server_bind(self): def finish_request(self, request, client_address): self.RequestHandlerClass(request, client_address, self, directory=args.directory, - response_headers=response_headers) + response_headers=args.header) class HTTPDualStackServer(DualStackServerMixin, ThreadingHTTPServer): pass diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index dfc2a2a02dee07..77b9ef1aa4d870 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -1505,7 +1505,7 @@ def test_response_headers_arg(self): mock_handler_init.assert_called_once_with( mock.ANY, mock.ANY, mock.ANY, directory=mock.ANY, response_headers=[ - ('Set-Cookie', 'k=v'), ('Set-Cookie', 'k2=v2') + ['Set-Cookie', 'k=v'], ['Set-Cookie', 'k2=v2'] ] ) From 5f1fb944d41ea6604b2bab7ce9afd91ac33edabf Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 11 Aug 2025 21:44:24 -0700 Subject: [PATCH 13/41] gh-135056: Factor out a _make_server function from test function. --- Lib/http/server.py | 30 +++++++++++++++--------- Lib/test/test_httpservers.py | 44 +++++++++++++++++++++--------------- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index ac22c6a76cdba3..c4ca889d9e857a 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -977,25 +977,34 @@ def _get_best_family(*address): return family, sockaddr -def test(HandlerClass=BaseHTTPRequestHandler, +def _make_server(HandlerClass=BaseHTTPRequestHandler, ServerClass=ThreadingHTTPServer, protocol="HTTP/1.0", port=8000, bind=None, tls_cert=None, tls_key=None, tls_password=None): - """Test the HTTP request handler class. - - This runs an HTTP server on port 8000 (or the port argument). - - """ ServerClass.address_family, addr = _get_best_family(bind, port) HandlerClass.protocol_version = protocol if tls_cert: - server = ServerClass(addr, HandlerClass, certfile=tls_cert, + return ServerClass(addr, HandlerClass, certfile=tls_cert, keyfile=tls_key, password=tls_password) else: - server = ServerClass(addr, HandlerClass) + return ServerClass(addr, HandlerClass) + + +def test(HandlerClass=BaseHTTPRequestHandler, + ServerClass=ThreadingHTTPServer, + protocol="HTTP/1.0", port=8000, bind=None, + tls_cert=None, tls_key=None, tls_password=None): + """Test the HTTP request handler class. + + This runs an HTTP server on port 8000 (or the port argument). - with server as httpd: + """ + with _make_server( + HandlerClass=HandlerClass, ServerClass=ServerClass, + protocol=protocol, port=port, bind=bind, tls_cert=tls_cert, + tls_key=tls_key, tls_password=tls_password + ) as httpd: host, port = httpd.socket.getsockname()[:2] url_host = f'[{host}]' if ':' in host else host protocol = 'HTTPS' if tls_cert else 'HTTP' @@ -1008,7 +1017,6 @@ def test(HandlerClass=BaseHTTPRequestHandler, except KeyboardInterrupt: print("\nKeyboard interrupt received, exiting.") sys.exit(0) - return server def _main(args=None): @@ -1077,7 +1085,7 @@ class HTTPSDualStackServer(DualStackServerMixin, ThreadingHTTPSServer): ServerClass = HTTPSDualStackServer if args.tls_cert else HTTPDualStackServer - return test( + test( HandlerClass=SimpleHTTPRequestHandler, ServerClass=ServerClass, port=args.port, diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 77b9ef1aa4d870..829deef6164e99 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -1489,25 +1489,33 @@ def test_unknown_flag(self, _): self.assertEqual(stdout.getvalue(), '') self.assertIn('error', stderr.getvalue()) - def test_response_headers_arg(self): - with mock.patch.object(HTTPServer, 'serve_forever'): - httpd = server._main( - ['-H', 'Set-Cookie', 'k=v', '-H', 'Set-Cookie', 'k2=v2', '8080'] + @mock.patch('http.server._make_server', wraps=server._make_server) + @mock.patch.object(HTTPServer, 'serve_forever') + def test_response_headers_arg(self, _, mock_make_server): + server._main( + ['-H', 'Set-Cookie', 'k=v', '-H', 'Set-Cookie', 'k2=v2', '8080'] + ) + # Get an instance of the server / RequestHandler by using + # the spied call args, then calling _make_server with them. + args, kwargs = mock_make_server.call_args + httpd = server._make_server(*args, **kwargs) + + # Ensure the RequestHandler class is passed the correct response + # headers + request_handler_class = httpd.RequestHandlerClass + with mock.patch.object( + request_handler_class, '__init__' + ) as mock_handler_init: + mock_handler_init.return_value = None + # finish_request instantiates a request handler class, + # ensure response_headers are passed to it + httpd.finish_request(mock.Mock(), '127.0.0.1') + mock_handler_init.assert_called_once_with( + mock.ANY, mock.ANY, mock.ANY, directory=mock.ANY, + response_headers=[ + ['Set-Cookie', 'k=v'], ['Set-Cookie', 'k2=v2'] + ] ) - request_handler_class = httpd.RequestHandlerClass - with mock.patch.object( - request_handler_class, '__init__' - ) as mock_handler_init: - mock_handler_init.return_value = None - # finish_request instantiates a request handler class, - # ensure response_headers are passed to it - httpd.finish_request(mock.Mock(), '127.0.0.1') - mock_handler_init.assert_called_once_with( - mock.ANY, mock.ANY, mock.ANY, directory=mock.ANY, - response_headers=[ - ['Set-Cookie', 'k=v'], ['Set-Cookie', 'k2=v2'] - ] - ) class CommandLineRunTimeTestCase(unittest.TestCase): From c376a7119c3a34192939801cc4f6409ada3f65a9 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 11 Aug 2025 22:28:07 -0700 Subject: [PATCH 14/41] gh-135056: Document directory and custom_headers as keyword args. --- Doc/library/http.server.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 62df260e8f5eb2..6bcd05e8870d11 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -369,15 +369,16 @@ instantiation, of which this module provides three different variants: mapping the directory structure to HTTP requests. .. versionchanged:: 3.7 - Added the *directory* parameter. + Added the *directory* keyword argument. .. versionchanged:: 3.9 - The *directory* parameter accepts a :term:`path-like object`. + The *directory* keyword argument accepts a :term:`path-like object`. .. versionchanged:: next - Added *response_headers*, which accepts an optional iterable of - name/value pairs of HTTP headers to add to each successful HTTP status 200 - response. All other status code responses will not include these headers. + Added a *response_headers* keyword argument, which accepts an optional + iterable of name/value pairs of HTTP headers to add to each successful + HTTP status 200 response. All other status code responses will not include + these headers. A lot of the work, such as parsing the request, is done by the base class :class:`BaseHTTPRequestHandler`. This class implements the :func:`do_GET` From 89a89f018f3de817ffd9cc68196e3484c5de901f Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 11 Aug 2025 22:29:44 -0700 Subject: [PATCH 15/41] gh-135056: Add whatsnew entries to 3.15.rst for custom headers. --- Doc/whatsnew/3.15.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 2fe33c4c535919..7e83fb817a16b4 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -109,6 +109,18 @@ difflib (Contributed by Jiahao Li in :gh:`134580`.) +http.server +----------- + +* Added a new ``response_headers=`` keyword argument to + :class:`SimpleHTTPRequestHandler` to support custom headers in HTTP responses. + (Contributed by Anton I. Sipos in :gh:`135057`.) + +* Added a ``--header`` flag to the :program:`python -m http.server` + command-line interface to support custom headers in HTTP responses. + (Contributed by Anton I. Sipos in :gh:`135057`.) + + math ---- From 965371092ea2c13c408609b5d64d2bbd3a282232 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 15:40:12 -0700 Subject: [PATCH 16/41] gh-135056: Revert document directory + custom_headers as kwargs This reverts commit c376a7119c3a34192939801cc4f6409ada3f65a9. --- Doc/library/http.server.rst | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 6bcd05e8870d11..62df260e8f5eb2 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -369,16 +369,15 @@ instantiation, of which this module provides three different variants: mapping the directory structure to HTTP requests. .. versionchanged:: 3.7 - Added the *directory* keyword argument. + Added the *directory* parameter. .. versionchanged:: 3.9 - The *directory* keyword argument accepts a :term:`path-like object`. + The *directory* parameter accepts a :term:`path-like object`. .. versionchanged:: next - Added a *response_headers* keyword argument, which accepts an optional - iterable of name/value pairs of HTTP headers to add to each successful - HTTP status 200 response. All other status code responses will not include - these headers. + Added *response_headers*, which accepts an optional iterable of + name/value pairs of HTTP headers to add to each successful HTTP status 200 + response. All other status code responses will not include these headers. A lot of the work, such as parsing the request, is done by the base class :class:`BaseHTTPRequestHandler`. This class implements the :func:`do_GET` From f3ae90499a0bd34228c260ae72f4a92000f424d9 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 15:51:11 -0700 Subject: [PATCH 17/41] gh-135056: Document response_headers as an instance_attribute. --- Doc/library/http.server.rst | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 62df260e8f5eb2..01d287f52f052d 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -375,9 +375,7 @@ instantiation, of which this module provides three different variants: The *directory* parameter accepts a :term:`path-like object`. .. versionchanged:: next - Added *response_headers*, which accepts an optional iterable of - name/value pairs of HTTP headers to add to each successful HTTP status 200 - response. All other status code responses will not include these headers. + Added *response_headers*. A lot of the work, such as parsing the request, is done by the base class :class:`BaseHTTPRequestHandler`. This class implements the :func:`do_GET` @@ -401,6 +399,13 @@ instantiation, of which this module provides three different variants: This dictionary is no longer filled with the default system mappings, but only contains overrides. + .. attribute:: response_headers + + A sequence of ``(name, value)`` pairs containing user specified custom + HTTP response headers to add to each successful HTTP status 200 response. + All other status code responses will not include these headers. + + The :class:`SimpleHTTPRequestHandler` class defines the following methods: .. method:: do_HEAD() @@ -433,8 +438,8 @@ instantiation, of which this module provides three different variants: followed by a ``'Content-Length:'`` header with the file's size and a ``'Last-Modified:'`` header with the file's modification time. - The instance attribute ``response_headers`` is used as an iterable of - name/value pairs to set user specified custom response headers. + The instance attribute ``response_headers`` is a sequence of + ``(name, value)`` pairs containing user specified custom response headers. Then follows a blank line signifying the end of the headers, and then the contents of the file are output. @@ -445,9 +450,6 @@ instantiation, of which this module provides three different variants: .. versionchanged:: 3.7 Support of the ``'If-Modified-Since'`` header. - .. versionchanged:: next - Support ``response_headers`` as an instance argument. - The :class:`SimpleHTTPRequestHandler` class can be used in the following manner in order to create a very basic webserver serving files relative to the current directory:: From 44efbed6f0444c19b3277155d657446966a6a2f1 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 16:01:12 -0700 Subject: [PATCH 18/41] gh-135056: Revert blank line removal in http.server.rst --- Doc/library/http.server.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 01d287f52f052d..d481e2f57198bd 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -556,6 +556,7 @@ The following options are accepted: .. versionadded:: 3.14 + .. _http.server-security: Security considerations From d47c5a7503e9013636cd4faa5ef8dd68abbbd8af Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 16:10:44 -0700 Subject: [PATCH 19/41] gh-135056: Remove incorrect = sign from whatsnew argument entry. --- Doc/whatsnew/3.15.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 7e83fb817a16b4..57e710fffcadb5 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -112,7 +112,7 @@ difflib http.server ----------- -* Added a new ``response_headers=`` keyword argument to +* Added a new ``response_headers`` keyword argument to :class:`SimpleHTTPRequestHandler` to support custom headers in HTTP responses. (Contributed by Anton I. Sipos in :gh:`135057`.) From 8d1286a27ad06568e50a4cf7e95dd7343b2f8e2d Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 16:11:49 -0700 Subject: [PATCH 20/41] gh-135056: Document -H, --header cli params in http.server.rst --- Doc/library/http.server.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index d481e2f57198bd..c42bd1f33dc172 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -556,6 +556,14 @@ The following options are accepted: .. versionadded:: 3.14 +.. option:: -H, --header
+ + Specify an additional custom HTTP Response Header to send on successful HTTP + 200 responses. Can be used multiple times to send additional custom response + headers. + + .. versionadded:: next + .. _http.server-security: From db9de68638664f753bb297bca080b84b0544e9dc Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 16:34:33 -0700 Subject: [PATCH 21/41] gh-135056: Rename custom headers to extra_response_headers. --- Doc/library/http.server.rst | 12 ++++++------ Doc/whatsnew/3.15.rst | 2 +- Lib/http/server.py | 19 +++++++++---------- Lib/test/test_httpservers.py | 20 ++++++++++---------- 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index c42bd1f33dc172..e55e1924571f67 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -362,7 +362,7 @@ instantiation, of which this module provides three different variants: delays, it now always returns the IP address. -.. class:: SimpleHTTPRequestHandler(request, client_address, server, directory=None, response_headers=None) +.. class:: SimpleHTTPRequestHandler(request, client_address, server, directory=None, extra_response_headers=None) This class serves files from the directory *directory* and below, or the current directory if *directory* is not provided, directly @@ -399,9 +399,9 @@ instantiation, of which this module provides three different variants: This dictionary is no longer filled with the default system mappings, but only contains overrides. - .. attribute:: response_headers + .. attribute:: extra_response_headers - A sequence of ``(name, value)`` pairs containing user specified custom + A sequence of ``(name, value)`` pairs containing user specified extra HTTP response headers to add to each successful HTTP status 200 response. All other status code responses will not include these headers. @@ -438,8 +438,8 @@ instantiation, of which this module provides three different variants: followed by a ``'Content-Length:'`` header with the file's size and a ``'Last-Modified:'`` header with the file's modification time. - The instance attribute ``response_headers`` is a sequence of - ``(name, value)`` pairs containing user specified custom response headers. + The instance attribute ``extra_response_headers`` is a sequence of + ``(name, value)`` pairs containing user specified extra response headers. Then follows a blank line signifying the end of the headers, and then the contents of the file are output. @@ -558,7 +558,7 @@ The following options are accepted: .. option:: -H, --header
- Specify an additional custom HTTP Response Header to send on successful HTTP + Specify an additional extra HTTP Response Header to send on successful HTTP 200 responses. Can be used multiple times to send additional custom response headers. diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 57e710fffcadb5..5b0498b054ff5f 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -112,7 +112,7 @@ difflib http.server ----------- -* Added a new ``response_headers`` keyword argument to +* Added a new ``extra_response_headers`` keyword argument to :class:`SimpleHTTPRequestHandler` to support custom headers in HTTP responses. (Contributed by Anton I. Sipos in :gh:`135057`.) diff --git a/Lib/http/server.py b/Lib/http/server.py index c4ca889d9e857a..b9961212ad0ab0 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -692,11 +692,11 @@ class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): '.xz': 'application/x-xz', } - def __init__(self, *args, directory=None, response_headers=None, **kwargs): + def __init__(self, *args, directory=None, extra_response_headers=None, **kwargs): if directory is None: directory = os.getcwd() self.directory = os.fspath(directory) - self.response_headers = response_headers + self.extra_response_headers = extra_response_headers super().__init__(*args, **kwargs) def do_GET(self): @@ -714,11 +714,10 @@ def do_HEAD(self): if f: f.close() - def send_custom_response_headers(self): - """Send the headers stored in self.response_headers""" - # User specified response_headers - if self.response_headers is not None: - for header, value in self.response_headers: + def _send_extra_response_headers(self): + """Send the headers stored in self.extra_response_headers""" + if self.extra_response_headers is not None: + for header, value in self.extra_response_headers: self.send_header(header, value) def send_head(self): @@ -803,7 +802,7 @@ def send_head(self): self.send_header("Content-Length", str(fs[6])) self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) - self.send_custom_response_headers() + self._send_extra_response_headers() self.end_headers() return f except: @@ -868,7 +867,7 @@ def list_directory(self, path): self.send_response(HTTPStatus.OK) self.send_header("Content-type", "text/html; charset=%s" % enc) self.send_header("Content-Length", str(len(encoded))) - self.send_custom_response_headers() + self._send_extra_response_headers() self.end_headers() return f @@ -1076,7 +1075,7 @@ def server_bind(self): def finish_request(self, request, client_address): self.RequestHandlerClass(request, client_address, self, directory=args.directory, - response_headers=args.header) + extra_response_headers=args.header) class HTTPDualStackServer(DualStackServerMixin, ThreadingHTTPServer): pass diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 829deef6164e99..740a47c83b17bd 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -466,9 +466,9 @@ def test_err(self): class CustomHeaderSimpleHTTPRequestHandler(SimpleHTTPRequestHandler): - custom_headers = None - def __init__(self, *args, directory=None, response_headers=None, **kwargs): - super().__init__(*args, response_headers=self.custom_headers, **kwargs) + extra_response_headers = None + def __init__(self, *args, directory=None, extra_response_headers=None, **kwargs): + super().__init__(*args, extra_response_headers=self.extra_response_headers, **kwargs) class SimpleHTTPServerTestCase(BaseTestCase): @@ -829,8 +829,8 @@ def test_path_without_leading_slash(self): self.assertEqual(response.getheader("Location"), self.tempdir_name + "/?hi=1") - def test_custom_headers_list_dir(self): - with mock.patch.object(self.request_handler, 'custom_headers', new=[ + def test_extra_headers_list_dir(self): + with mock.patch.object(self.request_handler, 'extra_response_headers', new=[ ('X-Test1', 'test1'), ('X-Test2', 'test2'), ]): @@ -838,8 +838,8 @@ def test_custom_headers_list_dir(self): self.assertEqual(response.getheader("X-Test1"), 'test1') self.assertEqual(response.getheader("X-Test2"), 'test2') - def test_custom_headers_get_file(self): - with mock.patch.object(self.request_handler, 'custom_headers', new=[ + def test_extra_response_headers_get_file(self): + with mock.patch.object(self.request_handler, 'extra_response_headers', new=[ ('Set-Cookie', 'test1=value1'), ('Set-Cookie', 'test2=value2'), ('X-Test1', 'value3'), @@ -1491,7 +1491,7 @@ def test_unknown_flag(self, _): @mock.patch('http.server._make_server', wraps=server._make_server) @mock.patch.object(HTTPServer, 'serve_forever') - def test_response_headers_arg(self, _, mock_make_server): + def test_extra_response_headers_arg(self, _, mock_make_server): server._main( ['-H', 'Set-Cookie', 'k=v', '-H', 'Set-Cookie', 'k2=v2', '8080'] ) @@ -1508,11 +1508,11 @@ def test_response_headers_arg(self, _, mock_make_server): ) as mock_handler_init: mock_handler_init.return_value = None # finish_request instantiates a request handler class, - # ensure response_headers are passed to it + # ensure extra_response_headers are passed to it httpd.finish_request(mock.Mock(), '127.0.0.1') mock_handler_init.assert_called_once_with( mock.ANY, mock.ANY, mock.ANY, directory=mock.ANY, - response_headers=[ + extra_response_headers=[ ['Set-Cookie', 'k=v'], ['Set-Cookie', 'k2=v2'] ] ) From e149708cff8c51e6442b1c4c68f7d2ba06d1bc61 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 16:45:49 -0700 Subject: [PATCH 22/41] gh-135056: Fix alignment of parameters to _make_server. --- Lib/http/server.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index b9961212ad0ab0..a2ed7e1a9667e3 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -977,15 +977,15 @@ def _get_best_family(*address): def _make_server(HandlerClass=BaseHTTPRequestHandler, - ServerClass=ThreadingHTTPServer, - protocol="HTTP/1.0", port=8000, bind=None, - tls_cert=None, tls_key=None, tls_password=None): + ServerClass=ThreadingHTTPServer, + protocol="HTTP/1.0", port=8000, bind=None, + tls_cert=None, tls_key=None, tls_password=None): ServerClass.address_family, addr = _get_best_family(bind, port) HandlerClass.protocol_version = protocol if tls_cert: return ServerClass(addr, HandlerClass, certfile=tls_cert, - keyfile=tls_key, password=tls_password) + keyfile=tls_key, password=tls_password) else: return ServerClass(addr, HandlerClass) From c16f4c9bb1acde2fee6274b5e3b7658e7c05c514 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 16:48:04 -0700 Subject: [PATCH 23/41] gh-135056: Remove extraneous newline in docstring for test() method --- Lib/http/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index a2ed7e1a9667e3..612e83c624fd3c 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -997,7 +997,6 @@ def test(HandlerClass=BaseHTTPRequestHandler, """Test the HTTP request handler class. This runs an HTTP server on port 8000 (or the port argument). - """ with _make_server( HandlerClass=HandlerClass, ServerClass=ServerClass, From 777b5b6706c6141c8cf956cf30fb0db24a5f3ba1 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 16:51:07 -0700 Subject: [PATCH 24/41] gh-135056: Simplify kwargs to CustomHeaderSimpleHTTPRequestHandler --- Lib/test/test_httpservers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 740a47c83b17bd..84085a1ba0a108 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -467,8 +467,10 @@ def test_err(self): class CustomHeaderSimpleHTTPRequestHandler(SimpleHTTPRequestHandler): extra_response_headers = None - def __init__(self, *args, directory=None, extra_response_headers=None, **kwargs): - super().__init__(*args, extra_response_headers=self.extra_response_headers, **kwargs) + + def __init__(self, *args, **kwargs): + kwargs.setdefault('extra_response_headers', self.extra_response_headers) + super().__init__(*args, **kwargs) class SimpleHTTPServerTestCase(BaseTestCase): From eac5c6a9f55d319cb14b182073f9bcff3107e919 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 16:57:12 -0700 Subject: [PATCH 25/41] gh-135056: Note both -H and --header in NEWS entries. --- Doc/whatsnew/3.15.rst | 2 +- .../next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 5b0498b054ff5f..0d6091fb026aa0 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -116,7 +116,7 @@ http.server :class:`SimpleHTTPRequestHandler` to support custom headers in HTTP responses. (Contributed by Anton I. Sipos in :gh:`135057`.) -* Added a ``--header`` flag to the :program:`python -m http.server` +* Added a ``-H`` or ``--header`` flag to the :program:`python -m http.server` command-line interface to support custom headers in HTTP responses. (Contributed by Anton I. Sipos in :gh:`135057`.) diff --git a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst index 0565260dc443ec..754df083ab1063 100644 --- a/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst +++ b/Misc/NEWS.d/next/Library/2025-06-02-22-23-38.gh-issue-135056.yz3dSs.rst @@ -1,2 +1,2 @@ -Add a ``--header`` CLI option to :program:`python -m http.server`. Contributed by +Add a ``-H`` or ``--header`` CLI option to :program:`python -m http.server`. Contributed by Anton I. Sipos. From c9c8083cf272e5dd886df40c1a99a697bdd0087c Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 17:01:23 -0700 Subject: [PATCH 26/41] gh-135056: Put kwarg on its own line in mock assertion. --- Lib/test/test_httpservers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 84085a1ba0a108..3b4acfb03696bb 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -1513,7 +1513,8 @@ def test_extra_response_headers_arg(self, _, mock_make_server): # ensure extra_response_headers are passed to it httpd.finish_request(mock.Mock(), '127.0.0.1') mock_handler_init.assert_called_once_with( - mock.ANY, mock.ANY, mock.ANY, directory=mock.ANY, + mock.ANY, mock.ANY, mock.ANY, + directory=mock.ANY, extra_response_headers=[ ['Set-Cookie', 'k=v'], ['Set-Cookie', 'k2=v2'] ] From c2d6bb3b8bec2287572d5307cebe1acb9e6ba412 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 17:19:38 -0700 Subject: [PATCH 27/41] gh-135056: Add tests for bad usage of header arg. --- Lib/test/test_httpservers.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 3b4acfb03696bb..9da4d869d85f6c 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -1408,6 +1408,14 @@ def test_header_flag(self, mock_func): mock_func.assert_called_once_with(**call_args) mock_func.reset_mock() + def test_extra_header_flag_too_few_args(self): + with self.assertRaises(SystemExit): + self.invoke_httpd('--header', 'h1') + + def test_extra_header_flag_too_many_args(self): + with self.assertRaises(SystemExit): + self.invoke_httpd('--header', 'h1', 'v1', 'h2') + @unittest.skipIf(ssl is None, "requires ssl") @mock.patch('http.server.test') def test_tls_cert_and_key_flags(self, mock_func): From 3377cf7d406742a4a647496565962875e1a2b421 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 17:56:19 -0700 Subject: [PATCH 28/41] gh-135056: Document SimpleHTTPRequestHandler params as keyword only. --- Doc/library/http.server.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index e55e1924571f67..0480a2f96ff211 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -362,7 +362,8 @@ instantiation, of which this module provides three different variants: delays, it now always returns the IP address. -.. class:: SimpleHTTPRequestHandler(request, client_address, server, directory=None, extra_response_headers=None) +.. class:: SimpleHTTPRequestHandler(request, client_address, server, \ + *, directory=None, extra_response_headers=None) This class serves files from the directory *directory* and below, or the current directory if *directory* is not provided, directly From 06a997786e6a8259cf638ace45fe633ab29fd481 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Mon, 6 Oct 2025 18:13:09 -0700 Subject: [PATCH 29/41] gh-135056: Fix missing renames of extra_response_headers. --- Doc/library/http.server.rst | 2 +- Lib/test/test_httpservers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 0480a2f96ff211..64afe529442dcb 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -376,7 +376,7 @@ instantiation, of which this module provides three different variants: The *directory* parameter accepts a :term:`path-like object`. .. versionchanged:: next - Added *response_headers*. + Added *extra_response_headers*. A lot of the work, such as parsing the request, is done by the base class :class:`BaseHTTPRequestHandler`. This class implements the :func:`do_GET` diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 9da4d869d85f6c..f5287a4f14bd52 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -831,7 +831,7 @@ def test_path_without_leading_slash(self): self.assertEqual(response.getheader("Location"), self.tempdir_name + "/?hi=1") - def test_extra_headers_list_dir(self): + def test_extra_response_headers_list_dir(self): with mock.patch.object(self.request_handler, 'extra_response_headers', new=[ ('X-Test1', 'test1'), ('X-Test2', 'test2'), From be7851534c227af26c3a0f9521d8c476b12fdb46 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 14:53:15 -0700 Subject: [PATCH 30/41] gh-135056: Clarify extra_response_headers is a paramter in http.server.rst version_changed section. --- Doc/library/http.server.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 64afe529442dcb..0a61288ce07a79 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -376,7 +376,7 @@ instantiation, of which this module provides three different variants: The *directory* parameter accepts a :term:`path-like object`. .. versionchanged:: next - Added *extra_response_headers*. + Added *extra_response_headers* parameter. A lot of the work, such as parsing the request, is done by the base class :class:`BaseHTTPRequestHandler`. This class implements the :func:`do_GET` From 53965ff949e9b807d7699596d663f47cbf3e66d0 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 14:56:29 -0700 Subject: [PATCH 31/41] gh-135056: Prefer user-defined to user specified in http.server docs --- Doc/library/http.server.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 0a61288ce07a79..f0fde3b0731a83 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -402,7 +402,7 @@ instantiation, of which this module provides three different variants: .. attribute:: extra_response_headers - A sequence of ``(name, value)`` pairs containing user specified extra + A sequence of ``(name, value)`` pairs containing user-defined extra HTTP response headers to add to each successful HTTP status 200 response. All other status code responses will not include these headers. @@ -440,7 +440,7 @@ instantiation, of which this module provides three different variants: ``'Last-Modified:'`` header with the file's modification time. The instance attribute ``extra_response_headers`` is a sequence of - ``(name, value)`` pairs containing user specified extra response headers. + ``(name, value)`` pairs containing user-defined extra response headers. Then follows a blank line signifying the end of the headers, and then the contents of the file are output. From c280ed852c9df7679acc4fbed7ce856fd165da9f Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 14:59:37 -0700 Subject: [PATCH 32/41] gh-135056: Clarify wording about non-200 response header logic --- Doc/library/http.server.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index f0fde3b0731a83..97132b12b7b55a 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -404,8 +404,7 @@ instantiation, of which this module provides three different variants: A sequence of ``(name, value)`` pairs containing user-defined extra HTTP response headers to add to each successful HTTP status 200 response. - All other status code responses will not include these headers. - + These headers are not included in other status code responses. The :class:`SimpleHTTPRequestHandler` class defines the following methods: From 64122df4e181601122cc0afcdfff6163d9aa690d Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 15:08:52 -0700 Subject: [PATCH 33/41] gh-135056: Keep TLS arguments to _make_server on the same line. --- Lib/http/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index 47cac48a1ea8cf..b73c261a003a01 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1007,8 +1007,8 @@ def test(HandlerClass=BaseHTTPRequestHandler, """ with _make_server( HandlerClass=HandlerClass, ServerClass=ServerClass, - protocol=protocol, port=port, bind=bind, tls_cert=tls_cert, - tls_key=tls_key, tls_password=tls_password + protocol=protocol, port=port, bind=bind, + tls_cert=tls_cert, tls_key=tls_key, tls_password=tls_password ) as httpd: host, port = httpd.socket.getsockname()[:2] url_host = f'[{host}]' if ':' in host else host From f0d1bacf24f03419738b4163d150f913ffa5380a Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 15:10:07 -0700 Subject: [PATCH 34/41] gh-135056: Prefer "specified" to "use" in cli --help text. --- Lib/http/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/http/server.py b/Lib/http/server.py index b73c261a003a01..09c3e51bb03a5e 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -1051,7 +1051,7 @@ def _main(args=None): parser.add_argument('-H', '--header', nargs=2, action='append', metavar=('HEADER', 'VALUE'), help='Add a custom response header ' - '(can be used multiple times)') + '(can be specified multiple times)') args = parser.parse_args(args) if not args.tls_cert and args.tls_key: From e99780e1f429089ff09be0bf5d1e0011342006f7 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 15:13:57 -0700 Subject: [PATCH 35/41] gh-135056: Change new arg to mock.patch.object to positional instead --- Lib/test/test_httpservers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index ff61318265576d..bd5db4f1b63331 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -870,7 +870,7 @@ def test_path_without_leading_slash(self): self.tempdir_name + "/?hi=1") def test_extra_response_headers_list_dir(self): - with mock.patch.object(self.request_handler, 'extra_response_headers', new=[ + with mock.patch.object(self.request_handler, 'extra_response_headers', [ ('X-Test1', 'test1'), ('X-Test2', 'test2'), ]): @@ -879,7 +879,7 @@ def test_extra_response_headers_list_dir(self): self.assertEqual(response.getheader("X-Test2"), 'test2') def test_extra_response_headers_get_file(self): - with mock.patch.object(self.request_handler, 'extra_response_headers', new=[ + with mock.patch.object(self.request_handler, 'extra_response_headers', [ ('Set-Cookie', 'test1=value1'), ('Set-Cookie', 'test2=value2'), ('X-Test1', 'value3'), From 2e829bb0e9b9db079b76b04bbbf3b9af5d8992ff Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 15:14:56 -0700 Subject: [PATCH 36/41] gh-135056: Correct proper 2 line spacing after test class. --- Lib/test/test_httpservers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index bd5db4f1b63331..0e914335ca0958 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -892,6 +892,7 @@ def test_extra_response_headers_get_file(self): 'test1=value1, test2=value2') self.assertEqual(response.getheader("X-Test1"), 'value3') + class SocketlessRequestHandler(SimpleHTTPRequestHandler): def __init__(self, directory=None): request = mock.Mock() From 8baa875591c502da18afccd65973225d0f05693b Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 15:17:12 -0700 Subject: [PATCH 37/41] gh-135056: Assert response.status is 200 in new tests. --- Lib/test/test_httpservers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 0e914335ca0958..f3f88911765f48 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -875,6 +875,7 @@ def test_extra_response_headers_list_dir(self): ('X-Test2', 'test2'), ]): response = self.request(self.base_url + '/') + self.assertEqual(response.status, 200) self.assertEqual(response.getheader("X-Test1"), 'test1') self.assertEqual(response.getheader("X-Test2"), 'test2') @@ -888,6 +889,7 @@ def test_extra_response_headers_get_file(self): with open(os.path.join(self.tempdir_name, 'index.html'), 'wb') as f: f.write(data) response = self.request(self.base_url + '/') + self.assertEqual(response.status, 200) self.assertEqual(response.getheader("Set-Cookie"), 'test1=value1, test2=value2') self.assertEqual(response.getheader("X-Test1"), 'value3') From 7856d271c6cf83f2baced8581708a348928875d3 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 15:26:32 -0700 Subject: [PATCH 38/41] gh-135056: Add test_extra_response_headers_missing_on_404 --- Lib/test/test_httpservers.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index f3f88911765f48..f9f6e5c2154e6a 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -894,6 +894,14 @@ def test_extra_response_headers_get_file(self): 'test1=value1, test2=value2') self.assertEqual(response.getheader("X-Test1"), 'value3') + def test_extra_response_headers_missing_on_404(self): + with mock.patch.object(self.request_handler, 'extra_response_headers', [ + ('X-Test1', 'value'), + ]): + response = self.request(self.base_url + '/missing.html') + self.assertEqual(response.status, 404) + self.assertEqual(response.getheader("X-Test1"), None) + class SocketlessRequestHandler(SimpleHTTPRequestHandler): def __init__(self, directory=None): From 303ab5bca9e49b28754ddcf256c6e7003be45f23 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 17:34:20 -0700 Subject: [PATCH 39/41] gh-135056: Augment header test case to check colons and spaces --- Lib/test/test_httpservers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index f9f6e5c2154e6a..111b198d070aef 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -1552,7 +1552,7 @@ def test_unknown_flag(self, _): @mock.patch.object(HTTPServer, 'serve_forever') def test_extra_response_headers_arg(self, _, mock_make_server): server._main( - ['-H', 'Set-Cookie', 'k=v', '-H', 'Set-Cookie', 'k2=v2', '8080'] + ['-H', 'Set-Cookie', 'k=v', '-H', 'Set-Cookie', 'k2=v2:v3 v4', '8080'] ) # Get an instance of the server / RequestHandler by using # the spied call args, then calling _make_server with them. @@ -1573,7 +1573,7 @@ def test_extra_response_headers_arg(self, _, mock_make_server): mock.ANY, mock.ANY, mock.ANY, directory=mock.ANY, extra_response_headers=[ - ['Set-Cookie', 'k=v'], ['Set-Cookie', 'k2=v2'] + ['Set-Cookie', 'k=v'], ['Set-Cookie', 'k2=v2:v3 v4'] ] ) From ed0b0b34c59b235b6459c4a81a9daae595cd96cd Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 21:53:11 -0700 Subject: [PATCH 40/41] gh-135056: Fix ReST fully qualified ref to SimpleHTTPRequestHandler --- Doc/whatsnew/3.15.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index d47d5c4c79e43b..554b8d33260263 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -378,7 +378,8 @@ http.server ----------- * Added a new ``extra_response_headers`` keyword argument to - :class:`SimpleHTTPRequestHandler` to support custom headers in HTTP responses. + :class:`~http.server.SimpleHTTPRequestHandler` to support custom headers in + HTTP responses. (Contributed by Anton I. Sipos in :gh:`135057`.) * Added a ``-H`` or ``--header`` flag to the :program:`python -m http.server` From 79c577b4f9f491669c63f7b41a75574a2d43cbd0 Mon Sep 17 00:00:00 2001 From: "Anton I. Sipos" Date: Thu, 9 Oct 2025 22:41:54 -0700 Subject: [PATCH 41/41] gh-135056: Fix socket closing in test_extra_response_headers_arg --- Lib/test/test_httpservers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 111b198d070aef..7f7f737f3c967e 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -1558,6 +1558,7 @@ def test_extra_response_headers_arg(self, _, mock_make_server): # the spied call args, then calling _make_server with them. args, kwargs = mock_make_server.call_args httpd = server._make_server(*args, **kwargs) + self.addCleanup(httpd.server_close) # Ensure the RequestHandler class is passed the correct response # headers