docparity 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.11", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - name: Install package with dev extras
20
+ run: pip install -e ".[dev]"
21
+ - name: Run tests
22
+ run: pytest tests/ -v
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
@@ -0,0 +1,57 @@
1
+ # Design notes
2
+
3
+ ## The gap
4
+
5
+ Docstring-vs-signature drift is distinct from every existing docstring
6
+ tool:
7
+
8
+ - `pydocstyle` / `flake8-docstrings` / `pydoclint` check *presence and
9
+ formatting conventions* (is there a docstring, does it start with a
10
+ capital letter, blank line rules). Some (pydoclint, darglint) do check
11
+ parameter lists against docstrings -- but they require adopting a full
12
+ docstring-linting workflow and tend to also demand full coverage
13
+ (every function must be fully documented), which produces high friction
14
+ and gets disabled. docparity does one narrow thing: catch *drift* in
15
+ code that already chose to document its parameters, with zero
16
+ configuration and zero opinion about which functions should have docs.
17
+ - Type checkers (mypy/pyright) validate the type *signature*, never the
18
+ prose in a docstring.
19
+ - `interrogate` measures docstring *coverage* (percentage of functions
20
+ documented), not correctness of what's already there.
21
+
22
+ The genuinely tedious, currently-manual work this replaces: a reviewer
23
+ (or nobody) has to notice, function by function, that a docstring's
24
+ `Args:` block still lists a parameter that was renamed three refactors
25
+ ago, or is missing one that was added last week. In a large codebase this
26
+ is invisible until someone reading the docs gets misled, or an LLM
27
+ coding assistant trusts the stale docstring and generates code calling
28
+ the wrong parameter name.
29
+
30
+ ## Scope boundaries (v0.1)
31
+
32
+ - Only two checks: stale documented parameter, missing documentation for
33
+ a real parameter that already has a params section.
34
+ - Only triggers on functions that already document at least one
35
+ parameter -- never nags about undocumented functions. This is the
36
+ single biggest false-positive-avoidance decision: a tool that also
37
+ flags "no docstring at all" gets disabled on first run in any real
38
+ codebase.
39
+ - No type-annotation cross-checking (documented type vs. real
40
+ annotation) in v0.1 -- name drift is the highest-value, lowest-noise
41
+ check to start with.
42
+ - No return-value or raises-section checking in v0.1.
43
+ - `self` and `cls` are always excluded from comparison.
44
+ - `*args` / `**kwargs` are compared by their own names (`args`, `kwargs`),
45
+ matching how both Google and reST style documents them in practice.
46
+
47
+ ## Docstring styles supported
48
+
49
+ Google, NumPy, and Sphinx/reST (`:param:`) -- detected independently per
50
+ docstring, not configured globally, since large codebases often mix
51
+ styles across modules.
52
+
53
+ ## Non-goals
54
+
55
+ - Not a docstring-coverage tool (see `interrogate` for that).
56
+ - Not a docstring-formatting linter (see `pydocstyle` for that).
57
+ - Not a type-checker.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.5
2
+ Name: docparity
3
+ Version: 0.1.0
4
+ Summary: Detects drift between a function's docstring parameters and its real signature (Google, NumPy, and Sphinx styles).
5
+ Author: Jay
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: code-quality,docstring,documentation,linter,static-analysis
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Topic :: Documentation
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: >=3.10
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # docparity
22
+
23
+ Detects drift between a function's **docstring parameters** and its **real
24
+ signature** -- the classic case of renaming or removing a parameter and
25
+ forgetting to update the docstring, so the documentation quietly lies to
26
+ the next reader (or to an LLM/IDE that trusts it).
27
+
28
+ Supports Google, NumPy, and Sphinx (`:param:`) docstring styles. Zero
29
+ runtime dependencies, pure `ast`-based static analysis.
30
+
31
+ ## Why
32
+
33
+ Docstrings drift silently:
34
+
35
+ ```python
36
+ def charge_card(amount, currency):
37
+ """Charge a card.
38
+
39
+ Args:
40
+ amount (float): the amount to charge
41
+ card_token (str): the card token to charge # <- renamed to `currency`, doc never updated
42
+ """
43
+ ```
44
+
45
+ Nothing catches this today. Type checkers don't read docstrings. Linters
46
+ that check docstring *presence* (pydocstyle, flake8-docstrings) don't check
47
+ that documented parameter names still match the signature. The result is
48
+ stale documentation that misleads humans, and misleads LLM coding
49
+ assistants and IDEs that surface docstrings as ground truth.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install docparity
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ```bash
60
+ docparity src/ # scan a directory recursively
61
+ docparity mymodule.py # scan a single file
62
+ docparity --stale-only src/ # only report renamed/removed params, not missing docs
63
+ ```
64
+
65
+ Exit codes: `0` clean, `1` findings reported, `2` usage/syntax error.
66
+
67
+ ## What it checks (v0.1)
68
+
69
+ 1. **Stale documented parameter** -- the docstring documents a parameter
70
+ name that no longer exists in the real signature (renamed or removed).
71
+ 2. **Missing documentation for a real parameter** -- the function already
72
+ has a params section, and a real parameter isn't in it (added to the
73
+ signature, forgotten in the docs).
74
+
75
+ docparity only checks functions that **already document at least one
76
+ parameter**. It never asks you to add documentation to an undocumented
77
+ function -- that's a coverage tool's job, not a drift tool's job. This
78
+ keeps false positives near zero.
79
+
80
+ ## Library usage
81
+
82
+ ```python
83
+ from docparity import scan_file
84
+
85
+ findings = scan_file("mymodule.py")
86
+ for f in findings:
87
+ print(f)
88
+ ```
89
+
90
+ See [docs/USAGE.md](docs/USAGE.md) for more, and [DETAILS.md](DETAILS.md)
91
+ for design rationale and scope boundaries.
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,75 @@
1
+ # docparity
2
+
3
+ Detects drift between a function's **docstring parameters** and its **real
4
+ signature** -- the classic case of renaming or removing a parameter and
5
+ forgetting to update the docstring, so the documentation quietly lies to
6
+ the next reader (or to an LLM/IDE that trusts it).
7
+
8
+ Supports Google, NumPy, and Sphinx (`:param:`) docstring styles. Zero
9
+ runtime dependencies, pure `ast`-based static analysis.
10
+
11
+ ## Why
12
+
13
+ Docstrings drift silently:
14
+
15
+ ```python
16
+ def charge_card(amount, currency):
17
+ """Charge a card.
18
+
19
+ Args:
20
+ amount (float): the amount to charge
21
+ card_token (str): the card token to charge # <- renamed to `currency`, doc never updated
22
+ """
23
+ ```
24
+
25
+ Nothing catches this today. Type checkers don't read docstrings. Linters
26
+ that check docstring *presence* (pydocstyle, flake8-docstrings) don't check
27
+ that documented parameter names still match the signature. The result is
28
+ stale documentation that misleads humans, and misleads LLM coding
29
+ assistants and IDEs that surface docstrings as ground truth.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install docparity
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ docparity src/ # scan a directory recursively
41
+ docparity mymodule.py # scan a single file
42
+ docparity --stale-only src/ # only report renamed/removed params, not missing docs
43
+ ```
44
+
45
+ Exit codes: `0` clean, `1` findings reported, `2` usage/syntax error.
46
+
47
+ ## What it checks (v0.1)
48
+
49
+ 1. **Stale documented parameter** -- the docstring documents a parameter
50
+ name that no longer exists in the real signature (renamed or removed).
51
+ 2. **Missing documentation for a real parameter** -- the function already
52
+ has a params section, and a real parameter isn't in it (added to the
53
+ signature, forgotten in the docs).
54
+
55
+ docparity only checks functions that **already document at least one
56
+ parameter**. It never asks you to add documentation to an undocumented
57
+ function -- that's a coverage tool's job, not a drift tool's job. This
58
+ keeps false positives near zero.
59
+
60
+ ## Library usage
61
+
62
+ ```python
63
+ from docparity import scan_file
64
+
65
+ findings = scan_file("mymodule.py")
66
+ for f in findings:
67
+ print(f)
68
+ ```
69
+
70
+ See [docs/USAGE.md](docs/USAGE.md) for more, and [DETAILS.md](DETAILS.md)
71
+ for design rationale and scope boundaries.
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,60 @@
1
+ # Usage
2
+
3
+ ## CLI
4
+
5
+ ```bash
6
+ docparity <path> [<path> ...] [--stale-only]
7
+ ```
8
+
9
+ - `<path>` -- one or more files or directories. Directories are scanned
10
+ recursively for `*.py` files.
11
+ - `--stale-only` -- only print "stale" findings (documented parameters
12
+ that no longer exist in the signature). Suppresses "missing
13
+ documentation" findings, useful if you want a stricter/quieter signal
14
+ in CI to start with.
15
+
16
+ Exit codes:
17
+
18
+ | Code | Meaning |
19
+ |------|---------|
20
+ | 0 | No findings |
21
+ | 1 | One or more findings printed |
22
+ | 2 | No Python files found, or a file failed to parse |
23
+
24
+ ## Example
25
+
26
+ ```
27
+ $ docparity examples/drifted_example.py
28
+ examples/drifted_example.py:4: charge_card(): docstring documents 'card_token' which is not a parameter
29
+ examples/drifted_example.py:4: charge_card(): parameter 'currency' is not documented
30
+ ```
31
+
32
+ ## CI integration
33
+
34
+ Add to a pre-commit hook or CI step:
35
+
36
+ ```yaml
37
+ - name: Check docstring/signature drift
38
+ run: docparity src/
39
+ ```
40
+
41
+ A non-zero exit fails the step only when a documented parameter has
42
+ drifted from the real signature.
43
+
44
+ ## Library API
45
+
46
+ ```python
47
+ from docparity import scan_file, scan_source
48
+
49
+ # From a file on disk
50
+ findings = scan_file("mymodule.py")
51
+
52
+ # From a source string (e.g. in a pre-commit hook operating on staged content)
53
+ findings = scan_source(source_text, filename="mymodule.py")
54
+
55
+ for f in findings:
56
+ print(f.file, f.line, f.function, f.kind, f.param)
57
+ ```
58
+
59
+ `Finding.kind` is either `"stale"` (documented, not a real parameter) or
60
+ `"missing"` (real parameter, not documented).
@@ -0,0 +1,7 @@
1
+ # Examples
2
+
3
+ - `drifted_example.py` -- a function whose docstring still documents a
4
+ parameter (`card_token`) that was renamed (`currency`) in the real
5
+ signature. Run `docparity examples/drifted_example.py` to see it flagged.
6
+ - `clean_example.py` -- the same idea kept in sync, plus a function with no
7
+ params section at all (which docparity intentionally never flags).
@@ -0,0 +1,21 @@
1
+ """Example module with no drift -- docparity reports nothing here."""
2
+
3
+
4
+ def charge_card(amount, currency):
5
+ """Charge a card.
6
+
7
+ Args:
8
+ amount (float): the amount to charge
9
+ currency (str): the ISO 4217 currency code
10
+ """
11
+ return amount, currency
12
+
13
+
14
+ def refund(amount):
15
+ """Refund an amount without a documented params section.
16
+
17
+ docparity does not flag this: it never asks you to add
18
+ documentation, only to fix documentation that already exists and
19
+ has drifted from the real signature.
20
+ """
21
+ return amount
@@ -0,0 +1,26 @@
1
+ """Example module with docstring/signature drift for docparity to catch."""
2
+
3
+
4
+ def charge_card(amount, currency):
5
+ """Charge a card.
6
+
7
+ Args:
8
+ amount (float): the amount to charge
9
+ card_token (str): the card token to charge
10
+
11
+ A parameter was renamed from ``card_token`` to ``currency`` in the
12
+ signature, but the docstring was never updated. docparity reports:
13
+
14
+ - stale: 'card_token' is documented but no longer a real parameter
15
+ - missing: 'currency' is a real parameter but is not documented
16
+ """
17
+ return amount, currency
18
+
19
+
20
+ def refund(amount):
21
+ """Refund an amount.
22
+
23
+ Args:
24
+ amount (float): the amount to refund
25
+ """
26
+ return amount
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "docparity"
7
+ version = "0.1.0"
8
+ description = "Detects drift between a function's docstring parameters and its real signature (Google, NumPy, and Sphinx styles)."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Jay" }]
13
+ keywords = ["docstring", "static-analysis", "linter", "documentation", "code-quality"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Topic :: Documentation",
21
+ "Topic :: Software Development :: Quality Assurance",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=8.0"]
27
+
28
+ [project.scripts]
29
+ docparity = "docparity.cli:main"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/docparity"]
@@ -0,0 +1 @@
1
+ [pytest]
@@ -0,0 +1,7 @@
1
+ """docparity - detect drift between a function's docstring and its real signature."""
2
+
3
+ from .scanner import scan_file, scan_source, Finding
4
+
5
+ __all__ = ["scan_file", "scan_source", "Finding"]
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,77 @@
1
+ """docparity command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .scanner import scan_file
10
+
11
+
12
+ def _iter_python_files(paths: list[str]) -> list[Path]:
13
+ files: list[Path] = []
14
+ for raw in paths:
15
+ p = Path(raw)
16
+ if p.is_dir():
17
+ files.extend(sorted(p.rglob("*.py")))
18
+ elif p.suffix == ".py":
19
+ files.append(p)
20
+ return files
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(
25
+ prog="docparity",
26
+ description=(
27
+ "Detect drift between a function's docstring parameters and its "
28
+ "real signature (Google, NumPy, and Sphinx docstring styles)."
29
+ ),
30
+ )
31
+ parser.add_argument(
32
+ "paths", nargs="+", help="Python files or directories to scan"
33
+ )
34
+ parser.add_argument(
35
+ "--stale-only",
36
+ action="store_true",
37
+ help=(
38
+ "Only report stale documented parameters (removed/renamed in "
39
+ "the signature). Suppresses 'missing documentation' findings."
40
+ ),
41
+ )
42
+ return parser
43
+
44
+
45
+ def main(argv: list[str] | None = None) -> int:
46
+ parser = build_parser()
47
+ args = parser.parse_args(argv)
48
+
49
+ files = _iter_python_files(args.paths)
50
+ if not files:
51
+ print("docparity: no Python files found", file=sys.stderr)
52
+ return 2
53
+
54
+ total = 0
55
+ had_error = False
56
+ for f in files:
57
+ try:
58
+ findings = scan_file(f)
59
+ except SyntaxError as exc:
60
+ print(f"docparity: {f}: syntax error: {exc}", file=sys.stderr)
61
+ had_error = True
62
+ continue
63
+
64
+ if args.stale_only:
65
+ findings = [fnd for fnd in findings if fnd.kind == "stale"]
66
+
67
+ for finding in findings:
68
+ print(str(finding))
69
+ total += 1
70
+
71
+ if had_error:
72
+ return 2
73
+ return 1 if total else 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())
@@ -0,0 +1,126 @@
1
+ """Extract documented parameter names from a docstring.
2
+
3
+ Supports the three docstring styles used in practice:
4
+
5
+ - Google style::
6
+
7
+ Args:
8
+ name (str): description
9
+ other: description
10
+
11
+ - NumPy style::
12
+
13
+ Parameters
14
+ ----------
15
+ name : str
16
+ description
17
+
18
+ - Sphinx / reST style::
19
+
20
+ :param name: description
21
+ :param str other: description
22
+
23
+ Only the *names* of documented parameters are extracted -- docparity
24
+ compares names against the real signature, it does not attempt to
25
+ validate types or descriptions.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from dataclasses import dataclass, field
32
+
33
+
34
+ @dataclass
35
+ class ParsedDocstring:
36
+ documented_params: list[str] = field(default_factory=list)
37
+ has_params_section: bool = False
38
+
39
+
40
+ _GOOGLE_SECTION_RE = re.compile(
41
+ r"^\s*(Args|Arguments|Parameters)\s*:\s*$", re.MULTILINE
42
+ )
43
+ _GOOGLE_PARAM_RE = re.compile(
44
+ r"^\s{2,}(\*{0,2}[A-Za-z_][A-Za-z0-9_]*)\s*(\(.*?\))?\s*:", re.MULTILINE
45
+ )
46
+ _GOOGLE_SECTION_STOP_RE = re.compile(
47
+ r"^\s*(Returns|Yields|Raises|Examples?|Note|Notes|Attributes)\s*:\s*$",
48
+ re.MULTILINE,
49
+ )
50
+
51
+ _NUMPY_SECTION_RE = re.compile(
52
+ r"^\s*Parameters\s*\n\s*-{3,}\s*$", re.MULTILINE
53
+ )
54
+ _NUMPY_PARAM_RE = re.compile(
55
+ r"^([A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*(:.*)?$"
56
+ )
57
+ _NUMPY_SECTION_STOP_RE = re.compile(
58
+ r"^\s*(Returns|Yields|Raises|Examples?|Notes?|Attributes)\s*\n\s*-{3,}\s*$",
59
+ re.MULTILINE,
60
+ )
61
+
62
+ _SPHINX_PARAM_RE = re.compile(
63
+ r"^\s*:param\s+(?:[A-Za-z_][A-Za-z0-9_.\[\]]*\s+)?\*{0,2}([A-Za-z_][A-Za-z0-9_]*)\s*:",
64
+ re.MULTILINE,
65
+ )
66
+
67
+
68
+ def _extract_google(doc: str) -> list[str] | None:
69
+ m = _GOOGLE_SECTION_RE.search(doc)
70
+ if not m:
71
+ return None
72
+ start = m.end()
73
+ stop_m = _GOOGLE_SECTION_STOP_RE.search(doc, pos=start)
74
+ end = stop_m.start() if stop_m else len(doc)
75
+ section = doc[start:end]
76
+ return [pm.group(1).lstrip("*") for pm in _GOOGLE_PARAM_RE.finditer(section)]
77
+
78
+
79
+ def _extract_numpy(doc: str) -> list[str] | None:
80
+ m = _NUMPY_SECTION_RE.search(doc)
81
+ if not m:
82
+ return None
83
+ start = m.end()
84
+ stop_m = _NUMPY_SECTION_STOP_RE.search(doc, pos=start)
85
+ end = stop_m.start() if stop_m else len(doc)
86
+ section = doc[start:end]
87
+ names: list[str] = []
88
+ for raw_line in section.splitlines():
89
+ line = raw_line.strip()
90
+ if not line:
91
+ continue
92
+ # Only lines that look like "name : type" or "name" (not indented
93
+ # description text, which NumPy style indents further than the name).
94
+ indent = len(raw_line) - len(raw_line.lstrip(" "))
95
+ if indent > 4:
96
+ continue
97
+ pm = _NUMPY_PARAM_RE.match(line)
98
+ if pm:
99
+ for n in pm.group(1).split(","):
100
+ names.append(n.strip())
101
+ return names
102
+
103
+
104
+ def _extract_sphinx(doc: str) -> list[str] | None:
105
+ names = [pm.group(1) for pm in _SPHINX_PARAM_RE.finditer(doc)]
106
+ return names or None
107
+
108
+
109
+ def parse_docstring(doc: str | None) -> ParsedDocstring:
110
+ """Parse a docstring and return the set of documented parameter names.
111
+
112
+ Returns an empty, ``has_params_section=False`` result when no
113
+ recognizable parameter section is found. docparity intentionally does
114
+ not flag functions with no params section at all -- only functions
115
+ that already document *some* parameters but have drifted from the
116
+ real signature.
117
+ """
118
+ if not doc:
119
+ return ParsedDocstring()
120
+
121
+ for extractor in (_extract_google, _extract_numpy, _extract_sphinx):
122
+ names = extractor(doc)
123
+ if names is not None:
124
+ return ParsedDocstring(documented_params=names, has_params_section=True)
125
+
126
+ return ParsedDocstring()
@@ -0,0 +1,108 @@
1
+ """Scan Python source for docstring-vs-signature parameter drift."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from .docstring_parser import parse_docstring
10
+
11
+ # Parameters that are conventionally excluded from docstrings and should
12
+ # never trigger an "undocumented parameter" finding.
13
+ _IGNORED_PARAMS = {"self", "cls"}
14
+
15
+
16
+ @dataclass
17
+ class Finding:
18
+ file: str
19
+ line: int
20
+ function: str
21
+ kind: str # "stale" (documented but not in signature) or "missing" (in
22
+ # signature but not documented)
23
+ param: str
24
+
25
+ def __str__(self) -> str:
26
+ if self.kind == "stale":
27
+ msg = f"docstring documents '{self.param}' which is not a parameter"
28
+ else:
29
+ msg = f"parameter '{self.param}' is not documented"
30
+ return f"{self.file}:{self.line}: {self.function}(): {msg}"
31
+
32
+
33
+ def _real_params(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
34
+ args = node.args
35
+ names = []
36
+ for a in args.posonlyargs:
37
+ names.append(a.arg)
38
+ for a in args.args:
39
+ names.append(a.arg)
40
+ if args.vararg:
41
+ names.append(args.vararg.arg)
42
+ for a in args.kwonlyargs:
43
+ names.append(a.arg)
44
+ if args.kwarg:
45
+ names.append(args.kwarg.arg)
46
+ return [n for n in names if n not in _IGNORED_PARAMS]
47
+
48
+
49
+ def _check_function(
50
+ node: ast.FunctionDef | ast.AsyncFunctionDef, filename: str
51
+ ) -> list[Finding]:
52
+ findings: list[Finding] = []
53
+ doc = ast.get_docstring(node)
54
+ parsed = parse_docstring(doc)
55
+ if not parsed.has_params_section:
56
+ # No documented parameter section -- nothing to compare, and
57
+ # flagging undocumented functions is out of scope (that is a
58
+ # coverage tool's job, not a drift tool's job).
59
+ return findings
60
+
61
+ real = _real_params(node)
62
+ real_set = set(real)
63
+ documented = parsed.documented_params
64
+ documented_set = set(documented)
65
+
66
+ for name in documented:
67
+ if name not in real_set:
68
+ findings.append(
69
+ Finding(
70
+ file=filename,
71
+ line=node.lineno,
72
+ function=node.name,
73
+ kind="stale",
74
+ param=name,
75
+ )
76
+ )
77
+
78
+ for name in real:
79
+ if name not in documented_set:
80
+ findings.append(
81
+ Finding(
82
+ file=filename,
83
+ line=node.lineno,
84
+ function=node.name,
85
+ kind="missing",
86
+ param=name,
87
+ )
88
+ )
89
+
90
+ return findings
91
+
92
+
93
+ def scan_source(source: str, filename: str = "<string>") -> list[Finding]:
94
+ """Scan a source string and return a list of drift findings."""
95
+ tree = ast.parse(source, filename=filename)
96
+ findings: list[Finding] = []
97
+ for node in ast.walk(tree):
98
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
99
+ findings.extend(_check_function(node, filename))
100
+ findings.sort(key=lambda f: (f.line, f.function, f.param))
101
+ return findings
102
+
103
+
104
+ def scan_file(path: str | Path) -> list[Finding]:
105
+ """Scan a single Python file on disk and return a list of drift findings."""
106
+ path = Path(path)
107
+ source = path.read_text(encoding="utf-8")
108
+ return scan_source(source, filename=str(path))
File without changes
@@ -0,0 +1,86 @@
1
+ import textwrap
2
+
3
+ from docparity.cli import main
4
+
5
+
6
+ def _write(tmp_path, name, content):
7
+ p = tmp_path / name
8
+ p.write_text(textwrap.dedent(content))
9
+ return p
10
+
11
+
12
+ def test_cli_exits_zero_when_clean(tmp_path, capsys):
13
+ _write(
14
+ tmp_path,
15
+ "clean.py",
16
+ '''
17
+ def greet(name):
18
+ """Greet.
19
+
20
+ Args:
21
+ name: the name
22
+ """
23
+ return name
24
+ ''',
25
+ )
26
+ code = main([str(tmp_path)])
27
+ assert code == 0
28
+ out = capsys.readouterr().out
29
+ assert out == ""
30
+
31
+
32
+ def test_cli_exits_one_and_prints_findings(tmp_path, capsys):
33
+ _write(
34
+ tmp_path,
35
+ "drift.py",
36
+ '''
37
+ def greet(name):
38
+ """Greet.
39
+
40
+ Args:
41
+ name: the name
42
+ title: stale
43
+ """
44
+ return name
45
+ ''',
46
+ )
47
+ code = main([str(tmp_path)])
48
+ assert code == 1
49
+ out = capsys.readouterr().out
50
+ assert "title" in out
51
+ assert "drift.py" in out
52
+
53
+
54
+ def test_cli_stale_only_filters_missing_findings(tmp_path, capsys):
55
+ _write(
56
+ tmp_path,
57
+ "drift.py",
58
+ '''
59
+ def greet(name, title):
60
+ """Greet.
61
+
62
+ Args:
63
+ name: the name
64
+ """
65
+ return name
66
+ ''',
67
+ )
68
+ code = main([str(tmp_path), "--stale-only"])
69
+ assert code == 0
70
+ out = capsys.readouterr().out
71
+ assert out == ""
72
+
73
+
74
+ def test_cli_no_python_files_found(tmp_path, capsys):
75
+ code = main([str(tmp_path)])
76
+ assert code == 2
77
+ err = capsys.readouterr().err
78
+ assert "no Python files found" in err
79
+
80
+
81
+ def test_cli_syntax_error_reported(tmp_path, capsys):
82
+ _write(tmp_path, "bad.py", "def f(:\n")
83
+ code = main([str(tmp_path)])
84
+ assert code == 2
85
+ err = capsys.readouterr().err
86
+ assert "syntax error" in err
@@ -0,0 +1,82 @@
1
+ from docparity.docstring_parser import parse_docstring
2
+
3
+
4
+ def test_no_docstring():
5
+ result = parse_docstring(None)
6
+ assert result.has_params_section is False
7
+ assert result.documented_params == []
8
+
9
+
10
+ def test_docstring_with_no_params_section():
11
+ result = parse_docstring("Just a summary line.\n\nNo params documented here.")
12
+ assert result.has_params_section is False
13
+
14
+
15
+ def test_google_style_args():
16
+ doc = """Do a thing.
17
+
18
+ Args:
19
+ name (str): the name
20
+ count (int): how many
21
+
22
+ Returns:
23
+ bool: success
24
+ """
25
+ result = parse_docstring(doc)
26
+ assert result.has_params_section is True
27
+ assert result.documented_params == ["name", "count"]
28
+
29
+
30
+ def test_google_style_arguments_header_and_star_args():
31
+ doc = """Do a thing.
32
+
33
+ Arguments:
34
+ *args: positional
35
+ **kwargs: keyword
36
+ """
37
+ result = parse_docstring(doc)
38
+ assert result.documented_params == ["args", "kwargs"]
39
+
40
+
41
+ def test_numpy_style():
42
+ doc = """Do a thing.
43
+
44
+ Parameters
45
+ ----------
46
+ name : str
47
+ the name
48
+ count : int
49
+ how many
50
+
51
+ Returns
52
+ -------
53
+ bool
54
+ success
55
+ """
56
+ result = parse_docstring(doc)
57
+ assert result.has_params_section is True
58
+ assert result.documented_params == ["name", "count"]
59
+
60
+
61
+ def test_numpy_style_combined_names():
62
+ doc = """Summary.
63
+
64
+ Parameters
65
+ ----------
66
+ x, y : int
67
+ coordinates
68
+ """
69
+ result = parse_docstring(doc)
70
+ assert result.documented_params == ["x", "y"]
71
+
72
+
73
+ def test_sphinx_style():
74
+ doc = """Do a thing.
75
+
76
+ :param name: the name
77
+ :param int count: how many
78
+ :returns: success
79
+ """
80
+ result = parse_docstring(doc)
81
+ assert result.has_params_section is True
82
+ assert result.documented_params == ["name", "count"]
@@ -0,0 +1,212 @@
1
+ from docparity.scanner import scan_source
2
+
3
+
4
+ def test_no_findings_when_no_docstring():
5
+ src = '''
6
+ def add(a, b):
7
+ return a + b
8
+ '''
9
+ assert scan_source(src) == []
10
+
11
+
12
+ def test_no_findings_when_docstring_has_no_params_section():
13
+ src = '''
14
+ def add(a, b):
15
+ """Add two numbers."""
16
+ return a + b
17
+ '''
18
+ assert scan_source(src) == []
19
+
20
+
21
+ def test_stale_param_detected_removed_from_signature():
22
+ src = '''
23
+ def greet(name):
24
+ """Greet someone.
25
+
26
+ Args:
27
+ name (str): the name
28
+ title (str): the honorific
29
+ """
30
+ return name
31
+ '''
32
+ findings = scan_source(src, filename="f.py")
33
+ assert len(findings) == 1
34
+ assert findings[0].kind == "stale"
35
+ assert findings[0].param == "title"
36
+ assert findings[0].function == "greet"
37
+
38
+
39
+ def test_missing_param_detected_added_to_signature():
40
+ src = '''
41
+ def greet(name, title):
42
+ """Greet someone.
43
+
44
+ Args:
45
+ name (str): the name
46
+ """
47
+ return name
48
+ '''
49
+ findings = scan_source(src, filename="f.py")
50
+ assert len(findings) == 1
51
+ assert findings[0].kind == "missing"
52
+ assert findings[0].param == "title"
53
+
54
+
55
+ def test_renamed_param_produces_both_stale_and_missing():
56
+ src = '''
57
+ def greet(full_name):
58
+ """Greet someone.
59
+
60
+ Args:
61
+ name (str): the name
62
+ """
63
+ return full_name
64
+ '''
65
+ findings = scan_source(src, filename="f.py")
66
+ kinds = sorted((f.kind, f.param) for f in findings)
67
+ assert kinds == [("missing", "full_name"), ("stale", "name")]
68
+
69
+
70
+ def test_matching_params_produce_no_findings():
71
+ src = '''
72
+ def greet(name, title):
73
+ """Greet someone.
74
+
75
+ Args:
76
+ name (str): the name
77
+ title (str): the honorific
78
+ """
79
+ return name
80
+ '''
81
+ assert scan_source(src) == []
82
+
83
+
84
+ def test_self_and_cls_never_flagged():
85
+ src = '''
86
+ class Greeter:
87
+ def greet(self, name):
88
+ """Greet someone.
89
+
90
+ Args:
91
+ name (str): the name
92
+ """
93
+ return name
94
+
95
+ @classmethod
96
+ def make(cls, name):
97
+ """Make a greeter.
98
+
99
+ Args:
100
+ name (str): the name
101
+ """
102
+ return cls()
103
+ '''
104
+ assert scan_source(src) == []
105
+
106
+
107
+ def test_async_function_supported():
108
+ src = '''
109
+ async def greet(name):
110
+ """Greet someone.
111
+
112
+ Args:
113
+ name (str): the name
114
+ title (str): the honorific
115
+ """
116
+ return name
117
+ '''
118
+ findings = scan_source(src)
119
+ assert len(findings) == 1
120
+ assert findings[0].kind == "stale"
121
+ assert findings[0].param == "title"
122
+
123
+
124
+ def test_numpy_style_drift_detected():
125
+ src = '''
126
+ def scale(x, factor):
127
+ """Scale a value.
128
+
129
+ Parameters
130
+ ----------
131
+ x : float
132
+ value to scale
133
+ """
134
+ return x * factor
135
+ '''
136
+ findings = scan_source(src)
137
+ assert len(findings) == 1
138
+ assert findings[0].kind == "missing"
139
+ assert findings[0].param == "factor"
140
+
141
+
142
+ def test_sphinx_style_drift_detected():
143
+ src = '''
144
+ def scale(x):
145
+ """Scale a value.
146
+
147
+ :param x: value to scale
148
+ :param factor: unused now
149
+ """
150
+ return x
151
+ '''
152
+ findings = scan_source(src)
153
+ assert len(findings) == 1
154
+ assert findings[0].kind == "stale"
155
+ assert findings[0].param == "factor"
156
+
157
+
158
+ def test_varargs_and_kwargs_matched_by_name():
159
+ src = '''
160
+ def run(*args, **kwargs):
161
+ """Run something.
162
+
163
+ Args:
164
+ args: positional args
165
+ kwargs: keyword args
166
+ """
167
+ pass
168
+ '''
169
+ assert scan_source(src) == []
170
+
171
+
172
+ def test_findings_sorted_by_line_then_function():
173
+ src = '''
174
+ def b(x):
175
+ """B.
176
+
177
+ Args:
178
+ x: val
179
+ y: stale
180
+ """
181
+ return x
182
+
183
+
184
+ def a(x):
185
+ """A.
186
+
187
+ Args:
188
+ x: val
189
+ z: stale
190
+ """
191
+ return x
192
+ '''
193
+ findings = scan_source(src)
194
+ assert [f.function for f in findings] == ["b", "a"]
195
+
196
+
197
+ def test_finding_str_format():
198
+ src = '''
199
+ def greet(name):
200
+ """Greet.
201
+
202
+ Args:
203
+ name: the name
204
+ title: stale
205
+ """
206
+ return name
207
+ '''
208
+ findings = scan_source(src, filename="pkg/mod.py")
209
+ s = str(findings[0])
210
+ assert "pkg/mod.py" in s
211
+ assert "greet" in s
212
+ assert "title" in s