Files

240 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Thin wrapper around the ``mkdocs`` CLI that silences two specific kinds
of noise produced by the upstream tooling without affecting anything else.
Two suppressions are applied to the combined stdout/stderr of the spawned
``mkdocs`` process:
1. **The Material for MkDocs 2.0 marketing banner.** Material has been
printing a multi-line "Warning from the Material for MkDocs team"
advisory on *every* MkDocs invocation since early 2026. It is not a
build warning — it is a project announcement. It cannot be suppressed
by configuration in any released version of the theme. We recognise
it by its distinctive opening line and drop it.
2. **Spurious ``code 400`` warnings from the LiveReload dev server.**
When MkDocs serves on ``0.0.0.0`` (so the docs are reachable from
another host on the network), clients occasionally connect via
``https://`` first — typically a browser autoconplete trying TLS, or
an opportunistic port scanner. Their TLS ``ClientHello`` bytes hit
the plain-HTTP server, which returns ``400 Bad Request`` and logs the
garbled bytes as a multi-line WARNING. These have nothing to do with
the documentation; they are pure HTTP-vs-HTTPS protocol noise. We
recognise them by the ``code 400`` suffix and drop them.
Every other line of MkDocs output is passed through verbatim. Real
warnings (e.g. ``code 404``, ``code 500``, broken-link warnings, build
errors) are unaffected. Signal handling is preserved so that Ctrl-C in
``nox -s serve`` shuts MkDocs down cleanly.
Usage:
python scripts/quiet_mkdocs.py <mkdocs subcommand and args>
Example (replacing ``python -m mkdocs serve …``):
python scripts/quiet_mkdocs.py serve -a 0.0.0.0:8000
Invoked from ``noxfile.py`` for the ``build``, ``build-html-layperson``,
``serve``, and ``serve-layperson`` sessions.
"""
from __future__ import annotations
import re
import signal
import subprocess
import sys
from typing import TextIO
# ---------------------------------------------------------------------------
# Patterns identifying the noise to suppress
# ---------------------------------------------------------------------------
# Opening line of the Material team's banner. The exact glyph and prefix
# style ("\u2502" + " \u26a0 Warning from the Material for MkDocs team")
# is what Material prints; the regex is permissive about surrounding
# whitespace.
_BANNER_START = re.compile(
r"^\s*\u2502\s+\u26a0\s+Warning from the Material for MkDocs team",
)
# Continuation line of the banner — every interior banner line is prefixed
# with the same vertical-bar glyph.
_BANNER_LINE = re.compile(r"^\s*\u2502")
# ANSI escape sequence stripper. Material colourises the banner with ANSI
# codes (e.g. ``\x1b[31m`` for red), which would otherwise prevent the
# patterns above from matching. We strip them before testing.
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
# Opening of any MkDocs LiveReload access-log warning. We do not drop
# every such line — only the ones whose status code is 400 (see below).
_WARNING_OPEN = re.compile(
r'^WARNING\s*-\s*\[[\d:]+\]\s+"',
)
# The closing fragment that identifies the spurious 400 lines. Real
# warnings about missing files come through as ``code 404`` and are not
# suppressed.
_CODE_400 = re.compile(r"code\s+400\s*$")
# ---------------------------------------------------------------------------
# Stateful line filter
# ---------------------------------------------------------------------------
class _Filter:
"""Line-oriented filter. Implemented as a tiny state machine.
States:
* ``normal`` — pass lines through; watch for the start of a banner
or a multi-line 400-warning block.
* ``banner`` — inside the Material banner; discard everything that
continues the banner (prefixed lines and blank lines) until a
non-banner, non-blank line restores ``normal``.
* ``warning_buffer`` — a multi-line WARNING is unfolding; buffer
all lines until we can decide whether it is the 400 noise we
want to drop, or a legitimate warning that should be emitted
(in which case we flush the buffer).
"""
def __init__(self, out: TextIO) -> None:
self._out = out
self._state = "normal"
self._buffer: list[str] = []
# -----------------------------------------------------------------------
def feed(self, line: str) -> None:
"""Process one input line."""
# Strip terminal newline and ANSI escape sequences for *matching*.
# We keep the raw ``line`` for output verbatim — the user's
# terminal can render the colours just fine; we only need a
# cleaned copy to decide what to suppress.
stripped = _ANSI_RE.sub("", line).rstrip("\r\n")
if self._state == "banner":
# Stay in the banner while lines remain prefixed or blank;
# any other line means the banner has ended (and that line is
# legitimate output to emit).
if _BANNER_LINE.match(stripped) or stripped == "":
return
self._state = "normal"
# Fall through to process this line as normal.
if self._state == "warning_buffer":
# A continuation line of a multi-line LiveReload warning is
# indented (the LiveReload formatter aligns continuation
# bytes under the opening quote). Decide whether the
# *current* line is part of the warning BEFORE buffering it.
is_continuation = line.startswith(" ") or line.startswith("\t")
if is_continuation:
self._buffer.append(line)
if _CODE_400.search(stripped):
# Confirmed 400 — drop the entire buffered block.
self._buffer.clear()
self._state = "normal"
return
# The previous WARNING was actually a single legitimate
# warning. Emit the buffered single line, leave
# warning_buffer mode, and let the current line fall through
# to normal processing below.
self._flush_buffer()
self._state = "normal"
# Intentional fall-through.
# state == "normal"
if _BANNER_START.match(stripped):
self._state = "banner"
return
if _WARNING_OPEN.match(stripped):
if _CODE_400.search(stripped):
# Single-line 400 warning — drop without trace.
return
# Otherwise begin buffering in case it is multi-line 400 noise.
self._state = "warning_buffer"
self._buffer = [line]
return
# Default: pass through.
self._out.write(line)
self._out.flush()
# -----------------------------------------------------------------------
def close(self) -> None:
"""Emit any pending buffer at end-of-stream."""
self._flush_buffer()
# -----------------------------------------------------------------------
def _flush_buffer(self) -> None:
for buffered in self._buffer:
self._out.write(buffered)
if self._buffer:
self._out.flush()
self._buffer.clear()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def _install_signal_forwarding(proc: subprocess.Popen) -> None:
"""Forward SIGINT / SIGTERM to the child so Ctrl-C shuts MkDocs down."""
def _forward(signum: int, _frame) -> None:
try:
proc.send_signal(signum)
except (ProcessLookupError, OSError):
pass
signal.signal(signal.SIGINT, _forward)
signal.signal(signal.SIGTERM, _forward)
def main(argv: list[str] | None = None) -> int:
args = list(argv if argv is not None else sys.argv[1:])
if not args:
sys.stderr.write(
"quiet_mkdocs: missing mkdocs subcommand\n"
"usage: quiet_mkdocs.py <mkdocs subcommand and args>\n",
)
return 2
cmd = [sys.executable, "-m", "mkdocs", *args]
# Combine stderr into stdout so a single line iterator preserves the
# natural interleaving of MkDocs's banner (stderr) and access log
# (also stderr) without race conditions. line-buffered (bufsize=1)
# text mode ensures we get one line per ``readline``.
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
_install_signal_forwarding(proc)
assert proc.stdout is not None
filt = _Filter(sys.stdout)
try:
for line in proc.stdout:
filt.feed(line)
except KeyboardInterrupt:
# The signal handler already forwarded SIGINT; just wait below.
pass
finally:
filt.close()
return proc.wait()
if __name__ == "__main__":
sys.exit(main())