From 2cdecb740555bd5499a666c031b8f843962b7918 Mon Sep 17 00:00:00 2001 From: Julien Palard Date: Wed, 12 Nov 2025 23:50:24 +0100 Subject: [PATCH 1/3] Parsing docstrings too. --- sphinxlint/sphinxlint.py | 14 +++++++++++--- sphinxlint/utils.py | 20 ++++++++++++++++++++ tests/test_py2rst.py | 17 +++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 tests/test_py2rst.py diff --git a/sphinxlint/sphinxlint.py b/sphinxlint/sphinxlint.py index ec699dbf9..37bd2064c 100644 --- a/sphinxlint/sphinxlint.py +++ b/sphinxlint/sphinxlint.py @@ -1,8 +1,8 @@ from collections import Counter -from dataclasses import dataclass +from dataclasses import dataclass, replace from os.path import splitext -from sphinxlint.utils import PER_FILE_CACHES, hide_non_rst_blocks, po2rst +from sphinxlint.utils import PER_FILE_CACHES, hide_non_rst_blocks, po2rst, py2rst @dataclass(frozen=True) @@ -63,7 +63,15 @@ def check_file(filename, checkers, options: CheckersOptions = None): return [f"{filename}: cannot open: {err}"] except UnicodeDecodeError as err: return [f"{filename}: cannot decode as UTF-8: {err}"] - return check_text(filename, text, checkers, options) + errors = check_text(filename, text, checkers, options) + if filename.endswith(".py"): + errors += [ + replace(error, filename=error.filename[:-4]) + for error in check_text( + filename + ".rst", py2rst(text), checkers, options + ) + ] + return errors finally: for memoized_function in PER_FILE_CACHES: memoized_function.cache_clear() diff --git a/sphinxlint/utils.py b/sphinxlint/utils.py index a539ca092..cd925f134 100644 --- a/sphinxlint/utils.py +++ b/sphinxlint/utils.py @@ -1,5 +1,6 @@ """Just a bunch of utility functions for sphinxlint.""" +import ast from functools import lru_cache import regex as re @@ -230,3 +231,22 @@ def po2rst(text): for line in entry.msgstr.splitlines(): output.append(line + "\n") return "".join(output) + + +def py2rst(text): + output = [] + tree = ast.parse(text) + for node in ast.walk(tree): + try: + docstring = ast.get_docstring(node, clean=True) + except TypeError: + continue + if docstring is None: + continue + if not hasattr(node, "lineno"): + node.lineno = 1 + while len(output) + 1 <= node.lineno: + output.append("\n") + for line in docstring.splitlines(): + output.append(line + "\n") + return "".join(output) diff --git a/tests/test_py2rst.py b/tests/test_py2rst.py new file mode 100644 index 000000000..f30cb3a6b --- /dev/null +++ b/tests/test_py2rst.py @@ -0,0 +1,17 @@ +from sphinxlint.utils import py2rst + + +def test_py2rst(): + py = '''#!/usr/bin/env python +"""Hello from the module docstring!!!""" + +def foo(): + """Hello from a function docstring!!!""" +''' + rst = """ +Hello from the module docstring!!! + + +Hello from a function docstring!!! +""" + assert py2rst(py) == rst From 78b3880fdbf292d15491854c9f6989af5cbed051 Mon Sep 17 00:00:00 2001 From: Julien Palard Date: Thu, 13 Nov 2025 11:27:23 +0100 Subject: [PATCH 2/3] Adding tests. --- .../class-docstring-containing-valid-rst.py | 16 ++++++++++++++++ .../xpass/docstrings-containing-plain-text.py | 8 ++++++++ 2 files changed, 24 insertions(+) create mode 100644 tests/fixtures/xpass/class-docstring-containing-valid-rst.py create mode 100644 tests/fixtures/xpass/docstrings-containing-plain-text.py diff --git a/tests/fixtures/xpass/class-docstring-containing-valid-rst.py b/tests/fixtures/xpass/class-docstring-containing-valid-rst.py new file mode 100644 index 000000000..db6285894 --- /dev/null +++ b/tests/fixtures/xpass/class-docstring-containing-valid-rst.py @@ -0,0 +1,16 @@ +class Youpi: + """Dielectric profile calculation + ============================== + + In the following example, we will show how to calculate the + dielectric profiles as described in + :ref:`dielectric-explanations`. + + Before producing trajectories to calculate dielectric profiles, + you will need to consider which information you will need and thus + need to print out. The dielectric profile calculators need + unwrapped positions and charges of **all** charged atoms in the + system. Unwrapped refers to the fact that you will need either + "repaired" molecules (which in GROMACS ``trjconv`` with the ``-pbc + mol`` option can do for you) or you will + """ diff --git a/tests/fixtures/xpass/docstrings-containing-plain-text.py b/tests/fixtures/xpass/docstrings-containing-plain-text.py new file mode 100644 index 000000000..acbbe7223 --- /dev/null +++ b/tests/fixtures/xpass/docstrings-containing-plain-text.py @@ -0,0 +1,8 @@ +class TestPlanarBaseChilds: + """Tests for the AnalayseBase child classes.""" + + ignored_parameters = ["atomgroup", "wrap_compound"] + + def test_parameters(self): + """Test if AnalysisBase paramaters exist in all modules.""" + pass From bb54c113546e4e9e2d546e9646b78a39df519ff6 Mon Sep 17 00:00:00 2001 From: Julien Palard Date: Thu, 13 Nov 2025 11:51:28 +0100 Subject: [PATCH 3/3] Add --check-docstrings --- pyproject.toml | 5 +++++ sphinxlint/cli.py | 7 +++++- sphinxlint/sphinxlint.py | 6 +++-- tests/fixtures/xfail/docstring.py | 6 +++++ tests/test_sphinxlint.py | 37 +++++++++++++++++++++++++------ 5 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/xfail/docstring.py diff --git a/pyproject.toml b/pyproject.toml index 8cb3b23e2..09cf54ac5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,9 +52,14 @@ packages = [ "sphinxlint" ] [tool.hatch.version.raw-options] local_scheme = "no-local-version" +[tool.black] + [tool.ruff] fix = true +[tool.isort] +profile = "black" + format.preview = true lint.select = [ "E", # pycodestyle errors diff --git a/sphinxlint/cli.py b/sphinxlint/cli.py index 241208a29..8b986bf7e 100644 --- a/sphinxlint/cli.py +++ b/sphinxlint/cli.py @@ -132,6 +132,11 @@ def job_count(values): "Values <= 1 are all considered 1.", default=StoreNumJobsAction.job_count("auto"), ) + parser.add_argument( + "--check-docstrings", + action="store_true", + help="Also check docstrings in Python files.", + ) parser.add_argument( "-V", "--version", action="version", version=f"%(prog)s {__version__}" ) @@ -228,7 +233,7 @@ def main(argv=None): return 2 todo = [ - (path, enabled_checkers, options) + (path, enabled_checkers, options, args.check_docstrings) for path in chain.from_iterable(walk(path, args.ignore) for path in args.paths) ] diff --git a/sphinxlint/sphinxlint.py b/sphinxlint/sphinxlint.py index 37bd2064c..7a7008262 100644 --- a/sphinxlint/sphinxlint.py +++ b/sphinxlint/sphinxlint.py @@ -49,7 +49,9 @@ def check_text(filename, text, checkers, options=None): return errors -def check_file(filename, checkers, options: CheckersOptions = None): +def check_file( + filename, checkers, options: CheckersOptions | None = None, check_docstrings=False +): try: ext = splitext(filename)[1] if not any(ext in checker.suffixes for checker in checkers): @@ -64,7 +66,7 @@ def check_file(filename, checkers, options: CheckersOptions = None): except UnicodeDecodeError as err: return [f"{filename}: cannot decode as UTF-8: {err}"] errors = check_text(filename, text, checkers, options) - if filename.endswith(".py"): + if check_docstrings and filename.endswith(".py"): errors += [ replace(error, filename=error.filename[:-4]) for error in check_text( diff --git a/tests/fixtures/xfail/docstring.py b/tests/fixtures/xfail/docstring.py new file mode 100644 index 000000000..b01eb0f22 --- /dev/null +++ b/tests/fixtures/xfail/docstring.py @@ -0,0 +1,6 @@ +# expect: default role used + +"""This is a module docstring. + +Containing BAD rst, like a `default` role. +""" diff --git a/tests/test_sphinxlint.py b/tests/test_sphinxlint.py index 377b2e1bc..2ebabb5e7 100644 --- a/tests/test_sphinxlint.py +++ b/tests/test_sphinxlint.py @@ -1,7 +1,7 @@ +import re from pathlib import Path import pytest - from sphinxlint.cli import main from sphinxlint.utils import paragraphs @@ -10,7 +10,9 @@ @pytest.mark.parametrize("file", [str(f) for f in (FIXTURE_DIR / "xpass").iterdir()]) def test_sphinxlint_shall_pass(file, capsys): - has_errors = main(["sphinxlint.py", "--enable", "all", str(file)]) + has_errors = main( + ["sphinxlint.py", "--check-docstrings", "--enable", "all", str(file)] + ) out, err = capsys.readouterr() assert err == "" assert out == "No problems found.\n" @@ -26,7 +28,9 @@ def test_sphinxlint_shall_trigger_false_positive(file, capsys): assert out == "No problems found.\n" assert err == "" assert not has_errors - has_errors = main(["sphinxlint.py", "--enable", "all", str(file)]) + has_errors = main( + ["sphinxlint.py", "--check-docstrings", "--enable", "all", str(file)] + ) out, err = capsys.readouterr() assert out != "No problems found.\n" assert err != "" @@ -39,18 +43,18 @@ def gather_xfail(): Each file is searched for lines containing expcted errors, they are starting with `.. expect: `. """ - marker = ".. expect: " + marker = re.compile(r"(\.\.|#) expect: ") for file in (FIXTURE_DIR / "xfail").iterdir(): expected_errors = [] for line in Path(file).read_text(encoding="UTF-8").splitlines(): - if line.startswith(marker): - expected_errors.append(line[len(marker) :]) + if match := marker.match(line): + expected_errors.append(line[len(match[0]) :]) yield str(file), expected_errors @pytest.mark.parametrize("file,expected_errors", gather_xfail()) def test_sphinxlint_shall_not_pass(file, expected_errors, capsys): - has_errors = main(["sphinxlint.py", "--enable", "all", file]) + has_errors = main(["sphinxlint.py", "--check-docstrings", "--enable", "all", file]) out, err = capsys.readouterr() assert out != "No problems found.\n" assert err != "" @@ -68,6 +72,25 @@ def test_sphinxlint_shall_not_pass(file, expected_errors, capsys): ), f"{number_of_reported_errors=}, {err=}" +def test_check_docstrings_flag(subtests): + for file in (FIXTURE_DIR / "xfail").glob("*.py"): + with subtests.test(msg="with file", file=file): + with subtests.test(msg="With --check-docstrings"): + has_errors = main( + [ + "sphinxlint.py", + "--check-docstrings", + "--enable", + "all", + str(file), + ] + ) + assert has_errors + with subtests.test(msg="Without --check-docstrings"): + has_errors = main(["sphinxlint.py", "--enable", "all", str(file)]) + assert not has_errors + + @pytest.mark.parametrize("file", [str(FIXTURE_DIR / "paragraphs.rst")]) def test_paragraphs(file): with open(file) as f: