Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 1 addition & 1 deletion epythet/data/skills/epythet-docstring-style/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 15 additions & 4 deletions epythet/normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>`_``.
10. ``escape_unmatched_stars``: ``*args`` / ``**kwargs`` in prose are escaped.

Expand Down Expand Up @@ -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)
Expand All @@ -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
)
Expand Down
21 changes: 18 additions & 3 deletions epythet/validation/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -101,16 +109,23 @@ def run_ruff(

_PYDOCLINT_LINE_RE = re.compile(r"^\s+(?P<line>\d+): (?P<code>DOC\d+): (?P<msg>.*)$")

#: 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",
"true",
"--arg-type-hints-in-docstring",
"false",
"--arg-type-hints-in-signature",
"false",
"true",
"--check-return-types",
"false",
"--check-yield-types",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Do a thing.

Examples:
>>> f(1)
2
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Do a thing.

Examples:
>>> f(1)
2
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Do a thing.

See Also:
- g: does the opposite.
- h: does something else.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Do a thing.

See Also:
- g: does the opposite.
- h: does something else.
102 changes: 102 additions & 0 deletions tests/test_lint_conflicts.py
Original file line number Diff line number Diff line change
@@ -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 == []
Loading