diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 063344e0284258..6bcd05e8870d11 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -362,17 +362,23 @@ 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 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 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` @@ -428,6 +434,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 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. @@ -437,6 +446,9 @@ 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:: @@ -543,7 +555,6 @@ The following options are accepted: .. versionadded:: 3.14 - .. _http.server-security: Security considerations 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 ---- diff --git a/Lib/http/server.py b/Lib/http/server.py index ef10d185932633..c4ca889d9e857a 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -692,10 +692,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 super().__init__(*args, **kwargs) def do_GET(self): @@ -713,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: + self.send_header(header, value) + def send_head(self): """Common code for GET and HEAD commands. @@ -795,6 +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)) + self.send_custom_response_headers() self.end_headers() return f except: @@ -859,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 @@ -967,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) - with server as httpd: + +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 _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' @@ -1024,6 +1043,10 @@ def _main(args=None): parser.add_argument('port', default=8000, type=int, nargs='?', help='bind to this port ' '(default: %(default)s)') + parser.add_argument('-H', '--header', nargs=2, action='append', + 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: @@ -1052,7 +1075,8 @@ def server_bind(self): def finish_request(self, request, client_address): self.RequestHandlerClass(request, client_address, self, - directory=args.directory) + directory=args.directory, + response_headers=args.header) class HTTPDualStackServer(DualStackServerMixin, ThreadingHTTPServer): pass diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 2548a7c5f292f0..829deef6164e99 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -465,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): @@ -823,6 +829,28 @@ 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=[ + ('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("Set-Cookie"), + 'test1=value1, test2=value2') + self.assertEqual(response.getheader("X-Test1"), 'value3') class SocketlessRequestHandler(SimpleHTTPRequestHandler): def __init__(self, directory=None): @@ -1371,6 +1399,13 @@ 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_header_flag(self, mock_func): + call_args = self.args + self.invoke_httpd('--header', 'h1', 'v1', '-H', '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): @@ -1454,6 +1489,34 @@ def test_unknown_flag(self, _): self.assertEqual(stdout.getvalue(), '') self.assertIn('error', stderr.getvalue()) + @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'] + ] + ) + class CommandLineRunTimeTestCase(unittest.TestCase): served_data = os.urandom(32) 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..0565260dc443ec --- /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 ``--header`` CLI option to :program:`python -m http.server`. Contributed by +Anton I. Sipos.