From d209102f58eb3d0c3854264c9a7e2004d5309db1 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:27:54 +0200 Subject: [PATCH] Reconcile three validate/repair rule conflicts from the qh sweep (closes #31) - normalizer.blank_lines_between_blocks no longer inserts a blank line between a Google section header and its immediate doctest/list body: napoleon renders both identically, so the blank line only existed to trip ruff's D412 that repair itself had just satisfied. - pydoclint's --arg-type-hints-in-signature is now true, matching the house convention (types live in the signature): the false value told pydoclint to expect NO type hints there, so DOC108 fired on every annotated function. - ruff's D107 (every __init__ needs a docstring) is dropped from the selection: it is the mirror image of pydoclint's DOC301 default (__init__ must not have one, Args merge into the class docstring), which is the convention epythet-docstring-style already documents. Confirmed via docutils/napoleon doctree inspection that the D412 blank line changes nothing about how Sphinx renders the section. --- .../skills/epythet-docstring-style/SKILL.md | 2 +- epythet/normalizer.py | 19 +++- epythet/validation/lint.py | 21 +++- .../section_header_then_doctest_untouched.in | 5 + .../section_header_then_doctest_untouched.out | 5 + .../section_header_then_list_untouched.in | 5 + .../section_header_then_list_untouched.out | 5 + tests/test_lint_conflicts.py | 102 ++++++++++++++++++ 8 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 tests/normalizer_fixtures/section_header_then_doctest_untouched.in create mode 100644 tests/normalizer_fixtures/section_header_then_doctest_untouched.out create mode 100644 tests/normalizer_fixtures/section_header_then_list_untouched.in create mode 100644 tests/normalizer_fixtures/section_header_then_list_untouched.out create mode 100644 tests/test_lint_conflicts.py diff --git a/epythet/data/skills/epythet-docstring-style/SKILL.md b/epythet/data/skills/epythet-docstring-style/SKILL.md index 41a198f..74daec1 100644 --- a/epythet/data/skills/epythet-docstring-style/SKILL.md +++ b/epythet/data/skills/epythet-docstring-style/SKILL.md @@ -38,7 +38,7 @@ What epythet's normalizer repairs at build time (so existing code renders, not s **Tier 2, entry point** (a name in `__all__` or re-exported from `__init__`): a disambiguating summary; two to four sentences of intent (what problem it solves); `Args:` with semantics, units, ranges, default behaviour; `Returns:` saying what the value is and how it is keyed or ordered; `Raises:` with trigger conditions; **at least one runnable doctest** for the common case plus one variation; `See Also:` naming 1 to 3 adjacent callables with how each differs; a when-to-use sentence if a near-neighbour exists. Target: 15 to 40 lines. -**Tier 3, complex class**: everything in Tier 2 on the class docstring (not `__init__`); `Attributes:` with invariants; a lifecycle sketch (construct, configure, use, tear down) as a doctest; state invariants (what mutates, what is safe to reuse); methods at Tier 1 or 2. Target: 40 to 80 lines on the class. +**Tier 3, complex class**: everything in Tier 2 on the class docstring (not `__init__`); `Attributes:` with invariants; a lifecycle sketch (construct, configure, use, tear down) as a doctest; state invariants (what mutates, what is safe to reuse); methods at Tier 1 or 2. Target: 40 to 80 lines on the class. `__init__` itself stays undocumented — its `Args:` live on the class docstring, never duplicated. `epythet validate` enforces only this direction (pydoclint's `DOC301`); the opposing pydocstyle rule (`D107`, "every `__init__` needs its own docstring") is dropped from the ruff selection for exactly this reason. **Module docstring** (required on every non-underscore module): one line on purpose; two to four sentences of intent and how the module relates to the package; a curated `Main entry points:` line followed by a blank line and a bullet list naming the 2 to 5 things to start with (a curation, not an inventory); one minimal doctest. The blank line matters: `Main entry points:` directly over an indented block is an RST definition list, which `epythet validate` reports as DR014. diff --git a/epythet/normalizer.py b/epythet/normalizer.py index ea42df3..b8da1ce 100644 --- a/epythet/normalizer.py +++ b/epythet/normalizer.py @@ -42,7 +42,8 @@ 7. ``reflow_list_continuations``: a wrapped list or field line at the marker's own indentation is indented under it. 8. ``blank_lines_between_blocks``: a blank line is inserted before a doctest, - list or field list that follows prose, and after an indented block ends. + list or field list that follows prose, and after an indented block ends; + not when the block sits directly under its own section header. 9. ``markdown_links_to_rst``: ``[text](url)`` becomes ```text `_``. 10. ``escape_unmatched_stars``: ``*args`` / ``**kwargs`` in prose are escaped. @@ -651,6 +652,14 @@ def blank_lines_between_blocks(lines: list[str]) -> list[str]: >>> normalize_text("Args:\\n a: one that\\n wraps.\\n b: two.", rules=[blank_lines_between_blocks]) 'Args:\\n a: one that\\n wraps.\\n b: two.' + + A block immediately under its own section header is left alone: napoleon + renders ``Examples:`` followed directly by a doctest or list identically + with or without the blank line, so inserting one only trips ``D412`` + (pydocstyle's "no blank lines between a section header and its content"). + + >>> normalize_text("Examples:\\n >>> f()\\n 1", rules=[blank_lines_between_blocks]) + 'Examples:\\n >>> f()\\n 1' """ contexts = line_contexts(lines) bodies = google_section_bodies(lines) @@ -660,9 +669,11 @@ def blank_lines_between_blocks(lines: list[str]) -> list[str]: prev_ctx, ctx = contexts[i - 1], contexts[i] # A blank line *before* a block that follows a drawing is outside the # drawing, and a doctest glued to one still has to be separated to run. - starts_block = ctx in (DOCTEST, LIST, FIELD) and prev_ctx not in ( - ctx, DOCTEST, FENCE, LITERAL, - ) # fmt: skip + starts_block = ( + ctx in (DOCTEST, LIST, FIELD) + and prev_ctx not in (ctx, DOCTEST, FENCE, LITERAL) + and not _is_section_header(lines[i - 1]) + ) next_is_deeper = i + 1 < len(lines) and indent_of(lines[i + 1]) > indent_of( line ) diff --git a/epythet/validation/lint.py b/epythet/validation/lint.py index 529726d..6eff8be 100644 --- a/epythet/validation/lint.py +++ b/epythet/validation/lint.py @@ -27,6 +27,12 @@ #: Docstring styles ruff's pydocstyle convention and pydoclint's ``--style`` both accept. STYLES = ("google", "numpy", "sphinx") +#: ``D107`` (``__init__`` must have its own docstring) contradicts pydoclint's +#: ``DOC301`` (``__init__`` must NOT have one; its Args merge into the class +#: docstring) -- the house convention this repo's docstring-style skill already +#: documents. Only one side can pass, so the ruff side is dropped. +RUFF_D_IGNORE = ("D107",) + def ruff_severity(code: str) -> str: """``D1xx`` (missing docstrings) are warnings; other ``D`` rules are style, so info. @@ -69,6 +75,8 @@ def run_ruff( "--no-cache", "--config", f"lint.pydocstyle.convention = '{style}'", + "--ignore", + ",".join(RUFF_D_IGNORE), str(package_dir), ] proc = subprocess.run(cmd, cwd=project_dir, capture_output=True, text=True) @@ -101,8 +109,15 @@ def run_ruff( _PYDOCLINT_LINE_RE = re.compile(r"^\s+(?P\d+): (?PDOC\d+): (?P.*)$") -#: pydoclint options that silence its type-hint bookkeeping: the house convention -#: is types in annotations, never in the docstring, and not every signature is annotated. +#: pydoclint options for the house convention: types live in annotations, never +#: in the docstring. ``--arg-type-hints-in-signature true`` tells pydoclint that +#: *is* how a documented signature looks (DOC108 fires on the opposite reading: +#: ``false`` means "expect no type hints in the signature", which trips on every +#: annotated function). ``--arg-type-hints-in-docstring false`` keeps it from +#: asking for types in the docstring text. ``--allow-init-docstring`` defaults to +#: ``False``, which enforces DOC301 (``__init__`` undocumented, its Args merged +#: into the class docstring) -- the convention this house already writes to, so +#: it is left at its default rather than passed explicitly. PYDOCLINT_OPTIONS = ( "--quiet", "--skip-checking-short-docstrings", @@ -110,7 +125,7 @@ def run_ruff( "--arg-type-hints-in-docstring", "false", "--arg-type-hints-in-signature", - "false", + "true", "--check-return-types", "false", "--check-yield-types", diff --git a/tests/normalizer_fixtures/section_header_then_doctest_untouched.in b/tests/normalizer_fixtures/section_header_then_doctest_untouched.in new file mode 100644 index 0000000..69a3490 --- /dev/null +++ b/tests/normalizer_fixtures/section_header_then_doctest_untouched.in @@ -0,0 +1,5 @@ +Do a thing. + +Examples: + >>> f(1) + 2 diff --git a/tests/normalizer_fixtures/section_header_then_doctest_untouched.out b/tests/normalizer_fixtures/section_header_then_doctest_untouched.out new file mode 100644 index 0000000..69a3490 --- /dev/null +++ b/tests/normalizer_fixtures/section_header_then_doctest_untouched.out @@ -0,0 +1,5 @@ +Do a thing. + +Examples: + >>> f(1) + 2 diff --git a/tests/normalizer_fixtures/section_header_then_list_untouched.in b/tests/normalizer_fixtures/section_header_then_list_untouched.in new file mode 100644 index 0000000..6163123 --- /dev/null +++ b/tests/normalizer_fixtures/section_header_then_list_untouched.in @@ -0,0 +1,5 @@ +Do a thing. + +See Also: + - g: does the opposite. + - h: does something else. diff --git a/tests/normalizer_fixtures/section_header_then_list_untouched.out b/tests/normalizer_fixtures/section_header_then_list_untouched.out new file mode 100644 index 0000000..6163123 --- /dev/null +++ b/tests/normalizer_fixtures/section_header_then_list_untouched.out @@ -0,0 +1,5 @@ +Do a thing. + +See Also: + - g: does the opposite. + - h: does something else. diff --git a/tests/test_lint_conflicts.py b/tests/test_lint_conflicts.py new file mode 100644 index 0000000..6525503 --- /dev/null +++ b/tests/test_lint_conflicts.py @@ -0,0 +1,102 @@ +"""Regression tests for i2mint/epythet#31: three validate/repair rule pairs that +used to be mutually exclusive (``D412`` vs what ``repair`` wrote, ``DOC108`` +firing on every annotated signature, ``D107`` vs ``DOC301`` over where +``__init__`` is documented). One fixture module per conflict; each must repair +to zero level-0 findings. +""" + +import shutil +import textwrap +from pathlib import Path + +import pytest + +from epythet.repair import repair_source +from epythet.validation.lint import run_lint_level + +pytestmark = pytest.mark.skipif( + shutil.which("ruff") is None or shutil.which("pydoclint") is None, + reason="ruff and pydoclint must both be installed to exercise level 0", +) + +D412_MODULE = '''"""A module.""" + + +def f(x: int) -> int: + """Add one to x. + + Args: + x: the value to increment. + + Returns: + x plus one. + + Examples: + >>> f(1) + 2 + """ + return x + 1 +''' + +DOC108_MODULE = '''"""A module.""" + + +def mk_app(funcs: list, app: int | None = None) -> int: + """Build an app from funcs. + + Args: + funcs: the functions to expose. + app: an existing app to extend. + + Returns: + the built app. + """ + return app or 0 +''' + +INIT_DOC_MODULE = '''"""A module.""" + + +class Thing: + """A thing. + + Args: + x: the value. + """ + + def __init__(self, x: int): + self.x = x +''' + + +def _repaired_package(tmp_path: Path, name: str, module_source: str) -> Path: + project = tmp_path / name + (project / name).mkdir(parents=True) + (project / "pyproject.toml").write_text( + textwrap.dedent( + f""" + [project] + name = "{name}" + version = "0.0.1" + """ + ) + ) + (project / name / "__init__.py").write_text('"""The package."""\n') + repaired = repair_source(module_source).repaired + (project / name / "mod.py").write_text(repaired) + return project + + +@pytest.mark.parametrize( + "name,source", + [ + ("d412pkg", D412_MODULE), + ("doc108pkg", DOC108_MODULE), + ("initdocpkg", INIT_DOC_MODULE), + ], +) +def test_repaired_fixture_has_no_level_0_findings(tmp_path, name, source): + project = _repaired_package(tmp_path, name, source) + findings, notes = run_lint_level(project / name, project_dir=project) + assert notes == [] + assert findings == []