api-claim-audit 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.
- api_claim_audit-0.1.0/LICENSE +21 -0
- api_claim_audit-0.1.0/PKG-INFO +135 -0
- api_claim_audit-0.1.0/README.md +124 -0
- api_claim_audit-0.1.0/pyproject.toml +19 -0
- api_claim_audit-0.1.0/setup.cfg +4 -0
- api_claim_audit-0.1.0/src/api_claim_audit/__init__.py +2 -0
- api_claim_audit-0.1.0/src/api_claim_audit/__main__.py +3 -0
- api_claim_audit-0.1.0/src/api_claim_audit/audit.py +294 -0
- api_claim_audit-0.1.0/src/api_claim_audit/cli.py +37 -0
- api_claim_audit-0.1.0/src/api_claim_audit.egg-info/PKG-INFO +135 -0
- api_claim_audit-0.1.0/src/api_claim_audit.egg-info/SOURCES.txt +13 -0
- api_claim_audit-0.1.0/src/api_claim_audit.egg-info/dependency_links.txt +1 -0
- api_claim_audit-0.1.0/src/api_claim_audit.egg-info/entry_points.txt +2 -0
- api_claim_audit-0.1.0/src/api_claim_audit.egg-info/top_level.txt +1 -0
- api_claim_audit-0.1.0/tests/test_audit.py +269 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 api-claim-audit contributors
|
|
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,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: api-claim-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline static audit of documented Python API claims
|
|
5
|
+
Author: api-claim-audit contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# api-claim-audit
|
|
13
|
+
|
|
14
|
+
Offline CLI that checks a Python project's documented public API against its
|
|
15
|
+
source on disk. Version 0.1.0 uses only the Python standard library (Python
|
|
16
|
+
3.11+). It never imports or executes project code, accesses the network, or
|
|
17
|
+
reads `.env` contents. Reports contain matched claims and rule names, never
|
|
18
|
+
unrelated file contents.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Install with `pip install .`, then run:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
api-claim-audit .
|
|
26
|
+
api-claim-audit /path/to/project --json
|
|
27
|
+
api-claim-audit . --fail-on HIGH
|
|
28
|
+
api-claim-audit . --ignore documented-import-missing --verbose
|
|
29
|
+
api-claim-audit --version
|
|
30
|
+
python -m api_claim_audit .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The path defaults to `.`. `--ignore RULE,...` drops named rules entirely; for
|
|
34
|
+
example, ignore an import rule when documenting a symbol provided only by an
|
|
35
|
+
optional extra. Unknown rules and invalid arguments are usage errors.
|
|
36
|
+
|
|
37
|
+
For a package defining `greet(name)` and `Config.__init__(name="x")`, this
|
|
38
|
+
README example verifies an import and a call:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from pkg import greet, Config
|
|
42
|
+
greet(name="Ada")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Changing the call to `greet(nam="Ada")` reports an unknown keyword. Documenting
|
|
46
|
+
`from pkg import missing_thing` reports a missing import. Constructor keyword
|
|
47
|
+
arguments are checked against `__init__`; `pkg.Config.run` checks a class method.
|
|
48
|
+
Imports with aliases check the original name, and imported aliases resolve
|
|
49
|
+
function and constructor calls within the same fenced example.
|
|
50
|
+
|
|
51
|
+
Human output prints `SEVERITY rule file:line message` for each finding, then a
|
|
52
|
+
PASS/FAIL summary. Verified claims print only with `--verbose`. `--json` returns:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{"status":"PASS","findings":[],"verified_claims":[{"claim":"from pkg import greet","file":"README.md"}]}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Each finding has `severity`, `rule`, `file`, `line`, and `message`; findings sort
|
|
59
|
+
by `(rule, file, line)`. Exit codes are **0** for PASS, **2** for FAIL, and **1**
|
|
60
|
+
for usage/input errors. The default failure threshold is MEDIUM. With
|
|
61
|
+
`--fail-on HIGH`, MEDIUM findings still print but do not fail.
|
|
62
|
+
|
|
63
|
+
## Full rule table
|
|
64
|
+
|
|
65
|
+
| Rule | Severity | Trigger |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `documented-import-missing` | HIGH | `from pkg[.sub] import Name` names a symbol absent from an existing module. Includes the import line and missing symbol in the message. |
|
|
68
|
+
| `documented-module-missing` | HIGH | `import pkg.sub` or `from pkg.sub import ...` names a module absent on disk; a missing own top-level package uses this rule too. |
|
|
69
|
+
| `documented-symbol-missing` | HIGH | `pkg[.sub].Name` is absent, or `pkg[.sub].Class.method` names a missing method. One finding per missing reference. |
|
|
70
|
+
| `documented-kwarg-unknown` | MEDIUM | A resolved function or constructor call passes a keyword its signature does not accept, without a `**kwargs` catch-all. One finding per unknown keyword. Positional-only parameters are not keyword-passable. |
|
|
71
|
+
| `verified_claims` | Not a finding | Resolved imports, symbols, and calls appear as claim text plus doc file in JSON; human output shows them with `--verbose`. No claims means PASS with zero verified claims. |
|
|
72
|
+
|
|
73
|
+
## Scan boundaries
|
|
74
|
+
|
|
75
|
+
Scans top-level `*.md` (including README.md) and recursive `docs/**/*.md`.
|
|
76
|
+
Imports are claims only inside Python (`python`, `py`) or bare backtick fences,
|
|
77
|
+
or inline code spans. Dotted references and calls are checked in fences only.
|
|
78
|
+
Malformed import syntax is skipped. Plain prose is not a claim.
|
|
79
|
+
|
|
80
|
+
Own names come from the normalized `[project].name` in pyproject.toml (lowercase,
|
|
81
|
+
dashes replaced by underscores), packages containing `__init__.py` under the
|
|
82
|
+
root or `src/`, and single Python modules there. Third-party names are ignored.
|
|
83
|
+
Module evidence comes from Python files and package initializers. Top-level
|
|
84
|
+
functions, classes, assignments, annotated assignments, and explicit
|
|
85
|
+
`from ... import ...` re-exports count as symbols. Class methods come from the
|
|
86
|
+
class body. Re-exports count in any module, including `__init__.py`.
|
|
87
|
+
|
|
88
|
+
Tests are never evidence: `tests/`, `test_*.py`, and `*_test.py` are excluded.
|
|
89
|
+
The scanner also skips `.venv`, `__pycache__`, `dist`, `.git`, `node_modules`,
|
|
90
|
+
and `.egg-info` directories. Symlinks are not followed. Unparseable Python
|
|
91
|
+
source produces an input error rather than a misleading missing-symbol report.
|
|
92
|
+
|
|
93
|
+
## Differentiation
|
|
94
|
+
|
|
95
|
+
- **readme-claim-audit** audits install names, --flags, links, versions, make
|
|
96
|
+
targets, and `python -m` paths. This tool audits documented importable symbols,
|
|
97
|
+
modules, and call signatures; there is no overlap in rules.
|
|
98
|
+
- **apimirage** really imports code at runtime to check whether an API exists,
|
|
99
|
+
including LLM-hallucinated APIs. This tool is static and offline, executes
|
|
100
|
+
nothing, and checks a project's own docs against its own source.
|
|
101
|
+
- **pyflakes/ruff** check names used inside code. This checks names promised
|
|
102
|
+
inside documentation.
|
|
103
|
+
- **Sphinx autodoc** generates docs from code and cannot drift in that generated
|
|
104
|
+
surface. Hand-written README/docs examples can drift; this audits that drift.
|
|
105
|
+
- **dep-claim-audit / privacy-claim-audit** audit dependency and privacy prose.
|
|
106
|
+
This audits API prose only, within the code formatting boundaries above.
|
|
107
|
+
|
|
108
|
+
## Honest limitations
|
|
109
|
+
|
|
110
|
+
Static scanning misses dynamic symbols: importlib, module `__getattr__` hooks,
|
|
111
|
+
exec/eval-defined names, and symbols added by decorators at runtime. A symbol
|
|
112
|
+
that exists but is not actually importable, for example behind a failing
|
|
113
|
+
top-level import, still counts as present.
|
|
114
|
+
|
|
115
|
+
Method checks cover only the `Class.method` dotted form. Instance calls such as
|
|
116
|
+
`client.foo()` cannot be resolved statically and are skipped. Inherited methods
|
|
117
|
+
and inherited constructor signatures are not inferred. Calls to functions
|
|
118
|
+
accepting `**kwargs` skip keyword checks entirely. Unresolved callees are skipped
|
|
119
|
+
by the keyword rule. Required arguments, argument values and types, positional
|
|
120
|
+
arity, and runtime behavior are not checked. Star re-exports are not expanded.
|
|
121
|
+
|
|
122
|
+
Claim detection is limited to fenced code blocks and inline code spans in docs;
|
|
123
|
+
usage written as plain prose without code formatting is not checked. Inline
|
|
124
|
+
spans support import claims only. Third-party imports are ignored by design.
|
|
125
|
+
Examples are static evidence, not proof that code runs successfully.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
131
|
+
python3 -m build
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Builds produce an sdist and wheel. Tests use local temporary fixtures and no
|
|
135
|
+
network. MIT licensed.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# api-claim-audit
|
|
2
|
+
|
|
3
|
+
Offline CLI that checks a Python project's documented public API against its
|
|
4
|
+
source on disk. Version 0.1.0 uses only the Python standard library (Python
|
|
5
|
+
3.11+). It never imports or executes project code, accesses the network, or
|
|
6
|
+
reads `.env` contents. Reports contain matched claims and rule names, never
|
|
7
|
+
unrelated file contents.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Install with `pip install .`, then run:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
api-claim-audit .
|
|
15
|
+
api-claim-audit /path/to/project --json
|
|
16
|
+
api-claim-audit . --fail-on HIGH
|
|
17
|
+
api-claim-audit . --ignore documented-import-missing --verbose
|
|
18
|
+
api-claim-audit --version
|
|
19
|
+
python -m api_claim_audit .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The path defaults to `.`. `--ignore RULE,...` drops named rules entirely; for
|
|
23
|
+
example, ignore an import rule when documenting a symbol provided only by an
|
|
24
|
+
optional extra. Unknown rules and invalid arguments are usage errors.
|
|
25
|
+
|
|
26
|
+
For a package defining `greet(name)` and `Config.__init__(name="x")`, this
|
|
27
|
+
README example verifies an import and a call:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from pkg import greet, Config
|
|
31
|
+
greet(name="Ada")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Changing the call to `greet(nam="Ada")` reports an unknown keyword. Documenting
|
|
35
|
+
`from pkg import missing_thing` reports a missing import. Constructor keyword
|
|
36
|
+
arguments are checked against `__init__`; `pkg.Config.run` checks a class method.
|
|
37
|
+
Imports with aliases check the original name, and imported aliases resolve
|
|
38
|
+
function and constructor calls within the same fenced example.
|
|
39
|
+
|
|
40
|
+
Human output prints `SEVERITY rule file:line message` for each finding, then a
|
|
41
|
+
PASS/FAIL summary. Verified claims print only with `--verbose`. `--json` returns:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{"status":"PASS","findings":[],"verified_claims":[{"claim":"from pkg import greet","file":"README.md"}]}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Each finding has `severity`, `rule`, `file`, `line`, and `message`; findings sort
|
|
48
|
+
by `(rule, file, line)`. Exit codes are **0** for PASS, **2** for FAIL, and **1**
|
|
49
|
+
for usage/input errors. The default failure threshold is MEDIUM. With
|
|
50
|
+
`--fail-on HIGH`, MEDIUM findings still print but do not fail.
|
|
51
|
+
|
|
52
|
+
## Full rule table
|
|
53
|
+
|
|
54
|
+
| Rule | Severity | Trigger |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `documented-import-missing` | HIGH | `from pkg[.sub] import Name` names a symbol absent from an existing module. Includes the import line and missing symbol in the message. |
|
|
57
|
+
| `documented-module-missing` | HIGH | `import pkg.sub` or `from pkg.sub import ...` names a module absent on disk; a missing own top-level package uses this rule too. |
|
|
58
|
+
| `documented-symbol-missing` | HIGH | `pkg[.sub].Name` is absent, or `pkg[.sub].Class.method` names a missing method. One finding per missing reference. |
|
|
59
|
+
| `documented-kwarg-unknown` | MEDIUM | A resolved function or constructor call passes a keyword its signature does not accept, without a `**kwargs` catch-all. One finding per unknown keyword. Positional-only parameters are not keyword-passable. |
|
|
60
|
+
| `verified_claims` | Not a finding | Resolved imports, symbols, and calls appear as claim text plus doc file in JSON; human output shows them with `--verbose`. No claims means PASS with zero verified claims. |
|
|
61
|
+
|
|
62
|
+
## Scan boundaries
|
|
63
|
+
|
|
64
|
+
Scans top-level `*.md` (including README.md) and recursive `docs/**/*.md`.
|
|
65
|
+
Imports are claims only inside Python (`python`, `py`) or bare backtick fences,
|
|
66
|
+
or inline code spans. Dotted references and calls are checked in fences only.
|
|
67
|
+
Malformed import syntax is skipped. Plain prose is not a claim.
|
|
68
|
+
|
|
69
|
+
Own names come from the normalized `[project].name` in pyproject.toml (lowercase,
|
|
70
|
+
dashes replaced by underscores), packages containing `__init__.py` under the
|
|
71
|
+
root or `src/`, and single Python modules there. Third-party names are ignored.
|
|
72
|
+
Module evidence comes from Python files and package initializers. Top-level
|
|
73
|
+
functions, classes, assignments, annotated assignments, and explicit
|
|
74
|
+
`from ... import ...` re-exports count as symbols. Class methods come from the
|
|
75
|
+
class body. Re-exports count in any module, including `__init__.py`.
|
|
76
|
+
|
|
77
|
+
Tests are never evidence: `tests/`, `test_*.py`, and `*_test.py` are excluded.
|
|
78
|
+
The scanner also skips `.venv`, `__pycache__`, `dist`, `.git`, `node_modules`,
|
|
79
|
+
and `.egg-info` directories. Symlinks are not followed. Unparseable Python
|
|
80
|
+
source produces an input error rather than a misleading missing-symbol report.
|
|
81
|
+
|
|
82
|
+
## Differentiation
|
|
83
|
+
|
|
84
|
+
- **readme-claim-audit** audits install names, --flags, links, versions, make
|
|
85
|
+
targets, and `python -m` paths. This tool audits documented importable symbols,
|
|
86
|
+
modules, and call signatures; there is no overlap in rules.
|
|
87
|
+
- **apimirage** really imports code at runtime to check whether an API exists,
|
|
88
|
+
including LLM-hallucinated APIs. This tool is static and offline, executes
|
|
89
|
+
nothing, and checks a project's own docs against its own source.
|
|
90
|
+
- **pyflakes/ruff** check names used inside code. This checks names promised
|
|
91
|
+
inside documentation.
|
|
92
|
+
- **Sphinx autodoc** generates docs from code and cannot drift in that generated
|
|
93
|
+
surface. Hand-written README/docs examples can drift; this audits that drift.
|
|
94
|
+
- **dep-claim-audit / privacy-claim-audit** audit dependency and privacy prose.
|
|
95
|
+
This audits API prose only, within the code formatting boundaries above.
|
|
96
|
+
|
|
97
|
+
## Honest limitations
|
|
98
|
+
|
|
99
|
+
Static scanning misses dynamic symbols: importlib, module `__getattr__` hooks,
|
|
100
|
+
exec/eval-defined names, and symbols added by decorators at runtime. A symbol
|
|
101
|
+
that exists but is not actually importable, for example behind a failing
|
|
102
|
+
top-level import, still counts as present.
|
|
103
|
+
|
|
104
|
+
Method checks cover only the `Class.method` dotted form. Instance calls such as
|
|
105
|
+
`client.foo()` cannot be resolved statically and are skipped. Inherited methods
|
|
106
|
+
and inherited constructor signatures are not inferred. Calls to functions
|
|
107
|
+
accepting `**kwargs` skip keyword checks entirely. Unresolved callees are skipped
|
|
108
|
+
by the keyword rule. Required arguments, argument values and types, positional
|
|
109
|
+
arity, and runtime behavior are not checked. Star re-exports are not expanded.
|
|
110
|
+
|
|
111
|
+
Claim detection is limited to fenced code blocks and inline code spans in docs;
|
|
112
|
+
usage written as plain prose without code formatting is not checked. Inline
|
|
113
|
+
spans support import claims only. Third-party imports are ignored by design.
|
|
114
|
+
Examples are static evidence, not proof that code runs successfully.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
120
|
+
python3 -m build
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Builds produce an sdist and wheel. Tests use local temporary fixtures and no
|
|
124
|
+
network. MIT licensed.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "api-claim-audit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Offline static audit of documented Python API claims"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "api-claim-audit contributors"}]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
api-claim-audit = "api_claim_audit.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Static documentation API auditing; project code is never imported."""
|
|
2
|
+
import ast
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
RULES = {
|
|
10
|
+
'documented-import-missing': 'HIGH',
|
|
11
|
+
'documented-module-missing': 'HIGH',
|
|
12
|
+
'documented-symbol-missing': 'HIGH',
|
|
13
|
+
'documented-kwarg-unknown': 'MEDIUM',
|
|
14
|
+
}
|
|
15
|
+
SKIP = {'.venv', '__pycache__', 'dist', '.git', 'node_modules', 'tests'}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def excluded(path):
|
|
19
|
+
return any(p in SKIP or p.endswith('.egg-info') for p in path.parts)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Symbol:
|
|
24
|
+
keywords: set | None = None
|
|
25
|
+
catchall: bool = False
|
|
26
|
+
methods: set | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Module:
|
|
31
|
+
symbols: dict = field(default_factory=dict)
|
|
32
|
+
exports: dict = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def signature(node, methods=None):
|
|
36
|
+
args = node.args
|
|
37
|
+
return Symbol({a.arg for a in args.args + args.kwonlyargs
|
|
38
|
+
if a.arg not in {'self', 'cls'}}, args.kwarg is not None, methods)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def index_source(root):
|
|
42
|
+
modules, own = {}, set()
|
|
43
|
+
config = root / 'pyproject.toml'
|
|
44
|
+
if config.is_file():
|
|
45
|
+
try:
|
|
46
|
+
name = tomllib.loads(config.read_text(encoding='utf-8')).get('project', {}).get('name')
|
|
47
|
+
if name:
|
|
48
|
+
own.add(name.lower().replace('-', '_'))
|
|
49
|
+
except (ValueError, OSError):
|
|
50
|
+
pass
|
|
51
|
+
for base in (root, root / 'src'):
|
|
52
|
+
if not base.is_dir():
|
|
53
|
+
continue
|
|
54
|
+
for entry in sorted(base.iterdir()):
|
|
55
|
+
if entry.is_symlink() or excluded(entry.relative_to(root)):
|
|
56
|
+
continue
|
|
57
|
+
if entry.is_file() and entry.suffix == '.py':
|
|
58
|
+
files = [entry]
|
|
59
|
+
package = entry.stem
|
|
60
|
+
elif entry.is_dir() and (entry / '__init__.py').is_file():
|
|
61
|
+
package = entry.name
|
|
62
|
+
files = []
|
|
63
|
+
for directory, dirs, names in os.walk(entry, followlinks=False):
|
|
64
|
+
dirs[:] = sorted(d for d in dirs if not excluded(Path(d))
|
|
65
|
+
and not (Path(directory) / d).is_symlink())
|
|
66
|
+
files.extend(Path(directory) / n for n in sorted(names) if n.endswith('.py'))
|
|
67
|
+
else:
|
|
68
|
+
continue
|
|
69
|
+
if package.startswith('test_') or package.endswith('_test'):
|
|
70
|
+
continue
|
|
71
|
+
own.add(package)
|
|
72
|
+
for path in files:
|
|
73
|
+
if path.is_symlink() or path.stem.startswith('test_') or path.stem.endswith('_test'):
|
|
74
|
+
continue
|
|
75
|
+
parts = list(path.relative_to(base).with_suffix('').parts)
|
|
76
|
+
is_package = parts[-1] == '__init__'
|
|
77
|
+
if is_package:
|
|
78
|
+
parts.pop()
|
|
79
|
+
name = '.'.join(parts)
|
|
80
|
+
module = Module()
|
|
81
|
+
modules[name] = module
|
|
82
|
+
try:
|
|
83
|
+
tree = ast.parse(path.read_text(encoding='utf-8'))
|
|
84
|
+
except (SyntaxError, UnicodeError) as exc:
|
|
85
|
+
raise ValueError('Cannot parse Python source: ' + str(path.relative_to(root))) from exc
|
|
86
|
+
for node in tree.body:
|
|
87
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
88
|
+
module.symbols[node.name] = signature(node)
|
|
89
|
+
elif isinstance(node, ast.ClassDef):
|
|
90
|
+
methods = {n.name for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
|
|
91
|
+
init = next((n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == '__init__'), None)
|
|
92
|
+
module.symbols[node.name] = signature(init, methods) if init else Symbol(set(), False, methods)
|
|
93
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
94
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
95
|
+
for target in targets:
|
|
96
|
+
for item in ast.walk(target):
|
|
97
|
+
if isinstance(item, ast.Name):
|
|
98
|
+
module.symbols[item.id] = Symbol()
|
|
99
|
+
elif isinstance(node, ast.ImportFrom):
|
|
100
|
+
parent = parts if is_package else parts[:-1]
|
|
101
|
+
origin = '.'.join(parent[:len(parent) - node.level + 1]) if node.level else ''
|
|
102
|
+
origin = '.'.join(p for p in (origin, node.module) if p)
|
|
103
|
+
for alias in node.names:
|
|
104
|
+
if alias.name != '*':
|
|
105
|
+
local = alias.asname or alias.name
|
|
106
|
+
module.symbols[local] = Symbol()
|
|
107
|
+
module.exports[local] = (origin, alias.name)
|
|
108
|
+
return own, modules
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def code_regions(text):
|
|
112
|
+
"""Yield (text, first line, is fenced); preserve physical line numbers."""
|
|
113
|
+
lines = text.splitlines()
|
|
114
|
+
start, block, active, accepted, fence = 0, [], False, False, 0
|
|
115
|
+
for number, line in enumerate(lines, 1):
|
|
116
|
+
match = re.match(r'^\s*(`{3,})(.*)$', line)
|
|
117
|
+
if match:
|
|
118
|
+
ticks, info = match.groups()
|
|
119
|
+
if not active:
|
|
120
|
+
active, accepted, fence = True, info.strip() in {'', 'py', 'python'}, len(ticks)
|
|
121
|
+
start, block = number + 1, []
|
|
122
|
+
elif len(ticks) >= fence and not info.strip():
|
|
123
|
+
if accepted:
|
|
124
|
+
yield '\n'.join(block), start, True
|
|
125
|
+
active = False
|
|
126
|
+
elif accepted:
|
|
127
|
+
block.append(line)
|
|
128
|
+
elif active:
|
|
129
|
+
if accepted:
|
|
130
|
+
block.append(line)
|
|
131
|
+
else:
|
|
132
|
+
for match in re.finditer(r'(?<!`)(`+)(?!`)(.+?)(?<!`)\1(?!`)', line):
|
|
133
|
+
yield match.group(2), number, False
|
|
134
|
+
if active and accepted:
|
|
135
|
+
yield '\n'.join(block), start, True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def audit(path='.', fail_on='MEDIUM', ignore=()):
|
|
139
|
+
root = Path(path).resolve()
|
|
140
|
+
if not root.is_dir():
|
|
141
|
+
raise ValueError('Project path must be a directory')
|
|
142
|
+
if fail_on not in {'HIGH', 'MEDIUM'}:
|
|
143
|
+
raise ValueError('Invalid severity threshold')
|
|
144
|
+
unknown = set(ignore) - RULES.keys()
|
|
145
|
+
if unknown:
|
|
146
|
+
raise ValueError('Unknown ignored rule: ' + ', '.join(sorted(unknown)))
|
|
147
|
+
own, modules = index_source(root)
|
|
148
|
+
findings, verified = [], []
|
|
149
|
+
|
|
150
|
+
def resolve(name, seen=None):
|
|
151
|
+
seen = set() if seen is None else seen
|
|
152
|
+
if name in seen:
|
|
153
|
+
return None
|
|
154
|
+
seen.add(name)
|
|
155
|
+
mod, _, symbol = name.rpartition('.')
|
|
156
|
+
if mod in modules and symbol in modules[mod].symbols:
|
|
157
|
+
record = modules[mod]
|
|
158
|
+
if symbol in record.exports:
|
|
159
|
+
origin, original = record.exports[symbol]
|
|
160
|
+
return resolve(origin + '.' + original, seen) or record.symbols[symbol]
|
|
161
|
+
return record.symbols[symbol]
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
docs = set(root.glob('*.md'))
|
|
165
|
+
docs_root = root / 'docs'
|
|
166
|
+
if docs_root.is_dir() and not docs_root.is_symlink():
|
|
167
|
+
for directory, dirs, names in os.walk(docs_root, followlinks=False):
|
|
168
|
+
dirs[:] = sorted(d for d in dirs if not excluded(Path(d)) and not (Path(directory) / d).is_symlink())
|
|
169
|
+
docs.update(Path(directory) / n for n in names if n.endswith('.md'))
|
|
170
|
+
for doc in sorted(docs):
|
|
171
|
+
if doc.is_symlink() or excluded(doc.relative_to(root)):
|
|
172
|
+
continue
|
|
173
|
+
filename = doc.relative_to(root).as_posix()
|
|
174
|
+
def good(claim):
|
|
175
|
+
item = {'claim': claim, 'file': filename}
|
|
176
|
+
if item not in verified:
|
|
177
|
+
verified.append(item)
|
|
178
|
+
def bad(rule, line, message):
|
|
179
|
+
if rule not in ignore:
|
|
180
|
+
findings.append(dict(severity=RULES[rule], rule=rule, file=filename, line=line, message=message))
|
|
181
|
+
for code, start, fenced in code_regions(doc.read_text(encoding='utf-8')):
|
|
182
|
+
aliases, import_lines = {}, set()
|
|
183
|
+
# Imports are parsed independently so a shell prompt or incomplete example
|
|
184
|
+
# elsewhere in the fence cannot conceal a valid import claim.
|
|
185
|
+
lines = code.splitlines()
|
|
186
|
+
offset = 0
|
|
187
|
+
imports = []
|
|
188
|
+
while offset < len(lines):
|
|
189
|
+
line = lines[offset]
|
|
190
|
+
if not re.match(r'^\s*(from\s|import\s)', line):
|
|
191
|
+
offset += 1
|
|
192
|
+
continue
|
|
193
|
+
end = offset + 1
|
|
194
|
+
while True:
|
|
195
|
+
candidate = '\n'.join(lines[offset:end])
|
|
196
|
+
try:
|
|
197
|
+
tree = ast.parse(candidate.strip())
|
|
198
|
+
except SyntaxError:
|
|
199
|
+
# Continue only an explicitly continued import, not
|
|
200
|
+
# arbitrary subsequent example lines.
|
|
201
|
+
if end < len(lines) and (candidate.rstrip().endswith('\\') or
|
|
202
|
+
candidate.count('(') > candidate.count(')')):
|
|
203
|
+
end += 1
|
|
204
|
+
continue
|
|
205
|
+
tree = None
|
|
206
|
+
break
|
|
207
|
+
import_lines.update(range(offset + 1, end + 1))
|
|
208
|
+
if tree is not None and tree.body and all(isinstance(n, (ast.Import, ast.ImportFrom)) for n in tree.body):
|
|
209
|
+
imports.append((offset, candidate, tree))
|
|
210
|
+
offset = end
|
|
211
|
+
for offset, line, tree in imports:
|
|
212
|
+
line = ' '.join(line.split())
|
|
213
|
+
for node in tree.body:
|
|
214
|
+
if isinstance(node, ast.ImportFrom):
|
|
215
|
+
module = node.module or ''
|
|
216
|
+
if node.level or module.split('.')[0] not in own:
|
|
217
|
+
continue
|
|
218
|
+
if module not in modules:
|
|
219
|
+
bad('documented-module-missing', start + offset, line.strip() + ': missing module ' + module)
|
|
220
|
+
continue
|
|
221
|
+
for alias in node.names:
|
|
222
|
+
if alias.name == '*':
|
|
223
|
+
continue
|
|
224
|
+
aliases[alias.asname or alias.name] = module + '.' + alias.name
|
|
225
|
+
if alias.name not in modules[module].symbols:
|
|
226
|
+
bad('documented-import-missing', start + offset, line.strip() + ': missing symbol ' + alias.name)
|
|
227
|
+
else:
|
|
228
|
+
good('from ' + module + ' import ' + alias.name)
|
|
229
|
+
else:
|
|
230
|
+
for alias in node.names:
|
|
231
|
+
if alias.name.split('.')[0] not in own:
|
|
232
|
+
continue
|
|
233
|
+
aliases[alias.asname or alias.name.split('.')[0]] = alias.name if alias.asname else alias.name.split('.')[0]
|
|
234
|
+
if alias.name not in modules:
|
|
235
|
+
bad('documented-module-missing', start + offset, line.strip() + ': missing module ' + alias.name)
|
|
236
|
+
else:
|
|
237
|
+
good('import ' + alias.name)
|
|
238
|
+
if not fenced:
|
|
239
|
+
continue
|
|
240
|
+
for offset, line in enumerate(code.splitlines()):
|
|
241
|
+
if offset + 1 in import_lines:
|
|
242
|
+
continue
|
|
243
|
+
for match in re.finditer(r'\b[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+', line):
|
|
244
|
+
reference = match.group()
|
|
245
|
+
parts = reference.split('.')
|
|
246
|
+
if parts[0] not in own:
|
|
247
|
+
continue
|
|
248
|
+
valid = reference in modules or resolve(reference) is not None
|
|
249
|
+
if not valid and len(parts) >= 3:
|
|
250
|
+
cls = resolve('.'.join(parts[:-1]))
|
|
251
|
+
valid = cls is not None and cls.methods is not None and parts[-1] in cls.methods
|
|
252
|
+
if valid:
|
|
253
|
+
good(reference)
|
|
254
|
+
else:
|
|
255
|
+
bad('documented-symbol-missing', start + offset, 'Missing documented reference ' + reference)
|
|
256
|
+
try:
|
|
257
|
+
tree = ast.parse(code)
|
|
258
|
+
except SyntaxError:
|
|
259
|
+
# Recover individually valid lines in incomplete snippets.
|
|
260
|
+
tree = ast.Module(body=[], type_ignores=[])
|
|
261
|
+
for offset, line in enumerate(code.splitlines()):
|
|
262
|
+
try:
|
|
263
|
+
part = ast.parse(line.strip())
|
|
264
|
+
except SyntaxError:
|
|
265
|
+
continue
|
|
266
|
+
ast.increment_lineno(part, offset)
|
|
267
|
+
tree.body.extend(part.body)
|
|
268
|
+
for node in ast.walk(tree):
|
|
269
|
+
if not isinstance(node, ast.Call):
|
|
270
|
+
continue
|
|
271
|
+
def dotted(expr):
|
|
272
|
+
if isinstance(expr, ast.Name):
|
|
273
|
+
return expr.id
|
|
274
|
+
if isinstance(expr, ast.Attribute):
|
|
275
|
+
parent = dotted(expr.value)
|
|
276
|
+
return parent + '.' + expr.attr if parent else ''
|
|
277
|
+
return ''
|
|
278
|
+
callee = dotted(node.func)
|
|
279
|
+
first, dot, tail = callee.partition('.')
|
|
280
|
+
expanded = aliases.get(first, first) + (dot + tail if dot else '')
|
|
281
|
+
if expanded.split('.')[0] not in own:
|
|
282
|
+
continue
|
|
283
|
+
symbol = resolve(expanded)
|
|
284
|
+
if symbol is None or symbol.keywords is None:
|
|
285
|
+
continue
|
|
286
|
+
invalid = [] if symbol.catchall else [k.arg for k in node.keywords if k.arg is not None and k.arg not in symbol.keywords]
|
|
287
|
+
for keyword in invalid:
|
|
288
|
+
bad('documented-kwarg-unknown', start + node.lineno - 1, callee + ': unknown keyword ' + keyword)
|
|
289
|
+
if not invalid:
|
|
290
|
+
good(ast.get_source_segment(code, node) or callee + '(...)')
|
|
291
|
+
findings.sort(key=lambda f: (f['rule'], f['file'], f['line']))
|
|
292
|
+
verified.sort(key=lambda v: (v['file'], v['claim']))
|
|
293
|
+
failed = any(f['severity'] == 'HIGH' or fail_on == 'MEDIUM' for f in findings)
|
|
294
|
+
return {'status': 'FAIL' if failed else 'PASS', 'findings': findings, 'verified_claims': verified}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from . import __version__
|
|
6
|
+
from .audit import audit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Parser(argparse.ArgumentParser):
|
|
10
|
+
def error(self, message):
|
|
11
|
+
self.print_usage(sys.stderr)
|
|
12
|
+
self.exit(1, self.prog + ': error: ' + message + '\n')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None):
|
|
16
|
+
parser = Parser(prog='api-claim-audit', description='Audit documented Python API claims without executing project code.')
|
|
17
|
+
parser.add_argument('path', nargs='?', default='.')
|
|
18
|
+
parser.add_argument('--json', action='store_true')
|
|
19
|
+
parser.add_argument('--fail-on', choices=['HIGH', 'MEDIUM'], default='MEDIUM')
|
|
20
|
+
parser.add_argument('--ignore', default='', metavar='RULE,...')
|
|
21
|
+
parser.add_argument('--verbose', action='store_true')
|
|
22
|
+
parser.add_argument('--version', action='version', version=__version__)
|
|
23
|
+
args = parser.parse_args(argv)
|
|
24
|
+
try:
|
|
25
|
+
result = audit(args.path, args.fail_on, [r.strip() for r in args.ignore.split(',') if r.strip()])
|
|
26
|
+
except (ValueError, OSError, UnicodeError) as exc:
|
|
27
|
+
parser.error(str(exc))
|
|
28
|
+
if args.json:
|
|
29
|
+
print(json.dumps(result, ensure_ascii=True))
|
|
30
|
+
else:
|
|
31
|
+
for item in result['findings']:
|
|
32
|
+
print('{severity} {rule} {file}:{line} {message}'.format(**item))
|
|
33
|
+
if args.verbose:
|
|
34
|
+
for item in result['verified_claims']:
|
|
35
|
+
print('VERIFIED {file} {claim}'.format(**item))
|
|
36
|
+
print(result['status'] + ': ' + str(len(result['findings'])) + ' finding(s)')
|
|
37
|
+
return 2 if result['status'] == 'FAIL' else 0
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: api-claim-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline static audit of documented Python API claims
|
|
5
|
+
Author: api-claim-audit contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# api-claim-audit
|
|
13
|
+
|
|
14
|
+
Offline CLI that checks a Python project's documented public API against its
|
|
15
|
+
source on disk. Version 0.1.0 uses only the Python standard library (Python
|
|
16
|
+
3.11+). It never imports or executes project code, accesses the network, or
|
|
17
|
+
reads `.env` contents. Reports contain matched claims and rule names, never
|
|
18
|
+
unrelated file contents.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Install with `pip install .`, then run:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
api-claim-audit .
|
|
26
|
+
api-claim-audit /path/to/project --json
|
|
27
|
+
api-claim-audit . --fail-on HIGH
|
|
28
|
+
api-claim-audit . --ignore documented-import-missing --verbose
|
|
29
|
+
api-claim-audit --version
|
|
30
|
+
python -m api_claim_audit .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The path defaults to `.`. `--ignore RULE,...` drops named rules entirely; for
|
|
34
|
+
example, ignore an import rule when documenting a symbol provided only by an
|
|
35
|
+
optional extra. Unknown rules and invalid arguments are usage errors.
|
|
36
|
+
|
|
37
|
+
For a package defining `greet(name)` and `Config.__init__(name="x")`, this
|
|
38
|
+
README example verifies an import and a call:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from pkg import greet, Config
|
|
42
|
+
greet(name="Ada")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Changing the call to `greet(nam="Ada")` reports an unknown keyword. Documenting
|
|
46
|
+
`from pkg import missing_thing` reports a missing import. Constructor keyword
|
|
47
|
+
arguments are checked against `__init__`; `pkg.Config.run` checks a class method.
|
|
48
|
+
Imports with aliases check the original name, and imported aliases resolve
|
|
49
|
+
function and constructor calls within the same fenced example.
|
|
50
|
+
|
|
51
|
+
Human output prints `SEVERITY rule file:line message` for each finding, then a
|
|
52
|
+
PASS/FAIL summary. Verified claims print only with `--verbose`. `--json` returns:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{"status":"PASS","findings":[],"verified_claims":[{"claim":"from pkg import greet","file":"README.md"}]}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Each finding has `severity`, `rule`, `file`, `line`, and `message`; findings sort
|
|
59
|
+
by `(rule, file, line)`. Exit codes are **0** for PASS, **2** for FAIL, and **1**
|
|
60
|
+
for usage/input errors. The default failure threshold is MEDIUM. With
|
|
61
|
+
`--fail-on HIGH`, MEDIUM findings still print but do not fail.
|
|
62
|
+
|
|
63
|
+
## Full rule table
|
|
64
|
+
|
|
65
|
+
| Rule | Severity | Trigger |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `documented-import-missing` | HIGH | `from pkg[.sub] import Name` names a symbol absent from an existing module. Includes the import line and missing symbol in the message. |
|
|
68
|
+
| `documented-module-missing` | HIGH | `import pkg.sub` or `from pkg.sub import ...` names a module absent on disk; a missing own top-level package uses this rule too. |
|
|
69
|
+
| `documented-symbol-missing` | HIGH | `pkg[.sub].Name` is absent, or `pkg[.sub].Class.method` names a missing method. One finding per missing reference. |
|
|
70
|
+
| `documented-kwarg-unknown` | MEDIUM | A resolved function or constructor call passes a keyword its signature does not accept, without a `**kwargs` catch-all. One finding per unknown keyword. Positional-only parameters are not keyword-passable. |
|
|
71
|
+
| `verified_claims` | Not a finding | Resolved imports, symbols, and calls appear as claim text plus doc file in JSON; human output shows them with `--verbose`. No claims means PASS with zero verified claims. |
|
|
72
|
+
|
|
73
|
+
## Scan boundaries
|
|
74
|
+
|
|
75
|
+
Scans top-level `*.md` (including README.md) and recursive `docs/**/*.md`.
|
|
76
|
+
Imports are claims only inside Python (`python`, `py`) or bare backtick fences,
|
|
77
|
+
or inline code spans. Dotted references and calls are checked in fences only.
|
|
78
|
+
Malformed import syntax is skipped. Plain prose is not a claim.
|
|
79
|
+
|
|
80
|
+
Own names come from the normalized `[project].name` in pyproject.toml (lowercase,
|
|
81
|
+
dashes replaced by underscores), packages containing `__init__.py` under the
|
|
82
|
+
root or `src/`, and single Python modules there. Third-party names are ignored.
|
|
83
|
+
Module evidence comes from Python files and package initializers. Top-level
|
|
84
|
+
functions, classes, assignments, annotated assignments, and explicit
|
|
85
|
+
`from ... import ...` re-exports count as symbols. Class methods come from the
|
|
86
|
+
class body. Re-exports count in any module, including `__init__.py`.
|
|
87
|
+
|
|
88
|
+
Tests are never evidence: `tests/`, `test_*.py`, and `*_test.py` are excluded.
|
|
89
|
+
The scanner also skips `.venv`, `__pycache__`, `dist`, `.git`, `node_modules`,
|
|
90
|
+
and `.egg-info` directories. Symlinks are not followed. Unparseable Python
|
|
91
|
+
source produces an input error rather than a misleading missing-symbol report.
|
|
92
|
+
|
|
93
|
+
## Differentiation
|
|
94
|
+
|
|
95
|
+
- **readme-claim-audit** audits install names, --flags, links, versions, make
|
|
96
|
+
targets, and `python -m` paths. This tool audits documented importable symbols,
|
|
97
|
+
modules, and call signatures; there is no overlap in rules.
|
|
98
|
+
- **apimirage** really imports code at runtime to check whether an API exists,
|
|
99
|
+
including LLM-hallucinated APIs. This tool is static and offline, executes
|
|
100
|
+
nothing, and checks a project's own docs against its own source.
|
|
101
|
+
- **pyflakes/ruff** check names used inside code. This checks names promised
|
|
102
|
+
inside documentation.
|
|
103
|
+
- **Sphinx autodoc** generates docs from code and cannot drift in that generated
|
|
104
|
+
surface. Hand-written README/docs examples can drift; this audits that drift.
|
|
105
|
+
- **dep-claim-audit / privacy-claim-audit** audit dependency and privacy prose.
|
|
106
|
+
This audits API prose only, within the code formatting boundaries above.
|
|
107
|
+
|
|
108
|
+
## Honest limitations
|
|
109
|
+
|
|
110
|
+
Static scanning misses dynamic symbols: importlib, module `__getattr__` hooks,
|
|
111
|
+
exec/eval-defined names, and symbols added by decorators at runtime. A symbol
|
|
112
|
+
that exists but is not actually importable, for example behind a failing
|
|
113
|
+
top-level import, still counts as present.
|
|
114
|
+
|
|
115
|
+
Method checks cover only the `Class.method` dotted form. Instance calls such as
|
|
116
|
+
`client.foo()` cannot be resolved statically and are skipped. Inherited methods
|
|
117
|
+
and inherited constructor signatures are not inferred. Calls to functions
|
|
118
|
+
accepting `**kwargs` skip keyword checks entirely. Unresolved callees are skipped
|
|
119
|
+
by the keyword rule. Required arguments, argument values and types, positional
|
|
120
|
+
arity, and runtime behavior are not checked. Star re-exports are not expanded.
|
|
121
|
+
|
|
122
|
+
Claim detection is limited to fenced code blocks and inline code spans in docs;
|
|
123
|
+
usage written as plain prose without code formatting is not checked. Inline
|
|
124
|
+
spans support import claims only. Third-party imports are ignored by design.
|
|
125
|
+
Examples are static evidence, not proof that code runs successfully.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
131
|
+
python3 -m build
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Builds produce an sdist and wheel. Tests use local temporary fixtures and no
|
|
135
|
+
network. MIT licensed.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/api_claim_audit/__init__.py
|
|
5
|
+
src/api_claim_audit/__main__.py
|
|
6
|
+
src/api_claim_audit/audit.py
|
|
7
|
+
src/api_claim_audit/cli.py
|
|
8
|
+
src/api_claim_audit.egg-info/PKG-INFO
|
|
9
|
+
src/api_claim_audit.egg-info/SOURCES.txt
|
|
10
|
+
src/api_claim_audit.egg-info/dependency_links.txt
|
|
11
|
+
src/api_claim_audit.egg-info/entry_points.txt
|
|
12
|
+
src/api_claim_audit.egg-info/top_level.txt
|
|
13
|
+
tests/test_audit.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
api_claim_audit
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from api_claim_audit.audit import audit
|
|
8
|
+
from api_claim_audit.cli import main
|
|
9
|
+
|
|
10
|
+
SOURCE = '''def greet(name, *, punctuation="!"):
|
|
11
|
+
return name + punctuation
|
|
12
|
+
class Config:
|
|
13
|
+
def __init__(self, name="x"):
|
|
14
|
+
self.name = name
|
|
15
|
+
def run(self):
|
|
16
|
+
pass
|
|
17
|
+
VALUE = 1
|
|
18
|
+
ANNOTATED: int = 2
|
|
19
|
+
async def async_greet(name):
|
|
20
|
+
pass
|
|
21
|
+
def flexible(**kwargs):
|
|
22
|
+
pass
|
|
23
|
+
def positional(name, /, *, option=1):
|
|
24
|
+
pass
|
|
25
|
+
'''
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuditTests(unittest.TestCase):
|
|
29
|
+
def setUp(self):
|
|
30
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
31
|
+
self.addCleanup(self.temp.cleanup)
|
|
32
|
+
self.root = Path(self.temp.name)
|
|
33
|
+
self.write('src/pkg/__init__.py', SOURCE)
|
|
34
|
+
|
|
35
|
+
def write(self, name, text):
|
|
36
|
+
path = self.root / name
|
|
37
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
path.write_text(text, encoding='utf-8')
|
|
39
|
+
|
|
40
|
+
def document(self, code, language='python', name='README.md'):
|
|
41
|
+
self.write(name, 'Intro\n```' + language + '\n' + code + '\n```\n')
|
|
42
|
+
|
|
43
|
+
def run_cli(self, *args):
|
|
44
|
+
out, err = io.StringIO(), io.StringIO()
|
|
45
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
46
|
+
try:
|
|
47
|
+
status = main([str(self.root), *args])
|
|
48
|
+
except SystemExit as exc:
|
|
49
|
+
status = exc.code
|
|
50
|
+
return status, out.getvalue(), err.getvalue()
|
|
51
|
+
|
|
52
|
+
def test_good_fixture(self):
|
|
53
|
+
self.document('from pkg import greet, Config\npkg.Config.run(...)\ngreet(name="x")')
|
|
54
|
+
status, output, _ = self.run_cli('--json')
|
|
55
|
+
self.assertEqual(status, 0)
|
|
56
|
+
result = json.loads(output)
|
|
57
|
+
self.assertEqual(result['findings'], [])
|
|
58
|
+
self.assertIn({'claim': 'from pkg import greet', 'file': 'README.md'}, result['verified_claims'])
|
|
59
|
+
|
|
60
|
+
def test_bad_import_exact(self):
|
|
61
|
+
self.document('from pkg import missing_thing')
|
|
62
|
+
self.assertEqual([x['rule'] for x in audit(self.root)['findings']], ['documented-import-missing'])
|
|
63
|
+
self.assertEqual(self.run_cli()[0], 2)
|
|
64
|
+
|
|
65
|
+
def test_bad_module_exact(self):
|
|
66
|
+
self.document('from pkg.nosuch import thing')
|
|
67
|
+
self.assertEqual([x['rule'] for x in audit(self.root)['findings']], ['documented-module-missing'])
|
|
68
|
+
|
|
69
|
+
def test_bad_symbols(self):
|
|
70
|
+
self.document('pkg.Nope\npkg.Config.nomethod')
|
|
71
|
+
self.assertEqual([x['rule'] for x in audit(self.root)['findings']], ['documented-symbol-missing'] * 2)
|
|
72
|
+
|
|
73
|
+
def test_bad_kwargs_exact(self):
|
|
74
|
+
self.document('from pkg import greet, Config\ngreet(nam="x")\nConfig(timeout=5)')
|
|
75
|
+
self.assertEqual([x['rule'] for x in audit(self.root)['findings']], ['documented-kwarg-unknown'] * 2)
|
|
76
|
+
|
|
77
|
+
def test_reexport_signature(self):
|
|
78
|
+
self.write('src/pkg/core.py', SOURCE)
|
|
79
|
+
self.write('src/pkg/__init__.py', 'from .core import greet')
|
|
80
|
+
self.document('from pkg import greet\ngreet(nam="x")')
|
|
81
|
+
self.assertEqual(audit(self.root)['findings'][0]['rule'], 'documented-kwarg-unknown')
|
|
82
|
+
|
|
83
|
+
def test_test_only(self):
|
|
84
|
+
self.write('tests/helpers.py', 'def missing_thing(): pass')
|
|
85
|
+
self.document('from pkg import missing_thing')
|
|
86
|
+
self.assertEqual(audit(self.root)['findings'][0]['rule'], 'documented-import-missing')
|
|
87
|
+
|
|
88
|
+
def test_ignore(self):
|
|
89
|
+
self.document('from pkg import missing')
|
|
90
|
+
status, output, _ = self.run_cli('--ignore', 'documented-import-missing', '--json')
|
|
91
|
+
self.assertEqual(status, 0)
|
|
92
|
+
self.assertEqual(json.loads(output)['findings'], [])
|
|
93
|
+
|
|
94
|
+
def test_high_threshold(self):
|
|
95
|
+
self.document('pkg.greet(nam="x")')
|
|
96
|
+
status, output, _ = self.run_cli('--fail-on', 'HIGH', '--json')
|
|
97
|
+
self.assertEqual(status, 0)
|
|
98
|
+
self.assertEqual(len(json.loads(output)['findings']), 1)
|
|
99
|
+
|
|
100
|
+
def test_medium_default(self):
|
|
101
|
+
self.document('pkg.greet(nam="x")')
|
|
102
|
+
self.assertEqual(self.run_cli()[0], 2)
|
|
103
|
+
|
|
104
|
+
def test_sorting(self):
|
|
105
|
+
self.document('pkg.Nope\npkg.greet(nam=1)\nfrom pkg import absent')
|
|
106
|
+
self.document('pkg.Other', name='docs/nested/a.md')
|
|
107
|
+
result = audit(self.root)
|
|
108
|
+
self.assertEqual(result['findings'], sorted(result['findings'], key=lambda f: (f['rule'], f['file'], f['line'])))
|
|
109
|
+
self.assertEqual(result, audit(self.root))
|
|
110
|
+
|
|
111
|
+
def test_line_numbers(self):
|
|
112
|
+
self.document('pkg.Nope\nfrom pkg import missing')
|
|
113
|
+
found = audit(self.root)['findings']
|
|
114
|
+
self.assertEqual({f['line'] for f in found}, {3, 4})
|
|
115
|
+
self.assertTrue(all(f['file'] == 'README.md' for f in found))
|
|
116
|
+
|
|
117
|
+
def test_verbose(self):
|
|
118
|
+
self.document('from pkg import greet')
|
|
119
|
+
self.assertNotIn('VERIFIED', self.run_cli()[1])
|
|
120
|
+
self.assertIn('VERIFIED', self.run_cli('--verbose')[1])
|
|
121
|
+
|
|
122
|
+
def test_version(self):
|
|
123
|
+
self.assertEqual(self.run_cli('--version')[:2], (0, '0.1.0\n'))
|
|
124
|
+
|
|
125
|
+
def test_invalid_option(self):
|
|
126
|
+
self.assertEqual(self.run_cli('--unknown')[0], 1)
|
|
127
|
+
|
|
128
|
+
def test_invalid_threshold(self):
|
|
129
|
+
self.assertEqual(self.run_cli('--fail-on', 'LOW')[0], 1)
|
|
130
|
+
|
|
131
|
+
def test_invalid_ignore(self):
|
|
132
|
+
self.assertEqual(self.run_cli('--ignore', 'typo')[0], 1)
|
|
133
|
+
|
|
134
|
+
def test_invalid_path(self):
|
|
135
|
+
with self.assertRaises(ValueError):
|
|
136
|
+
audit(self.root / 'absent')
|
|
137
|
+
|
|
138
|
+
def test_no_execution(self):
|
|
139
|
+
self.write('src/pkg/__init__.py', 'raise RuntimeError("never execute")\n' + SOURCE)
|
|
140
|
+
self.document('from pkg import greet')
|
|
141
|
+
self.assertEqual(audit(self.root)['status'], 'PASS')
|
|
142
|
+
|
|
143
|
+
def test_no_env_read(self):
|
|
144
|
+
(self.root / '.env').write_bytes(b'\xff\x00')
|
|
145
|
+
self.assertEqual(audit(self.root)['status'], 'PASS')
|
|
146
|
+
|
|
147
|
+
def test_root_package(self):
|
|
148
|
+
self.write('other/__init__.py', 'def fn(x): pass')
|
|
149
|
+
self.document('other.fn(typo=1)')
|
|
150
|
+
self.assertEqual(audit(self.root)['findings'][0]['rule'], 'documented-kwarg-unknown')
|
|
151
|
+
|
|
152
|
+
def test_single_module(self):
|
|
153
|
+
self.write('single.py', 'VALUE = 1')
|
|
154
|
+
self.document('from single import VALUE')
|
|
155
|
+
self.assertEqual(audit(self.root)['status'], 'PASS')
|
|
156
|
+
|
|
157
|
+
def test_normalized_project_name_missing(self):
|
|
158
|
+
self.write('pyproject.toml', '[project]\nname = "Absent-Pkg"')
|
|
159
|
+
self.document('import absent_pkg')
|
|
160
|
+
self.assertEqual(audit(self.root)['findings'][0]['rule'], 'documented-module-missing')
|
|
161
|
+
|
|
162
|
+
def test_nested_module(self):
|
|
163
|
+
self.write('src/pkg/sub/__init__.py', '')
|
|
164
|
+
self.write('src/pkg/sub/core.py', 'def hello(x): pass')
|
|
165
|
+
self.document('import pkg.sub.core\npkg.sub.core.hello(x=1)')
|
|
166
|
+
self.assertEqual(audit(self.root)['status'], 'PASS')
|
|
167
|
+
|
|
168
|
+
def test_test_files_excluded(self):
|
|
169
|
+
for name in ['test_hidden.py', 'hidden_test.py', 'tests/helper.py']:
|
|
170
|
+
self.write('src/pkg/' + name, 'def hidden(): pass')
|
|
171
|
+
self.document('import pkg.test_hidden\nimport pkg.hidden_test\nimport pkg.tests.helper')
|
|
172
|
+
self.assertEqual(len(audit(self.root)['findings']), 3)
|
|
173
|
+
|
|
174
|
+
def test_excluded_directories(self):
|
|
175
|
+
for name in ['.venv', 'dist', '.git', '__pycache__', 'x.egg-info', 'node_modules']:
|
|
176
|
+
self.write('src/pkg/' + name + '/bad.py', 'invalid source !!!')
|
|
177
|
+
self.assertEqual(audit(self.root)['status'], 'PASS')
|
|
178
|
+
|
|
179
|
+
def test_symlink_not_followed(self):
|
|
180
|
+
(self.root / 'src/pkg/loop').symlink_to(self.root, target_is_directory=True)
|
|
181
|
+
self.assertEqual(audit(self.root)['status'], 'PASS')
|
|
182
|
+
|
|
183
|
+
def test_invalid_source_error(self):
|
|
184
|
+
self.write('src/pkg/bad.py', 'invalid syntax !!!')
|
|
185
|
+
self.assertEqual(self.run_cli()[0], 1)
|
|
186
|
+
|
|
187
|
+
def test_inline_only_imports(self):
|
|
188
|
+
self.write('README.md', '`from pkg import absent` and `pkg.Nope`')
|
|
189
|
+
self.assertEqual([f['rule'] for f in audit(self.root)['findings']], ['documented-import-missing'])
|
|
190
|
+
|
|
191
|
+
def test_non_docs_ignored(self):
|
|
192
|
+
self.write('other/note.md', '```python\npkg.Nope\n```')
|
|
193
|
+
self.assertEqual(audit(self.root)['status'], 'PASS')
|
|
194
|
+
|
|
195
|
+
def test_reexport_cycle(self):
|
|
196
|
+
self.write('src/pkg/a.py', 'from .b import x')
|
|
197
|
+
self.write('src/pkg/b.py', 'from .a import x')
|
|
198
|
+
self.document('from pkg.a import x')
|
|
199
|
+
self.assertEqual(audit(self.root)['status'], 'PASS')
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
CASES = [
|
|
203
|
+
('multiline_import', 'from pkg import (\n greet,\n Config,\n)', None),
|
|
204
|
+
('multiline_import_bad', 'from pkg import (\n absent,\n)', 'documented-import-missing'),
|
|
205
|
+
('third_party', 'from requests import get\nrequests.get(url="x")', None),
|
|
206
|
+
('catchall', 'pkg.flexible(arbitrary=1)', None),
|
|
207
|
+
('no_kwargs', 'pkg.greet()', None),
|
|
208
|
+
('unknown_call', 'unknown(nam=1)', None),
|
|
209
|
+
('unresolved_call', 'pkg.unknown(nam=1)', 'documented-symbol-missing'),
|
|
210
|
+
('import_alias', 'from pkg import greet as hello\nhello(name="x")', None),
|
|
211
|
+
('import_alias_bad', 'from pkg import greet as hello\nhello(nam="x")', 'documented-kwarg-unknown'),
|
|
212
|
+
('module_alias', 'import pkg as p\np.greet(nam="x")', 'documented-kwarg-unknown'),
|
|
213
|
+
('import_module', 'import pkg', None),
|
|
214
|
+
('missing_import_module', 'import pkg.absent as p', 'documented-module-missing'),
|
|
215
|
+
('class_symbol', 'pkg.Config', None),
|
|
216
|
+
('method_exists', 'pkg.Config.run()', None),
|
|
217
|
+
('assignment', 'pkg.VALUE', None),
|
|
218
|
+
('annotated_assignment', 'pkg.ANNOTATED', None),
|
|
219
|
+
('async_signature', 'pkg.async_greet(nam=1)', 'documented-kwarg-unknown'),
|
|
220
|
+
('positional_only', 'pkg.positional(name="x")', 'documented-kwarg-unknown'),
|
|
221
|
+
('keyword_only', 'pkg.positional(option=1)', None),
|
|
222
|
+
('malformed_dotted_import', 'from pkg.nosuch import ???', None),
|
|
223
|
+
('malformed_import', 'from pkg import ???', None),
|
|
224
|
+
('star_import', 'from pkg import *', None),
|
|
225
|
+
('instance_method', 'client.foo(timeout=1)', None),
|
|
226
|
+
('expanded_kwargs', 'pkg.greet(**options)', None),
|
|
227
|
+
('multiline_call', 'pkg.greet(\n nam="x",\n)', 'documented-kwarg-unknown'),
|
|
228
|
+
('repeated_reference', 'pkg.Nope; pkg.Nope', 'documented-symbol-missing'),
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def make_case(code, rule):
|
|
233
|
+
def test(self):
|
|
234
|
+
self.document(code)
|
|
235
|
+
result = audit(self.root)
|
|
236
|
+
self.assertEqual(result['status'], 'FAIL' if rule else 'PASS')
|
|
237
|
+
self.assertEqual({f['rule'] for f in result['findings']}, {rule} if rule else set())
|
|
238
|
+
return test
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
for name, code, rule in CASES:
|
|
242
|
+
setattr(AuditTests, 'test_case_' + name, make_case(code, rule))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def make_markdown(text, count):
|
|
246
|
+
def test(self):
|
|
247
|
+
self.write('README.md', text)
|
|
248
|
+
result = audit(self.root)
|
|
249
|
+
self.assertEqual(len(result['findings']), count)
|
|
250
|
+
if not count:
|
|
251
|
+
self.assertEqual(result['verified_claims'], [])
|
|
252
|
+
return test
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
for name, text, count in [
|
|
256
|
+
('prose', 'pkg.Nope is mentioned in prose.', 0),
|
|
257
|
+
('empty', '# Command line\nUse the CLI.', 0),
|
|
258
|
+
('py_fence', '```py\npkg.Nope\n```', 1),
|
|
259
|
+
('bare_fence', '```\npkg.Nope\n```', 1),
|
|
260
|
+
('other_fence', '```javascript\npkg.Nope\n```', 0),
|
|
261
|
+
('inline_import', 'Use `from pkg import missing`.', 1),
|
|
262
|
+
('plain_import', 'from pkg import missing', 0),
|
|
263
|
+
('inline_third_party', '`from requests import get`', 0),
|
|
264
|
+
]:
|
|
265
|
+
setattr(AuditTests, 'test_markdown_' + name, make_markdown(text, count))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == '__main__':
|
|
269
|
+
unittest.main()
|