docparity 0.1.0__py3-none-any.whl

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.
docparity/__init__.py ADDED
@@ -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"
docparity/cli.py ADDED
@@ -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()
docparity/scanner.py ADDED
@@ -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))
@@ -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,9 @@
1
+ docparity/__init__.py,sha256=Z2BuEgsGYUThYVxXPNIeCWcvlddsg1-dt_zM8Y14uz8,214
2
+ docparity/cli.py,sha256=DP3POljVROSZPXS7KsjrX9fD3Eancc0taMFkqIrZNYM,1963
3
+ docparity/docstring_parser.py,sha256=qIhmYs7fhd8YBibODOcrULRiuz6zf-1A9DEXmRiKD1s,3678
4
+ docparity/scanner.py,sha256=I5SzZM330iCynvxn4RIHTLgX9GiP5S_qOztIF8SzQ-I,3332
5
+ docparity-0.1.0.dist-info/METADATA,sha256=Ict1_r3y8uQBDb8ZnlJRnyGKo3MoQiyTEx87lQxJ8kI,3002
6
+ docparity-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ docparity-0.1.0.dist-info/entry_points.txt,sha256=kKZZRagnBj1WPdE2-7whqaRZcJnMAxIJhQziSpsRfIc,49
8
+ docparity-0.1.0.dist-info/licenses/LICENSE,sha256=xEsRvzJ-7Nbqa9ppe87ZOX9Xgmc2CJ7HOEFGRa0rcpU,1060
9
+ docparity-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docparity = docparity.cli:main
@@ -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.