diff --git a/CHANGES.rst b/CHANGES.rst index b02c10934ec..f551c76ec48 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -20,6 +20,9 @@ Bugs fixed Patch by Vinay Sajip. * #11622: Ensure that the order of keys in ``searchindex.js`` is deterministic. Patch by Pietro Albini. +* #11617: ANSI control sequences are stripped from the output when writing to + a warnings file with :option:`-w `. + Patch by Bénédikt Tran. Testing ------- diff --git a/doc/man/sphinx-build.rst b/doc/man/sphinx-build.rst index 908017e1964..654a851ec53 100644 --- a/doc/man/sphinx-build.rst +++ b/doc/man/sphinx-build.rst @@ -195,6 +195,10 @@ Options Write warnings (and errors) to the given file, in addition to standard error. + .. versionchanged:: 7.3 + + ANSI control sequences are stripped when writing to *file*. + .. option:: -W Turn warnings into errors. This means that the build stops at the first diff --git a/sphinx/cmd/build.py b/sphinx/cmd/build.py index 64452c2b10c..9f31ffe72be 100644 --- a/sphinx/cmd/build.py +++ b/sphinx/cmd/build.py @@ -21,7 +21,7 @@ from sphinx.application import Sphinx from sphinx.errors import SphinxError, SphinxParallelError from sphinx.locale import __ -from sphinx.util import Tee +from sphinx.util._io import TeeStripANSI from sphinx.util.console import ( # type: ignore[attr-defined] color_terminal, nocolor, @@ -34,6 +34,11 @@ if TYPE_CHECKING: from collections.abc import Sequence + from typing import Protocol + + class SupportsWrite(Protocol): + def write(self, text: str, /) -> int | None: + ... def handle_exception( @@ -266,11 +271,12 @@ def _parse_logging( try: warnfile = path.abspath(warnfile) ensuredir(path.dirname(warnfile)) + # the caller is responsible for closing this file descriptor warnfp = open(warnfile, 'w', encoding="utf-8") # NoQA: SIM115 except Exception as exc: parser.error(__('cannot open warning file %r: %s') % ( warnfile, exc)) - warning = Tee(warning, warnfp) # type: ignore[assignment] + warning = TeeStripANSI(warning, warnfp) # type: ignore[assignment] error = warning return status, warning, error, warnfp @@ -334,6 +340,10 @@ def build_main(argv: Sequence[str]) -> int: except (Exception, KeyboardInterrupt) as exc: handle_exception(app, args, exc, args.error) return 2 + finally: + if warnfp is not None: + # close the file descriptor for the warnings file opened by Sphinx + warnfp.close() def _bug_report_info() -> int: diff --git a/sphinx/util/_io.py b/sphinx/util/_io.py index 23d525685ee..1dc111c0cd7 100644 --- a/sphinx/util/_io.py +++ b/sphinx/util/_io.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from sphinx.util.console import _strip_escape_sequences @@ -6,7 +8,7 @@ from typing import Protocol class SupportsWrite(Protocol): - def write(self, text: str, /) -> None: + def write(self, text: str, /) -> int | None: ... diff --git a/tests/test_build.py b/tests/test_build.py index ed4bc43895e..1fb2f7585e6 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -2,11 +2,14 @@ import os import shutil +from contextlib import contextmanager +from pathlib import Path from unittest import mock import pytest from docutils import nodes +from sphinx.cmd.build import build_main from sphinx.errors import SphinxError @@ -133,3 +136,29 @@ def test_image_glob(app, status, warning): assert doctree[0][3][0]['candidates'] == {'application/pdf': 'subdir/svgimg.pdf', 'image/svg+xml': 'subdir/svgimg.svg'} assert doctree[0][3][0]['uri'] == 'subdir/svgimg.*' + + +@contextmanager +def force_colors(): + forcecolor = os.environ.get('FORCE_COLOR', None) + + try: + os.environ['FORCE_COLOR'] = '1' + yield + finally: + if forcecolor is None: + os.environ.pop('FORCE_COLOR', None) + else: + os.environ['FORCE_COLOR'] = forcecolor + + +def test_log_no_ansi_colors(tmp_path): + with force_colors(): + wfile = tmp_path / 'warnings.txt' + srcdir = Path(__file__).parent / 'roots/test-nitpicky-warnings' + argv = list(map(str, ['-b', 'html', srcdir, tmp_path, '-n', '-w', wfile])) + retcode = build_main(argv) + assert retcode == 0 + + content = wfile.read_text(encoding='utf8') + assert '\x1b[91m' not in content