Source code for tests.test_todolist

# Copyright Kevin Deldycke <[email protected]> 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.

"""Guard every `{todo}` admonition against never reaching the published page.

`sphinx.ext.todo` collects the admonitions onto `docs/todolist.md`, which is
the project's inventory of what it owes. An item written where `autodoc` never
looks is absent from that inventory: the work stays undone and unlisted, and
the page reads as complete while under-reporting.

`autodoc` skips a name starting with an underscore, so a `{todo}` on a private
constant, function or class publishes nowhere. Two of them sat on the private
regexes behind the `mdformat` post-process shim in `repomatic.tooling.tool_registry`,
and the page carried 14 items while the tree held 16.

Nothing reports it. A `{todo}` renders exactly the same way whether or not its
owner is picked up, the build stays warning-free, and the only way to notice is
to count the page against the tree by hand. That silence is what earns this a
conformance test rather than a line in `claude.md`.

The check reads the owner from the syntax tree instead of importing anything,
so it runs everywhere the suite does. `tests/test_docstrings.py` covers the
same population for a different corruption, but drives the real MyST converter
and therefore skips wherever Sphinx is absent.
"""

from __future__ import annotations

import ast

import pytest

from tests.conftest import (
    PACKAGE_DIR,
    PACKAGE_FILES,
    PROJECT_ROOT,
    attribute_docstrings,
)

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator
    from pathlib import Path

TODO_MARKER = "```{todo}"
"""Opening fence of a todo admonition, as written throughout the package."""

CONF_PY = PROJECT_ROOT / "docs" / "conf.py"
"""Sphinx configuration, read for the `autodoc` options this check assumes."""


def _autodoc_default_options() -> dict[str, object]:
    """Return the `autodoc_default_options` mapping declared in `conf.py`.

    Parsed rather than imported: `conf.py` runs under Sphinx and pulls in the
    whole documentation stack, which the test environment does not carry.
    """
    tree = ast.parse(CONF_PY.read_text(encoding="UTF-8"))
    for node in tree.body:
        targets: list[ast.expr]
        if isinstance(node, ast.Assign):
            targets = list(node.targets)
        elif isinstance(node, ast.AnnAssign):
            targets = [node.target]
        else:
            continue
        if node.value is None:
            continue
        names = {t.id for t in targets if isinstance(t, ast.Name)}
        if "autodoc_default_options" in names:
            options = ast.literal_eval(node.value)
            assert isinstance(options, dict), options
            return options
    raise AssertionError(f"No autodoc_default_options found in {CONF_PY}.")


def _is_private(name: str) -> bool:
    """Whether *name* is one `autodoc` skips by default."""
    return name.startswith("_")


def _module_path(source_file: Path) -> str:
    """Return the dotted import path of *source_file*."""
    relative = source_file.relative_to(PACKAGE_DIR.parent).with_suffix("")
    parts = [part for part in relative.parts if part != "__init__"]
    return ".".join(parts)


def _todo_owners(tree: ast.Module, module: str) -> Iterator[tuple[int, str]]:
    """Yield `(line, dotted_owner)` for every todo admonition in *tree*.

    The owner carries its full nesting path, so a public method on a private
    class is reported under the private ancestor that actually hides it.
    """

    def visit(node: ast.AST, prefix: str) -> Iterator[tuple[int, str]]:
        body = getattr(node, "body", None)
        if not isinstance(body, list):
            return
        if isinstance(
            node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
        ):
            docstring = ast.get_docstring(node, clean=False)
            if docstring and TODO_MARKER in docstring:
                yield getattr(node, "lineno", 1), prefix
        for line, owner, text in attribute_docstrings(body):
            if TODO_MARKER in text:
                yield line, f"{prefix}.{owner}"
        # Only a class body nests further: `autodoc` never reaches a name
        # defined inside a function.
        if isinstance(node, (ast.Module, ast.ClassDef)):
            for statement in body:
                if isinstance(
                    statement,
                    (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef),
                ):
                    yield from visit(statement, f"{prefix}.{statement.name}")

    yield from visit(tree, module)


def _unpublished_todos(tree: ast.Module, module: str) -> list[str]:
    """Return one description per todo admonition `autodoc` never renders."""
    violations = []
    for line, owner in _todo_owners(tree, module):
        hidden = [part for part in owner.split(".") if _is_private(part)]
        if hidden:
            violations.append(f"line {line}: {owner} (hidden by {hidden[0]})")
    return sorted(violations)


[docs] def test_package_discovered() -> None: """The scan actually walks the package, so a green result means something.""" assert len(PACKAGE_FILES) > 40 assert PACKAGE_DIR / "tooling" / "tool_registry.py" in PACKAGE_FILES
[docs] def test_the_package_owes_something() -> None: """The tree carries todo admonitions at all, so the sweep has a subject. An empty population would pass every assertion below while proving nothing. """ total = sum( 1 for source_file in PACKAGE_FILES for _ in _todo_owners( ast.parse(source_file.read_text(encoding="UTF-8")), _module_path(source_file), ) ) assert total >= 10, f"Only {total} todo admonitions found in the package."
[docs] def test_autodoc_skips_private_members() -> None: """Pin the premise the sweep below rests on. Turning `private-members` on would publish a private owner's docstring and make the sweep over-strict. It is off today, and this fails the moment that changes, rather than leaving the sibling test quietly wrong. """ options = _autodoc_default_options() assert not options.get("private-members"), ( "docs/conf.py now renders private members, so a `{todo}` on one does" " reach the todo list. Relax `_unpublished_todos` accordingly." )
[docs] def test_detector_flags_hidden_todo() -> None: """The detector catches each hidden owner, and clears the published ones.""" tree = ast.parse( f'"""Module prose.\n\n{TODO_MARKER}\nPick the mangoes.\n```\n"""\n' "RIPENESS = 3\n" f'"""Public constant.\n\n{TODO_MARKER}\nWeigh the papayas.\n```\n"""\n' "_CRATE_SIZE = 12\n" f'"""Private constant.\n\n{TODO_MARKER}\nCount the crates.\n```\n"""\n' "class Orchard:\n" " rows = 4\n" f' """Public field.\n\n{TODO_MARKER}\nMeasure the rows.\n```\n"""\n' "class _Shed:\n" " def sweep(self):\n" f' """Public method on a private class.\n\n{TODO_MARKER}\n' 'Sweep.\n```\n"""\n' ) assert _unpublished_todos(tree, "harvest") == [ "line 15: harvest._CRATE_SIZE (hidden by _CRATE_SIZE)", "line 30: harvest._Shed.sweep (hidden by _Shed)", ], _unpublished_todos(tree, "harvest")
[docs] @pytest.mark.parametrize( "source_file", PACKAGE_FILES, ids=lambda path: str(path.relative_to(PROJECT_ROOT)), ) def test_every_todo_is_published(source_file) -> None: """Every todo admonition sits where `autodoc` renders it onto the page.""" tree = ast.parse(source_file.read_text(encoding="UTF-8")) violations = _unpublished_todos(tree, _module_path(source_file)) assert not violations, ( f"{source_file.relative_to(PROJECT_ROOT)} carries a `{TODO_MARKER}`" " admonition on a private owner, so it never reaches" " https://repomatic.net/todolist:\n " + "\n ".join(violations) + "\n" "Move it to the public docstring that owns the concern, and leave a" " pointer beside the code." )