#!/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 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 \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())