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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Offline static audit of a project's documented Python API."""
2
+ __version__ = '0.1.0'
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ api-claim-audit = api_claim_audit.cli:main
@@ -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()