Source code for click_extra.sphinx.fail_on_warnings

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Fail a Sphinx build on the warning types a project lists, and only on those.

Sphinx fails a build on warnings all or nothing: `--fail-on-warning` counts
every warning, and `suppress_warnings` can hide a type but never make one
fatal. A project that lives with some warnings, like the unresolved references
autodoc produces for third-party names, then cannot stop the build on the ones
it does not accept.

The case this module was written for is `myst.xref_missing`. myst-parser
resolves every `[text](#anchor)` and `[text](page.md#anchor)` link against what
the build produced. For a link it cannot place, it logs that warning and ships
the link as written anyway, so a dead fragment reaches the published site
behind a green build.

A handler on the `sphinx` logger records each warning whose type the project
lists in {data}`FAIL_ON_WARNINGS_CONFIG`. Once the build is over, it reports
them in one error and sets the exit status to `1`. The build itself runs to
its end, so every offending warning is reported, not only the first.

```{todo}
Contribute this upstream as a Sphinx config value, on
[sphinx-doc/sphinx#7949](https://github.com/sphinx-doc/sphinx/issues/7949),
which asks for the same feature and has waited for a design since 2020.

Sphinx already holds both halves. `sphinx.util.logging.is_suppressed_warning`
is the matching this module reuses, and the exit status `--fail-on-warning`
sets is the outcome. A config value read where
`sphinx.util.logging.WarningSuppressor` already counts each warning would
replace this module.

Should it land, keep this module as a shim for the Sphinx releases that
predate it, then drop it once click-extra's Sphinx floor moves past them.
```
"""

from __future__ import annotations

import logging

from sphinx.util.logging import NAMESPACE, getLogger, is_suppressed_warning

TYPE_CHECKING = False
if TYPE_CHECKING:
    from sphinx.application import Sphinx


logger = getLogger(__name__)


FAIL_ON_WARNINGS_CONFIG = "click_extra_fail_on_warnings"
"""Name of the `conf.py` value listing the warning types that fail the build.

Entries use the syntax of `suppress_warnings`: `myst` covers every subtype,
`myst.xref_missing` only that one. Empty by default, which leaves Sphinx's
behavior untouched: which warnings a project can live with is its own call.
"""


[docs] class ListedWarningCollector(logging.Handler): """Collect the warnings whose type the project listed as fatal. Attached to the `sphinx` logger rather than to Sphinx's warning stream, so it sees every warning, whatever `--quiet` does to the console. A warning that `suppress_warnings` hides is skipped, the same way `--fail-on-warning` skips it. """ def __init__(self, app: Sphinx) -> None: super().__init__(level=logging.WARNING) self.app = app self.matched: dict[logging.LogRecord, str] = {} """The `type.subtype` label of each matched record. Sphinx buffers the warnings of a build phase and replays them through every handler when the phase ends, after this handler saw them live. A record hashes by identity, so keying on it counts each one once. """
[docs] def emit(self, record: logging.LogRecord) -> None: """Keep `record` if its type is listed and not suppressed.""" warning_type = getattr(record, "type", "") or "" subtype = getattr(record, "subtype", "") or "" if not warning_type: return listed = getattr(self.app.config, FAIL_ON_WARNINGS_CONFIG) suppressed = self.app.config.suppress_warnings if not is_suppressed_warning(warning_type, subtype, listed): return if is_suppressed_warning(warning_type, subtype, suppressed): return self.matched[record] = f"{warning_type}.{subtype}" if subtype else warning_type
[docs] def finish(self, app: Sphinx, exception: Exception | None) -> None: """Detach, then fail the build if a listed warning was logged.""" logging.getLogger(NAMESPACE).removeHandler(self) if exception is not None or not self.matched: return types = sorted(set(self.matched.values())) count = len(self.matched) logger.error( "click_extra.sphinx: %d warning%s of a type listed in %s (%s).", count, "" if count == 1 else "s", FAIL_ON_WARNINGS_CONFIG, ", ".join(types), ) app.statuscode = 1
[docs] def setup(app: Sphinx) -> None: """Register the config value and attach a collector for `app`. Called from {func}`click_extra.sphinx.setup` so projects only need to list `"click_extra.sphinx"` in their `extensions`. The collector reports at priority 900, after the default 500 of other `build-finished` handlers, so a warning one of them logs still counts. """ app.add_config_value( FAIL_ON_WARNINGS_CONFIG, default=[], rebuild="", types=[list, tuple] ) collector = ListedWarningCollector(app) logging.getLogger(NAMESPACE).addHandler(collector) app.connect("build-finished", collector.finish, priority=900)