diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dfd18182105e11..854e7a39b56678 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: a27a2e47c7751b639d2b5badf0ef6ff11fee893f # frozen: v0.15.4 + rev: e05c5c0818279e5ac248ac9e954431ba58865e61 # frozen: v0.15.7 hooks: - id: ruff-check name: Run Ruff (lint) on Apple/ diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 5e6265a45231db..02fc1b857cc46b 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -795,6 +795,17 @@ http.cookies (Contributed by Nick Burns and Senthil Kumaran in :gh:`92936`.) +http.server +----------- + +* The logging of :mod:`~http.server.BaseHTTPRequestHandler`, + as used by the :ref:`command-line interface `, + is colored by default. + This can be controlled with :ref:`environment variables + `. + (Contributed by Hugo van Kemenade in :gh:`146292`.) + + inspect ------- diff --git a/Lib/_colorize.py b/Lib/_colorize.py index fd0ae9d6145961..894d79a00fd69c 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -200,6 +200,22 @@ class Difflib(ThemeSection): reset: str = ANSIColors.RESET +@dataclass(frozen=True, kw_only=True) +class HttpServer(ThemeSection): + error: str = ANSIColors.YELLOW + path: str = ANSIColors.CYAN + serving: str = ANSIColors.GREEN + size: str = ANSIColors.GREY + status_informational: str = ANSIColors.RESET + status_ok: str = ANSIColors.GREEN + status_redirect: str = ANSIColors.INTENSE_CYAN + status_client_error: str = ANSIColors.YELLOW + status_server_error: str = ANSIColors.RED + timestamp: str = ANSIColors.GREY + url: str = ANSIColors.CYAN + reset: str = ANSIColors.RESET + + @dataclass(frozen=True, kw_only=True) class LiveProfiler(ThemeSection): """Theme section for the live profiling TUI (Tachyon profiler). @@ -354,6 +370,7 @@ class Theme: """ argparse: Argparse = field(default_factory=Argparse) difflib: Difflib = field(default_factory=Difflib) + http_server: HttpServer = field(default_factory=HttpServer) live_profiler: LiveProfiler = field(default_factory=LiveProfiler) syntax: Syntax = field(default_factory=Syntax) traceback: Traceback = field(default_factory=Traceback) @@ -364,6 +381,7 @@ def copy_with( *, argparse: Argparse | None = None, difflib: Difflib | None = None, + http_server: HttpServer | None = None, live_profiler: LiveProfiler | None = None, syntax: Syntax | None = None, traceback: Traceback | None = None, @@ -377,6 +395,7 @@ def copy_with( return type(self)( argparse=argparse or self.argparse, difflib=difflib or self.difflib, + http_server=http_server or self.http_server, live_profiler=live_profiler or self.live_profiler, syntax=syntax or self.syntax, traceback=traceback or self.traceback, @@ -394,6 +413,7 @@ def no_colors(cls) -> Self: return cls( argparse=Argparse.no_colors(), difflib=Difflib.no_colors(), + http_server=HttpServer.no_colors(), live_profiler=LiveProfiler.no_colors(), syntax=Syntax.no_colors(), traceback=Traceback.no_colors(), diff --git a/Lib/http/server.py b/Lib/http/server.py index 9c9cfbce421343..568d3bb38deb6c 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -85,6 +85,8 @@ from http import HTTPStatus +lazy import _colorize + # Default error message template DEFAULT_ERROR_MESSAGE = """\ @@ -574,6 +576,31 @@ def flush_headers(self): self.wfile.write(b"".join(self._headers_buffer)) self._headers_buffer = [] + def _colorize_request(self, code, size, t): + try: + code_int = int(code) + except (TypeError, ValueError): + code_color = "" + else: + if code_int >= 500: + code_color = t.status_server_error + elif code_int >= 400: + code_color = t.status_client_error + elif code_int >= 300: + code_color = t.status_redirect + elif code_int >= 200: + code_color = t.status_ok + else: + code_color = t.status_informational + + request_line = self.requestline.translate(self._control_char_table) + parts = request_line.split(None, 2) + if len(parts) == 3: + method, path, version = parts + request_line = f"{method} {t.path}{path}{t.reset} {version}" + + return f'"{request_line}" {code_color}{code} {t.size}{size}{t.reset}' + def log_request(self, code='-', size='-'): """Log an accepted request. @@ -582,6 +609,7 @@ def log_request(self, code='-', size='-'): """ if isinstance(code, HTTPStatus): code = code.value + self._log_request_info = (code, size) self.log_message('"%s" %s %s', self.requestline, str(code), str(size)) @@ -596,7 +624,7 @@ def log_error(self, format, *args): XXX This should go to the separate error log. """ - + self._log_is_error = True self.log_message(format, *args) # https://en.wikipedia.org/wiki/List_of_Unicode_characters#Control_codes @@ -623,12 +651,22 @@ def log_message(self, format, *args): before writing the output to stderr. """ - - message = format % args - sys.stderr.write("%s - - [%s] %s\n" % - (self.address_string(), - self.log_date_time_string(), - message.translate(self._control_char_table))) + message = (format % args).translate(self._control_char_table) + t = _colorize.get_theme(tty_file=sys.stderr).http_server + + info = getattr(self, "_log_request_info", None) + if info is not None: + self._log_request_info = None + message = self._colorize_request(*info, t) + elif getattr(self, "_log_is_error", False): + self._log_is_error = False + message = f"{t.error}{message}{t.reset}" + + sys.stderr.write( + f"{t.timestamp}{self.address_string()} - - " + f"[{self.log_date_time_string()}]{t.reset} " + f"{message}\n" + ) def version_string(self): """Return the server software version string.""" @@ -994,9 +1032,11 @@ def test(HandlerClass=BaseHTTPRequestHandler, host, port = httpd.socket.getsockname()[:2] url_host = f'[{host}]' if ':' in host else host protocol = 'HTTPS' if tls_cert else 'HTTP' + t = _colorize.get_theme().http_server + url = f"{protocol.lower()}://{url_host}:{port}/" print( - f"Serving {protocol} on {host} port {port} " - f"({protocol.lower()}://{url_host}:{port}/) ..." + f"{t.serving}Serving {protocol} on {host} port {port}{t.reset} " + f"({t.url}{url}{t.reset}) ..." ) try: httpd.serve_forever() diff --git a/Lib/test/.ruff.toml b/Lib/test/.ruff.toml index f3e6a46663e100..a960543f277935 100644 --- a/Lib/test/.ruff.toml +++ b/Lib/test/.ruff.toml @@ -1,7 +1,7 @@ extend = "../../.ruff.toml" # Inherit the project-wide settings # Unlike Tools/, tests can use newer syntax than PYTHON_FOR_REGEN -target-version = "py314" +target-version = "py315" extend-exclude = [ # Excluded (run with the other AC files in its own separate ruff job in pre-commit) diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 0dc5c9dbaed5d8..d78b94e3a373d4 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -28,10 +28,12 @@ import threading from unittest import mock from io import BytesIO, StringIO +from _colorize import get_theme import unittest from test import support from test.support import ( + force_not_colorized, is_apple, import_helper, os_helper, threading_helper ) from test.support.script_helper import kill_python, spawn_python @@ -480,6 +482,7 @@ def do_GET(self): def do_ERROR(self): self.send_error(HTTPStatus.NOT_FOUND, 'File not found') + @force_not_colorized def test_get(self): self.con = http.client.HTTPConnection(self.HOST, self.PORT) self.con.connect() @@ -490,6 +493,7 @@ def test_get(self): self.assertEndsWith(err.getvalue(), '"GET / HTTP/1.1" 200 -\n') + @force_not_colorized def test_err(self): self.con = http.client.HTTPConnection(self.HOST, self.PORT) self.con.connect() @@ -503,6 +507,39 @@ def test_err(self): self.assertEndsWith(lines[1], '"ERROR / HTTP/1.1" 404 -') +@support.force_colorized_test_class +class RequestHandlerColorizedLoggingTestCase(RequestHandlerLoggingTestCase): + + def test_get(self): + t = get_theme(force_color=True).http_server + self.con = http.client.HTTPConnection(self.HOST, self.PORT) + self.con.connect() + + with support.captured_stderr() as err: + self.con.request("GET", "/") + self.con.getresponse() + + output = err.getvalue() + self.assertIn(f"{t.path}/{t.reset}", output) + self.assertIn(f"{t.status_ok}200", output) + self.assertIn(t.reset, output) + + def test_err(self): + t = get_theme(force_color=True).http_server + self.con = http.client.HTTPConnection(self.HOST, self.PORT) + self.con.connect() + + with support.captured_stderr() as err: + self.con.request("ERROR", "/") + self.con.getresponse() + + lines = err.getvalue().split("\n") + self.assertIn( + f"{t.error}code 404, message File not found{t.reset}", lines[0] + ) + self.assertIn(f"{t.status_client_error}404", lines[1]) + + class SimpleHTTPServerTestCase(BaseTestCase): class request_handler(NoLogRequestHandler, SimpleHTTPRequestHandler): pass @@ -935,6 +972,7 @@ def verify_http_server_response(self, response): match = self.HTTPResponseMatch.search(response) self.assertIsNotNone(match) + @force_not_colorized def test_unprintable_not_logged(self): # We call the method from the class directly as our Socketless # Handler subclass overrode it... nice for everything BUT this test. diff --git a/Lib/test/test_wsgiref.py b/Lib/test/test_wsgiref.py index 3379df37d38ca8..32ef0ccf4e638d 100644 --- a/Lib/test/test_wsgiref.py +++ b/Lib/test/test_wsgiref.py @@ -1,6 +1,6 @@ from unittest import mock from test import support -from test.support import socket_helper, control_characters_c0 +from test.support import force_not_colorized, socket_helper, control_characters_c0 from test.test_httpservers import NoLogRequestHandler from unittest import TestCase from wsgiref.util import setup_testing_defaults @@ -192,6 +192,7 @@ def bad_app(e,s): err.splitlines()[-2], "AssertionError" ) + @force_not_colorized def test_bytes_validation(self): def app(e, s): s("200 OK", [ diff --git a/Misc/NEWS.d/next/Library/2026-03-22-16-52-04.gh-issue-146292.rJvvs0.rst b/Misc/NEWS.d/next/Library/2026-03-22-16-52-04.gh-issue-146292.rJvvs0.rst new file mode 100644 index 00000000000000..40f3b386155cfc --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-22-16-52-04.gh-issue-146292.rJvvs0.rst @@ -0,0 +1,2 @@ +Add colour to :mod:`~http.server.BaseHTTPRequestHandler` logs, as used by +the :mod:`http.server` CLI. Patch by Hugo van Kemenade.