240 lines
8.9 KiB
Python
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())
|