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.
- docparity-0.1.0/.github/workflows/ci.yml +22 -0
- docparity-0.1.0/.gitignore +6 -0
- docparity-0.1.0/DETAILS.md +57 -0
- docparity-0.1.0/LICENSE +21 -0
- docparity-0.1.0/PKG-INFO +95 -0
- docparity-0.1.0/README.md +75 -0
- docparity-0.1.0/docs/USAGE.md +60 -0
- docparity-0.1.0/examples/README.md +7 -0
- docparity-0.1.0/examples/clean_example.py +21 -0
- docparity-0.1.0/examples/drifted_example.py +26 -0
- docparity-0.1.0/pyproject.toml +32 -0
- docparity-0.1.0/pytest.ini +1 -0
- docparity-0.1.0/src/docparity/__init__.py +7 -0
- docparity-0.1.0/src/docparity/cli.py +77 -0
- docparity-0.1.0/src/docparity/docstring_parser.py +126 -0
- docparity-0.1.0/src/docparity/scanner.py +108 -0
- docparity-0.1.0/tests/__init__.py +0 -0
- docparity-0.1.0/tests/test_cli.py +86 -0
- docparity-0.1.0/tests/test_docstring_parser.py +82 -0
- docparity-0.1.0/tests/test_scanner.py +212 -0
|
@@ -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,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.
|
docparity-0.1.0/LICENSE
ADDED
|
@@ -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.
|
docparity-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|