supervision/.github/scripts/check_doctest_fences.py

110 lines
3.4 KiB
Python

#!/usr/bin/env python3
"""Validate doctest prompt formatting in source docstrings."""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
DOCTEST_PROMPT_RE = re.compile(r"^\s*>>>")
FENCE_RE = re.compile(r"^\s*```(?P<language>[A-Za-z0-9_-]*)\s*$")
def _check_content(content: str, path: Path) -> list[str]:
r"""Return doctest fence violations for file content.
Examples:
```pycon
>>> from pathlib import Path
>>> _check_content('```pycon\n>>> len([1])\n1\n\n```\n', Path('src/a.py'))
[]
>>> _check_content('>>> len([1])\n1\n', Path('src/a.py'))
['src/a.py:1: doctest prompt must be inside a ```pycon fenced block']
>>> violations = _check_content(
... '```pycon\n>>> len([1])\n1\n```\n', Path('src/a.py')
... )
>>> violations == [
... 'src/a.py:4: pycon doctest block must include exactly one blank line '
... 'before the closing fence'
... ]
True
```
"""
violations: list[str] = []
active_fence_language: str | None = None
in_invalid_doctest_block = False
line_before_previous = ""
previous_line = ""
for line_number, line in enumerate(content.splitlines(), start=1):
fence_match = FENCE_RE.match(line)
if fence_match is not None:
in_invalid_doctest_block = False
if active_fence_language is None:
active_fence_language = fence_match.group("language")
else:
has_exactly_one_blank_line = (
previous_line.strip() == "" and line_before_previous.strip() != ""
)
if active_fence_language == "pycon" and not has_exactly_one_blank_line:
violations.append(
f"{path}:{line_number}: pycon doctest block must include "
"exactly one blank line before the closing fence"
)
active_fence_language = None
line_before_previous = previous_line
previous_line = line
continue
if not line.strip():
in_invalid_doctest_block = False
if (
DOCTEST_PROMPT_RE.match(line)
and active_fence_language != "pycon"
and not in_invalid_doctest_block
):
violations.append(
f"{path}:{line_number}: doctest prompt must be inside a "
"```pycon fenced block"
)
in_invalid_doctest_block = True
line_before_previous = previous_line
previous_line = line
return violations
def check_file(path: Path) -> list[str]:
"""Return doctest fence violations for a single source file."""
if not path.is_file() or path.suffix != ".py" or "src" not in path.parts:
return []
return _check_content(content=path.read_text(encoding="utf-8"), path=path)
def main() -> int:
"""Run the doctest fence check for pre-commit supplied files."""
parser = argparse.ArgumentParser(
description="Validate doctest prompts in src/ are fenced as pycon blocks."
)
parser.add_argument("files", nargs="*", type=Path)
args = parser.parse_args()
violations = [
violation for path in args.files for violation in check_file(path=path)
]
if violations:
print("\n".join(violations))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())