Skip to content

Commit 2c8f26c

Browse files
gh-146292: Add colour to http.server logs (GH-146293)
Co-authored-by: Brian Schubert <brianm.schubert@gmail.com>
1 parent 7563585 commit 2c8f26c

File tree

8 files changed

+124
-12
lines changed

8 files changed

+124
-12
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/astral-sh/ruff-pre-commit
3-
rev: a27a2e47c7751b639d2b5badf0ef6ff11fee893f # frozen: v0.15.4
3+
rev: e05c5c0818279e5ac248ac9e954431ba58865e61 # frozen: v0.15.7
44
hooks:
55
- id: ruff-check
66
name: Run Ruff (lint) on Platforms/Apple/

Doc/whatsnew/3.15.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,17 @@ http.cookies
822822
(Contributed by Nick Burns and Senthil Kumaran in :gh:`92936`.)
823823

824824

825+
http.server
826+
-----------
827+
828+
* The logging of :mod:`~http.server.BaseHTTPRequestHandler`,
829+
as used by the :ref:`command-line interface <http-server-cli>`,
830+
is colored by default.
831+
This can be controlled with :ref:`environment variables
832+
<using-on-controlling-color>`.
833+
(Contributed by Hugo van Kemenade in :gh:`146292`.)
834+
835+
825836
inspect
826837
-------
827838

Lib/_colorize.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,22 @@ class FancyCompleter(ThemeSection):
223223
str: str = ANSIColors.BOLD_GREEN
224224

225225

226+
@dataclass(frozen=True, kw_only=True)
227+
class HttpServer(ThemeSection):
228+
error: str = ANSIColors.YELLOW
229+
path: str = ANSIColors.CYAN
230+
serving: str = ANSIColors.GREEN
231+
size: str = ANSIColors.GREY
232+
status_informational: str = ANSIColors.RESET
233+
status_ok: str = ANSIColors.GREEN
234+
status_redirect: str = ANSIColors.INTENSE_CYAN
235+
status_client_error: str = ANSIColors.YELLOW
236+
status_server_error: str = ANSIColors.RED
237+
timestamp: str = ANSIColors.GREY
238+
url: str = ANSIColors.CYAN
239+
reset: str = ANSIColors.RESET
240+
241+
226242
@dataclass(frozen=True, kw_only=True)
227243
class LiveProfiler(ThemeSection):
228244
"""Theme section for the live profiling TUI (Tachyon profiler).
@@ -378,6 +394,7 @@ class Theme:
378394
argparse: Argparse = field(default_factory=Argparse)
379395
difflib: Difflib = field(default_factory=Difflib)
380396
fancycompleter: FancyCompleter = field(default_factory=FancyCompleter)
397+
http_server: HttpServer = field(default_factory=HttpServer)
381398
live_profiler: LiveProfiler = field(default_factory=LiveProfiler)
382399
syntax: Syntax = field(default_factory=Syntax)
383400
traceback: Traceback = field(default_factory=Traceback)
@@ -389,6 +406,7 @@ def copy_with(
389406
argparse: Argparse | None = None,
390407
difflib: Difflib | None = None,
391408
fancycompleter: FancyCompleter | None = None,
409+
http_server: HttpServer | None = None,
392410
live_profiler: LiveProfiler | None = None,
393411
syntax: Syntax | None = None,
394412
traceback: Traceback | None = None,
@@ -403,6 +421,7 @@ def copy_with(
403421
argparse=argparse or self.argparse,
404422
difflib=difflib or self.difflib,
405423
fancycompleter=fancycompleter or self.fancycompleter,
424+
http_server=http_server or self.http_server,
406425
live_profiler=live_profiler or self.live_profiler,
407426
syntax=syntax or self.syntax,
408427
traceback=traceback or self.traceback,
@@ -421,6 +440,7 @@ def no_colors(cls) -> Self:
421440
argparse=Argparse.no_colors(),
422441
difflib=Difflib.no_colors(),
423442
fancycompleter=FancyCompleter.no_colors(),
443+
http_server=HttpServer.no_colors(),
424444
live_profiler=LiveProfiler.no_colors(),
425445
syntax=Syntax.no_colors(),
426446
traceback=Traceback.no_colors(),

Lib/http/server.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@
8585

8686
from http import HTTPStatus
8787

88+
lazy import _colorize
89+
8890

8991
# Default error message template
9092
DEFAULT_ERROR_MESSAGE = """\
@@ -574,6 +576,31 @@ def flush_headers(self):
574576
self.wfile.write(b"".join(self._headers_buffer))
575577
self._headers_buffer = []
576578

579+
def _colorize_request(self, code, size, t):
580+
try:
581+
code_int = int(code)
582+
except (TypeError, ValueError):
583+
code_color = ""
584+
else:
585+
if code_int >= 500:
586+
code_color = t.status_server_error
587+
elif code_int >= 400:
588+
code_color = t.status_client_error
589+
elif code_int >= 300:
590+
code_color = t.status_redirect
591+
elif code_int >= 200:
592+
code_color = t.status_ok
593+
else:
594+
code_color = t.status_informational
595+
596+
request_line = self.requestline.translate(self._control_char_table)
597+
parts = request_line.split(None, 2)
598+
if len(parts) == 3:
599+
method, path, version = parts
600+
request_line = f"{method} {t.path}{path}{t.reset} {version}"
601+
602+
return f'"{request_line}" {code_color}{code} {t.size}{size}{t.reset}'
603+
577604
def log_request(self, code='-', size='-'):
578605
"""Log an accepted request.
579606
@@ -582,6 +609,7 @@ def log_request(self, code='-', size='-'):
582609
"""
583610
if isinstance(code, HTTPStatus):
584611
code = code.value
612+
self._log_request_info = (code, size)
585613
self.log_message('"%s" %s %s',
586614
self.requestline, str(code), str(size))
587615

@@ -596,7 +624,7 @@ def log_error(self, format, *args):
596624
XXX This should go to the separate error log.
597625
598626
"""
599-
627+
self._log_is_error = True
600628
self.log_message(format, *args)
601629

602630
# https://en.wikipedia.org/wiki/List_of_Unicode_characters#Control_codes
@@ -623,12 +651,22 @@ def log_message(self, format, *args):
623651
before writing the output to stderr.
624652
625653
"""
626-
627-
message = format % args
628-
sys.stderr.write("%s - - [%s] %s\n" %
629-
(self.address_string(),
630-
self.log_date_time_string(),
631-
message.translate(self._control_char_table)))
654+
message = (format % args).translate(self._control_char_table)
655+
t = _colorize.get_theme(tty_file=sys.stderr).http_server
656+
657+
info = getattr(self, "_log_request_info", None)
658+
if info is not None:
659+
self._log_request_info = None
660+
message = self._colorize_request(*info, t)
661+
elif getattr(self, "_log_is_error", False):
662+
self._log_is_error = False
663+
message = f"{t.error}{message}{t.reset}"
664+
665+
sys.stderr.write(
666+
f"{t.timestamp}{self.address_string()} - - "
667+
f"[{self.log_date_time_string()}]{t.reset} "
668+
f"{message}\n"
669+
)
632670

633671
def version_string(self):
634672
"""Return the server software version string."""
@@ -994,9 +1032,11 @@ def test(HandlerClass=BaseHTTPRequestHandler,
9941032
host, port = httpd.socket.getsockname()[:2]
9951033
url_host = f'[{host}]' if ':' in host else host
9961034
protocol = 'HTTPS' if tls_cert else 'HTTP'
1035+
t = _colorize.get_theme().http_server
1036+
url = f"{protocol.lower()}://{url_host}:{port}/"
9971037
print(
998-
f"Serving {protocol} on {host} port {port} "
999-
f"({protocol.lower()}://{url_host}:{port}/) ..."
1038+
f"{t.serving}Serving {protocol} on {host} port {port}{t.reset} "
1039+
f"({t.url}{url}{t.reset}) ..."
10001040
)
10011041
try:
10021042
httpd.serve_forever()

Lib/test/.ruff.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
extend = "../../.ruff.toml" # Inherit the project-wide settings
22

33
# Unlike Tools/, tests can use newer syntax than PYTHON_FOR_REGEN
4-
target-version = "py314"
4+
target-version = "py315"
55

66
extend-exclude = [
77
# Excluded (run with the other AC files in its own separate ruff job in pre-commit)

Lib/test/test_httpservers.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@
2828
import threading
2929
from unittest import mock
3030
from io import BytesIO, StringIO
31+
from _colorize import get_theme
3132

3233
import unittest
3334
from test import support
3435
from test.support import (
36+
force_not_colorized,
3537
is_apple, import_helper, os_helper, threading_helper
3638
)
3739
from test.support.script_helper import kill_python, spawn_python
@@ -480,6 +482,7 @@ def do_GET(self):
480482
def do_ERROR(self):
481483
self.send_error(HTTPStatus.NOT_FOUND, 'File not found')
482484

485+
@force_not_colorized
483486
def test_get(self):
484487
self.con = http.client.HTTPConnection(self.HOST, self.PORT)
485488
self.con.connect()
@@ -490,6 +493,7 @@ def test_get(self):
490493

491494
self.assertEndsWith(err.getvalue(), '"GET / HTTP/1.1" 200 -\n')
492495

496+
@force_not_colorized
493497
def test_err(self):
494498
self.con = http.client.HTTPConnection(self.HOST, self.PORT)
495499
self.con.connect()
@@ -503,6 +507,39 @@ def test_err(self):
503507
self.assertEndsWith(lines[1], '"ERROR / HTTP/1.1" 404 -')
504508

505509

510+
@support.force_colorized_test_class
511+
class RequestHandlerColorizedLoggingTestCase(RequestHandlerLoggingTestCase):
512+
513+
def test_get(self):
514+
t = get_theme(force_color=True).http_server
515+
self.con = http.client.HTTPConnection(self.HOST, self.PORT)
516+
self.con.connect()
517+
518+
with support.captured_stderr() as err:
519+
self.con.request("GET", "/")
520+
self.con.getresponse()
521+
522+
output = err.getvalue()
523+
self.assertIn(f"{t.path}/{t.reset}", output)
524+
self.assertIn(f"{t.status_ok}200", output)
525+
self.assertIn(t.reset, output)
526+
527+
def test_err(self):
528+
t = get_theme(force_color=True).http_server
529+
self.con = http.client.HTTPConnection(self.HOST, self.PORT)
530+
self.con.connect()
531+
532+
with support.captured_stderr() as err:
533+
self.con.request("ERROR", "/")
534+
self.con.getresponse()
535+
536+
lines = err.getvalue().split("\n")
537+
self.assertIn(
538+
f"{t.error}code 404, message File not found{t.reset}", lines[0]
539+
)
540+
self.assertIn(f"{t.status_client_error}404", lines[1])
541+
542+
506543
class SimpleHTTPServerTestCase(BaseTestCase):
507544
class request_handler(NoLogRequestHandler, SimpleHTTPRequestHandler):
508545
pass
@@ -935,6 +972,7 @@ def verify_http_server_response(self, response):
935972
match = self.HTTPResponseMatch.search(response)
936973
self.assertIsNotNone(match)
937974

975+
@force_not_colorized
938976
def test_unprintable_not_logged(self):
939977
# We call the method from the class directly as our Socketless
940978
# Handler subclass overrode it... nice for everything BUT this test.

Lib/test/test_wsgiref.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from unittest import mock
22
from test import support
3-
from test.support import socket_helper, control_characters_c0
3+
from test.support import force_not_colorized, socket_helper, control_characters_c0
44
from test.test_httpservers import NoLogRequestHandler
55
from unittest import TestCase
66
from wsgiref.util import setup_testing_defaults
@@ -192,6 +192,7 @@ def bad_app(e,s):
192192
err.splitlines()[-2], "AssertionError"
193193
)
194194

195+
@force_not_colorized
195196
def test_bytes_validation(self):
196197
def app(e, s):
197198
s("200 OK", [
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add colour to :mod:`~http.server.BaseHTTPRequestHandler` logs, as used by
2+
the :mod:`http.server` CLI. Patch by Hugo van Kemenade.

0 commit comments

Comments
 (0)