https://github.com/python/cpython/commit/2c8f26cf5c6ff2398f1e1ed8bfe18e9938bbdebd
commit: 2c8f26cf5c6ff2398f1e1ed8bfe18e9938bbdebd
branch: main
author: Hugo van Kemenade <[email protected]>
committer: ambv <[email protected]>
date: 2026-04-08T14:10:05+02:00
summary:

gh-146292: Add colour to `http.server` logs (GH-146293)

Co-authored-by: Brian Schubert <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-03-22-16-52-04.gh-issue-146292.rJvvs0.rst
M .pre-commit-config.yaml
M Doc/whatsnew/3.15.rst
M Lib/_colorize.py
M Lib/http/server.py
M Lib/test/.ruff.toml
M Lib/test/test_httpservers.py
M Lib/test/test_wsgiref.py

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6e612ce232fb29..c77610e209ebbd 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 Platforms/Apple/
diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst
index 08a0ba7a68768d..71870a38a6a8d6 100644
--- a/Doc/whatsnew/3.15.rst
+++ b/Doc/whatsnew/3.15.rst
@@ -822,6 +822,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 <http-server-cli>`,
+  is colored by default.
+  This can be controlled with :ref:`environment variables
+  <using-on-controlling-color>`.
+  (Contributed by Hugo van Kemenade in :gh:`146292`.)
+
+
 inspect
 -------
 
diff --git a/Lib/_colorize.py b/Lib/_colorize.py
index 8361ddbea89716..bd2070ea97d370 100644
--- a/Lib/_colorize.py
+++ b/Lib/_colorize.py
@@ -223,6 +223,22 @@ class FancyCompleter(ThemeSection):
     str: str = ANSIColors.BOLD_GREEN
 
 
+@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).
@@ -378,6 +394,7 @@ class Theme:
     argparse: Argparse = field(default_factory=Argparse)
     difflib: Difflib = field(default_factory=Difflib)
     fancycompleter: FancyCompleter = field(default_factory=FancyCompleter)
+    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)
@@ -389,6 +406,7 @@ def copy_with(
         argparse: Argparse | None = None,
         difflib: Difflib | None = None,
         fancycompleter: FancyCompleter | None = None,
+        http_server: HttpServer | None = None,
         live_profiler: LiveProfiler | None = None,
         syntax: Syntax | None = None,
         traceback: Traceback | None = None,
@@ -403,6 +421,7 @@ def copy_with(
             argparse=argparse or self.argparse,
             difflib=difflib or self.difflib,
             fancycompleter=fancycompleter or self.fancycompleter,
+            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,
@@ -421,6 +440,7 @@ def no_colors(cls) -> Self:
             argparse=Argparse.no_colors(),
             difflib=Difflib.no_colors(),
             fancycompleter=FancyCompleter.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 -')
 
 
[email protected]_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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to