Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ What's New?
in development
^^^^^^^^^^^^^^

* When option ``-W/--warnings-as-errors`` is used, pydoctor exits with code 3 when it issues any warning.

pydoctor 25.10.1
^^^^^^^^^^^^^^^^

Expand Down
4 changes: 2 additions & 2 deletions pydoctor/_configparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
import functools
import configparser
from ast import literal_eval
import warnings

from configargparse import ConfigFileParserException, ConfigFileParser, ArgumentParser

Expand Down Expand Up @@ -435,7 +434,8 @@ def parse(self, stream:TextIO) -> Dict[str, Any]:
action = known_config_keys.get(key)
if not action:
# Warn "no such config option"
warnings.warn(f"No such config option: {key!r}")
from pydoctor.utils import warn
warn(f"No such config option: {key!r}")
# Remove option
else:
new_data[key] = value
Expand Down
4 changes: 2 additions & 2 deletions pydoctor/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pathlib import Path

from pydoctor.options import Options, BUILDTIME_FORMAT
from pydoctor.utils import error
from pydoctor.utils import error, warned
from pydoctor import model
from pydoctor.templatewriter import IWriter, TemplateLookup, TemplateError
from pydoctor.sphinx import SphinxInventoryWriter, prepareCache
Expand Down Expand Up @@ -184,7 +184,7 @@ def p(msg: str) -> None:
elif any(system.parse_errors.values()):
exitcode = 2

if system.violations and options.warnings_as_errors:
if options.warnings_as_errors and (system.violations or warned()):
# Update exit code if the run has produced warnings.
exitcode = 3

Expand Down
15 changes: 4 additions & 11 deletions pydoctor/epydoc/sre_parse36.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,14 +796,8 @@ def _parse(source, state, verbose, nested, first=False):
flags = _parse_flags(source, state, char)
if flags is None: # global flags
if not first or subpattern:
import warnings
warnings.warn(
'Flags not at the start of the expression %r%s' % (
source.string[:20], # truncate long regexes
' (truncated)' if len(source.string) > 20 else '',
),
DeprecationWarning, stacklevel=nested + 6
)
# changed: we don't trigger deprecated warning here
pass
if (state.flags & SRE_FLAG_VERBOSE) and not verbose:
raise Verbose
continue
Expand Down Expand Up @@ -1008,9 +1002,8 @@ def addgroup(index, pos):
this = chr(ESCAPES[this][1])
except KeyError:
if c in ASCIILETTERS:
import warnings
warnings.warn('bad escape %s' % this,
DeprecationWarning, stacklevel=4)
# changed: we don't trigger deprecated warning here
pass
lappend(this)
else:
lappend(this)
Expand Down
7 changes: 3 additions & 4 deletions pydoctor/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import re
from typing import NamedTuple, Sequence, List, Optional, Type, Tuple, TYPE_CHECKING
import sys
import functools
from pathlib import Path
from argparse import SUPPRESS, Namespace
Expand Down Expand Up @@ -287,9 +286,9 @@ def _warn_deprecated_options(options: Namespace) -> None:
Check the CLI options and warn on deprecated options.
"""
if options.enable_intersphinx_cache_deprecated:
print("The --enable-intersphinx-cache option is deprecated; "
"the cache is now enabled by default.",
file=sys.stderr, flush=True)
from pydoctor.utils import warn
warn("The --enable-intersphinx-cache option is deprecated; "
"the cache is now enabled by default.")

# CONVERTERS

Expand Down
10 changes: 6 additions & 4 deletions pydoctor/templatewriter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ def runtime_checkable(f):
return f
import abc
from pathlib import Path, PurePath
import warnings
from xml.dom import minidom

# Newer APIs from importlib_resources should arrive to stdlib importlib.resources in Python 3.9.
Expand Down Expand Up @@ -241,7 +240,8 @@ def _extract_version(dom: minidom.Document, template_name: str) -> int:
meta.parentNode.removeChild(meta)

if not meta.hasAttribute("content"):
warnings.warn(f"Could not read '{template_name}' template version: "
from pydoctor.utils import warn
warn(f"Could not read '{template_name}' template version: "
f"the 'content' attribute is missing")
continue

Expand All @@ -250,7 +250,8 @@ def _extract_version(dom: minidom.Document, template_name: str) -> int:
try:
version = int(version_str)
except ValueError:
warnings.warn(f"Could not read '{template_name}' template version: "
from pydoctor.utils import warn
warn(f"Could not read '{template_name}' template version: "
"the 'content' attribute must be an integer")
else:
break
Expand Down Expand Up @@ -295,7 +296,8 @@ def _add_overriding_html_template(self, template: HtmlTemplate, current_template
template_version = template.version
if default_version != -1 and template_version != -1:
if template_version < default_version:
warnings.warn(f"Your custom template '{template.name}' is out of date, "
from pydoctor.utils import warn
warn(f"Your custom template '{template.name}' is out of date, "
"information might be missing. "
"Latest templates are available to download from our github." )
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, a link would be more actionable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I agree, but the chalenge is to provide the right link. And that depends on the current pydoctor version, so this would add more complexity

elif template_version > default_version:
Expand Down
4 changes: 2 additions & 2 deletions pydoctor/templatewriter/util.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Miscellaneous utilities for the HTML writer."""
from __future__ import annotations

import warnings
from typing import (Any, Callable, Dict, Generic, Iterable, Iterator, List, Mapping,
Optional, MutableMapping, Tuple, TypeVar, Union, Sequence, TYPE_CHECKING)
from pydoctor import epydoc2stan
Expand Down Expand Up @@ -164,7 +163,8 @@ def inherited_members(cls: model.Class) -> List[model.Documentable]:

def templatefile(filename: str) -> None:
"""Deprecated: can be removed once Twisted stops patching this."""
warnings.warn("pydoctor.templatewriter.util.templatefile() "
from pydoctor.utils import warn
warn("pydoctor.templatewriter.util.templatefile() "
"is deprecated and returns None. It will be remove in future versions. "
"Please use the templating system.")
return None
Expand Down
60 changes: 60 additions & 0 deletions pydoctor/test/test_commandline.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path
import re
import sys
import warnings

import pytest

Expand Down Expand Up @@ -343,3 +344,62 @@ def test_html_ids_dont_look_like_python_names(tmp_path: Path) -> None:
assert re.findall(r'id="[a-z]+"', text, re.IGNORECASE) == ['id="basic"'], text
else:
assert re.findall(r'id="[a-z]+"', text, re.IGNORECASE) == [], text

def test_no_such_option_exits_code0(tmp_path: Path) -> None:
"""
When no such option is used in the config file it just ignores it and
continues normally, whith a warning message printed to stderr.
"""

tmp_path.mkdir(parents=True, exist_ok=True)
conf_file = (tmp_path / "pydoctor_temp_conf")
with conf_file.open('w') as f:
f.write("[pydoctor]\nno-such-option = somevalue\n")

with warnings.catch_warnings(record=True) as w:
exit_code = driver.main(args=[
'--config', str(conf_file),
'--html-output', str(tmp_path / 'output'),
'pydoctor/test/testpackages/basic/'
])

assert exit_code == 0
assert [str(warn.message) for warn in w] == ["No such config option: 'no-such-option'"]

def test_warnings_as_errors_configured_from_config_file_no_such_option_exits_code3(tmp_path: Path) -> None:
"""
When `warnings-as-errors = true` is used it returns 3 as exit code when there are warnings.

We demonstrate this using a non existing configuration keyword
"""

tmp_path.mkdir(parents=True, exist_ok=True)
conf_file = (tmp_path / "pydoctor_temp_conf")
with conf_file.open('w') as f:
f.write("[pydoctor]\nno-such-option = somevalue\nwarnings-as-errors = true\n")

with warnings.catch_warnings(record=True) as w:
exit_code = driver.main(args=[
'--config', str(conf_file),
'--html-output', str(tmp_path / 'output'),
'pydoctor/test/testpackages/basic/'
])

assert exit_code == 3
assert [str(warn.message) for warn in w] == ["No such config option: 'no-such-option'"]

def test_warnings_as_errors_configured_from_cli_option_no_such_option_exits_code3(tmp_path: Path) -> None:
"""
When `-W` is used it returns 3 as exit code when there are warnings.

We demonstrate this using deprecated option --enable-intersphinx-cache.
"""
with warnings.catch_warnings(record=True) as w:
exit_code = driver.main(args=[
'-W', '--enable-intersphinx-cache',
'--html-output', str(tmp_path / 'output'),
'pydoctor/test/testpackages/basic/'
])

assert exit_code == 3
assert [str(warn.message) for warn in w] == ["The --enable-intersphinx-cache option is deprecated; the cache is now enabled by default."]
22 changes: 22 additions & 0 deletions pydoctor/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pathlib import Path
import sys
import functools
import contextvars
from typing import Any, Type, TypeVar, Tuple, Union, cast, TYPE_CHECKING

if TYPE_CHECKING:
Expand Down Expand Up @@ -103,3 +104,24 @@ class NewPartialCls(cls):
__class__ = cls
assert isinstance(NewPartialCls, type)
return NewPartialCls

class PydoctorWarning(UserWarning):
"""
Base class for all warnings emitted by pydoctor thru the L{warnings} module.
"""

_warned = contextvars.ContextVar('warned', default=False)

def warn(msg: str) -> None:
"""
Emit a pydoctor warning message.
"""
import warnings
warnings.warn(msg, category=PydoctorWarning)
_warned.set(True)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels a little delicate that all warnings emitted by pydoctor need to go through this function for the error status to track. There's nothing beyond familiarity with the codebase to guide future contributions down this path.

Could this be made more robust? Perhaps a lint rule could forbid direct use of warnings.warn()?

Copy link
Contributor Author

@tristanlatr tristanlatr Nov 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don’t have this kind of custom linting in place at the moment. I suppose this can be implemented with a semgrep pattern. But do you have another idea to enforce this ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps flake8-tidy-imports banned-api? I've used it to banish os.system() before.


def warned() -> bool:
"""
Return whether any pydoctor warning has been emitted.
"""
return _warned.get()
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ addopts = --doctest-glob='*.doctest' --doctest-modules --ignore-glob='*/testpack
doctest_optionflags = ELLIPSIS IGNORE_EXCEPTION_DETAIL
xfail_strict = true
filterwarnings =
default::pydoctor.utils.PydoctorWarning
error

[tool:pydoctor]
Expand Down
Loading