entry-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.
- entry_claim_audit-0.1.0/LICENSE +21 -0
- entry_claim_audit-0.1.0/PKG-INFO +82 -0
- entry_claim_audit-0.1.0/README.md +72 -0
- entry_claim_audit-0.1.0/pyproject.toml +18 -0
- entry_claim_audit-0.1.0/setup.cfg +4 -0
- entry_claim_audit-0.1.0/src/entry_claim_audit/__init__.py +2 -0
- entry_claim_audit-0.1.0/src/entry_claim_audit/__main__.py +3 -0
- entry_claim_audit-0.1.0/src/entry_claim_audit/audit.py +155 -0
- entry_claim_audit-0.1.0/src/entry_claim_audit/cli.py +38 -0
- entry_claim_audit-0.1.0/src/entry_claim_audit.egg-info/PKG-INFO +82 -0
- entry_claim_audit-0.1.0/src/entry_claim_audit.egg-info/SOURCES.txt +13 -0
- entry_claim_audit-0.1.0/src/entry_claim_audit.egg-info/dependency_links.txt +1 -0
- entry_claim_audit-0.1.0/src/entry_claim_audit.egg-info/entry_points.txt +2 -0
- entry_claim_audit-0.1.0/src/entry_claim_audit.egg-info/top_level.txt +1 -0
- entry_claim_audit-0.1.0/tests/test_audit.py +329 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 entry-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,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: entry-claim-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline static audits of Python entry-point claims
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# entry-claim-audit
|
|
12
|
+
|
|
13
|
+
Offline static CLI that checks Python packaging entry-point claims against source on disk. It checks `[project.scripts]` and `[project.gui-scripts]` targets for a module, symbol, and callability, and checks documented command names. It never executes project code, accesses the network, or reads .env contents. Reports contain rule names, paths, script names and targets, not unrelated file contents.
|
|
14
|
+
|
|
15
|
+
Requires Python 3.9 or later. Runtime dependencies: none.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pip install .
|
|
21
|
+
entry-claim-audit .
|
|
22
|
+
entry-claim-audit /path/to/project --json
|
|
23
|
+
entry-claim-audit . --fail-on HIGH
|
|
24
|
+
entry-claim-audit . --ignore script-undocumented,script-target-not-callable
|
|
25
|
+
entry-claim-audit . --verbose
|
|
26
|
+
entry-claim-audit --version
|
|
27
|
+
python -m entry_claim_audit .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The path defaults to `.`. Exit codes: 0 PASS, 2 FAIL, 1 usage/input error. Default failure threshold is MEDIUM; `--fail-on HIGH` still prints MEDIUM findings but does not fail for them. `--ignore RULE,...` drops those rules entirely, for example when scripts are documented elsewhere or a data-object target is intentional. `--verbose` adds verified scripts to human output.
|
|
31
|
+
|
|
32
|
+
Human output has one `SEVERITY rule file:line message` line per finding followed by PASS/FAIL. Metadata findings use `pyproject.toml:0`. JSON contains `status`, `findings` (severity, rule, file, line, message) sorted by rule/file/line, and `verified_scripts` (script, table, target).
|
|
33
|
+
|
|
34
|
+
For example, `mytool = "pkg.cli:main"` passes if `pkg/cli.py` defines `def main()` and README mentions `mytool`. Renaming that file without updating the target yields `script-target-module-missing` and exit 2.
|
|
35
|
+
|
|
36
|
+
## Full rule table
|
|
37
|
+
|
|
38
|
+
| Rule | Severity | Trigger and report |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `script-target-malformed` | HIGH | Target has no colon, an empty module/object, internal whitespace, or invalid dotted identifiers. Surrounding whitespace is stripped. Reports script, table and raw target; no further target checks for that script. |
|
|
41
|
+
| `script-target-module-missing` | HIGH | Target module does not resolve to a source file in the supported layouts. Reports script, table and module. |
|
|
42
|
+
| `script-target-symbol-missing` | HIGH | Resolved module lacks the first object segment. Reports script, table, module and missing symbol. |
|
|
43
|
+
| `script-target-attr-missing` | HIGH | First object is a class but the next segment is absent from its directly defined methods. Reports script, table and missing method. Chains beyond that method, or attributes of non-class objects, are unchecked. |
|
|
44
|
+
| `script-target-not-callable` | MEDIUM | First object is a plain data assignment. Functions, async functions, classes, imports, lambdas and call-result assignments pass. Only the first object segment is checked. |
|
|
45
|
+
| `documented-command-missing` | HIGH | First token of a fenced or inline code command equals the project name (case/dash/underscore normalized), but is not an exactly declared script name. Reports doc file:line, token and declared names. Arbitrary other commands are ignored; skipped without a project name. |
|
|
46
|
+
| `script-name-duplicate` | MEDIUM | Same name is declared in both scripts and gui-scripts. |
|
|
47
|
+
| `script-undocumented` | MEDIUM | Script name never occurs as a whole word anywhere in scanned docs, including prose. Skipped when there is neither README.md nor docs markdown. |
|
|
48
|
+
|
|
49
|
+
Verified targets are not findings. Both script tables receive the same checks. No pyproject.toml, no project table, or no script tables yields PASS with zero findings.
|
|
50
|
+
|
|
51
|
+
## Static interpretation
|
|
52
|
+
|
|
53
|
+
Modules resolve in this order: `src/pkg/mod.py`, `src/pkg/mod/__init__.py`, `pkg/mod.py`, `pkg/mod/__init__.py`. Single-segment modules and package `__init__.py` targets are supported. Namespace parent directories are allowed. The directories tests, .venv, __pycache__, dist, .git and .egg-info are not module evidence.
|
|
54
|
+
|
|
55
|
+
Only the resolved module is parsed. Top-level functions, async functions, classes, Name assignment targets (including annotations), and imported names count as symbols. Re-exports count without importing or following them. Direct class methods include `__init__`. Assignments to lambda/call expressions count as callable or unknown; all other assigned values, including names and attributes, count as data. Definitions nested inside functions or conditional blocks are not collected.
|
|
56
|
+
|
|
57
|
+
Docs scanned: top-level `*.md` including README.md, and recursive `docs/**/*.md`. Command claims come only from fenced code and inline code spans. Undocumented checks use full text. TOML uses stdlib tomllib on Python 3.11+, with a minimal quoted key/value regex fallback for script tables on older Python (and a simple project-name lookup).
|
|
58
|
+
|
|
59
|
+
## Differentiation
|
|
60
|
+
|
|
61
|
+
- **readme-claim-audit** audits pip install names against pyproject and docs flags, links, versions and make targets. This checks whether installed commands resolve to modules, symbols and callables; the rules do not overlap.
|
|
62
|
+
- **api-claim-audit** audits symbols promised in docs code examples. This checks symbols promised in packaging metadata, using similar AST machinery with a different promise source; the rules do not overlap.
|
|
63
|
+
- **dep-claim-audit** audits dependency declarations against imports. This audits entry-point declarations against source.
|
|
64
|
+
- **pip-check, pipdeptree and importlib.metadata checks** inspect an installed environment at runtime. This tool is static and offline and works on a repository that has never been installed.
|
|
65
|
+
- **twine check** validates package metadata for upload; it does not resolve entry-point targets to source symbols.
|
|
66
|
+
|
|
67
|
+
## Honest limitations
|
|
68
|
+
|
|
69
|
+
Static scanning misses dynamic symbols created by importlib, module `__getattr__` hooks, exec or eval. An existing symbol still counts even if a failing top-level import prevents actual import. Imported/re-exported names and call-result assignments have unknown runtime callability and pass. Decorators and runtime behavior are not evaluated.
|
|
70
|
+
|
|
71
|
+
Attribute chains resolve only one class-method step. Further attributes and non-class attributes remain unchecked. setup.cfg and setup.py entry_points are not parsed. The documented-command rule catches only the project's own name, not arbitrary mistyped command names. It examines the first whitespace-separated code token, so shell prompts or wrappers can hide commands. script-undocumented is heuristic: a script documented only outside README/top-level markdown/docs is flagged; use `--ignore script-undocumented`.
|
|
72
|
+
|
|
73
|
+
The fallback parser supports simple single-line quoted declarations, not full TOML syntax. Invalid TOML (with tomllib), unreadable files and invalid Python syntax produce input-error exit 1. The tool establishes static evidence, not that a built distribution includes the module or that a command will execute successfully.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
79
|
+
python3 -m build
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
MIT licensed.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# entry-claim-audit
|
|
2
|
+
|
|
3
|
+
Offline static CLI that checks Python packaging entry-point claims against source on disk. It checks `[project.scripts]` and `[project.gui-scripts]` targets for a module, symbol, and callability, and checks documented command names. It never executes project code, accesses the network, or reads .env contents. Reports contain rule names, paths, script names and targets, not unrelated file contents.
|
|
4
|
+
|
|
5
|
+
Requires Python 3.9 or later. Runtime dependencies: none.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install .
|
|
11
|
+
entry-claim-audit .
|
|
12
|
+
entry-claim-audit /path/to/project --json
|
|
13
|
+
entry-claim-audit . --fail-on HIGH
|
|
14
|
+
entry-claim-audit . --ignore script-undocumented,script-target-not-callable
|
|
15
|
+
entry-claim-audit . --verbose
|
|
16
|
+
entry-claim-audit --version
|
|
17
|
+
python -m entry_claim_audit .
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The path defaults to `.`. Exit codes: 0 PASS, 2 FAIL, 1 usage/input error. Default failure threshold is MEDIUM; `--fail-on HIGH` still prints MEDIUM findings but does not fail for them. `--ignore RULE,...` drops those rules entirely, for example when scripts are documented elsewhere or a data-object target is intentional. `--verbose` adds verified scripts to human output.
|
|
21
|
+
|
|
22
|
+
Human output has one `SEVERITY rule file:line message` line per finding followed by PASS/FAIL. Metadata findings use `pyproject.toml:0`. JSON contains `status`, `findings` (severity, rule, file, line, message) sorted by rule/file/line, and `verified_scripts` (script, table, target).
|
|
23
|
+
|
|
24
|
+
For example, `mytool = "pkg.cli:main"` passes if `pkg/cli.py` defines `def main()` and README mentions `mytool`. Renaming that file without updating the target yields `script-target-module-missing` and exit 2.
|
|
25
|
+
|
|
26
|
+
## Full rule table
|
|
27
|
+
|
|
28
|
+
| Rule | Severity | Trigger and report |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `script-target-malformed` | HIGH | Target has no colon, an empty module/object, internal whitespace, or invalid dotted identifiers. Surrounding whitespace is stripped. Reports script, table and raw target; no further target checks for that script. |
|
|
31
|
+
| `script-target-module-missing` | HIGH | Target module does not resolve to a source file in the supported layouts. Reports script, table and module. |
|
|
32
|
+
| `script-target-symbol-missing` | HIGH | Resolved module lacks the first object segment. Reports script, table, module and missing symbol. |
|
|
33
|
+
| `script-target-attr-missing` | HIGH | First object is a class but the next segment is absent from its directly defined methods. Reports script, table and missing method. Chains beyond that method, or attributes of non-class objects, are unchecked. |
|
|
34
|
+
| `script-target-not-callable` | MEDIUM | First object is a plain data assignment. Functions, async functions, classes, imports, lambdas and call-result assignments pass. Only the first object segment is checked. |
|
|
35
|
+
| `documented-command-missing` | HIGH | First token of a fenced or inline code command equals the project name (case/dash/underscore normalized), but is not an exactly declared script name. Reports doc file:line, token and declared names. Arbitrary other commands are ignored; skipped without a project name. |
|
|
36
|
+
| `script-name-duplicate` | MEDIUM | Same name is declared in both scripts and gui-scripts. |
|
|
37
|
+
| `script-undocumented` | MEDIUM | Script name never occurs as a whole word anywhere in scanned docs, including prose. Skipped when there is neither README.md nor docs markdown. |
|
|
38
|
+
|
|
39
|
+
Verified targets are not findings. Both script tables receive the same checks. No pyproject.toml, no project table, or no script tables yields PASS with zero findings.
|
|
40
|
+
|
|
41
|
+
## Static interpretation
|
|
42
|
+
|
|
43
|
+
Modules resolve in this order: `src/pkg/mod.py`, `src/pkg/mod/__init__.py`, `pkg/mod.py`, `pkg/mod/__init__.py`. Single-segment modules and package `__init__.py` targets are supported. Namespace parent directories are allowed. The directories tests, .venv, __pycache__, dist, .git and .egg-info are not module evidence.
|
|
44
|
+
|
|
45
|
+
Only the resolved module is parsed. Top-level functions, async functions, classes, Name assignment targets (including annotations), and imported names count as symbols. Re-exports count without importing or following them. Direct class methods include `__init__`. Assignments to lambda/call expressions count as callable or unknown; all other assigned values, including names and attributes, count as data. Definitions nested inside functions or conditional blocks are not collected.
|
|
46
|
+
|
|
47
|
+
Docs scanned: top-level `*.md` including README.md, and recursive `docs/**/*.md`. Command claims come only from fenced code and inline code spans. Undocumented checks use full text. TOML uses stdlib tomllib on Python 3.11+, with a minimal quoted key/value regex fallback for script tables on older Python (and a simple project-name lookup).
|
|
48
|
+
|
|
49
|
+
## Differentiation
|
|
50
|
+
|
|
51
|
+
- **readme-claim-audit** audits pip install names against pyproject and docs flags, links, versions and make targets. This checks whether installed commands resolve to modules, symbols and callables; the rules do not overlap.
|
|
52
|
+
- **api-claim-audit** audits symbols promised in docs code examples. This checks symbols promised in packaging metadata, using similar AST machinery with a different promise source; the rules do not overlap.
|
|
53
|
+
- **dep-claim-audit** audits dependency declarations against imports. This audits entry-point declarations against source.
|
|
54
|
+
- **pip-check, pipdeptree and importlib.metadata checks** inspect an installed environment at runtime. This tool is static and offline and works on a repository that has never been installed.
|
|
55
|
+
- **twine check** validates package metadata for upload; it does not resolve entry-point targets to source symbols.
|
|
56
|
+
|
|
57
|
+
## Honest limitations
|
|
58
|
+
|
|
59
|
+
Static scanning misses dynamic symbols created by importlib, module `__getattr__` hooks, exec or eval. An existing symbol still counts even if a failing top-level import prevents actual import. Imported/re-exported names and call-result assignments have unknown runtime callability and pass. Decorators and runtime behavior are not evaluated.
|
|
60
|
+
|
|
61
|
+
Attribute chains resolve only one class-method step. Further attributes and non-class attributes remain unchecked. setup.cfg and setup.py entry_points are not parsed. The documented-command rule catches only the project's own name, not arbitrary mistyped command names. It examines the first whitespace-separated code token, so shell prompts or wrappers can hide commands. script-undocumented is heuristic: a script documented only outside README/top-level markdown/docs is flagged; use `--ignore script-undocumented`.
|
|
62
|
+
|
|
63
|
+
The fallback parser supports simple single-line quoted declarations, not full TOML syntax. Invalid TOML (with tomllib), unreadable files and invalid Python syntax produce input-error exit 1. The tool establishes static evidence, not that a built distribution includes the module or that a command will execute successfully.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
69
|
+
python3 -m build
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
MIT licensed.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "entry-claim-audit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Offline static audits of Python entry-point claims"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
entry-claim-audit = "entry_claim_audit.cli:main"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["src"]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Offline entry-point auditing; project modules are parsed, never imported."""
|
|
2
|
+
import ast
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import tomllib
|
|
8
|
+
except ImportError: # Python 3.9–3.10
|
|
9
|
+
tomllib = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fallback_metadata(text):
|
|
13
|
+
"""Read only simple quoted key/value pairs in the two script tables."""
|
|
14
|
+
project = {}
|
|
15
|
+
table = None
|
|
16
|
+
for line in text.splitlines():
|
|
17
|
+
header = re.fullmatch(r'\s*\[([^]]+)\]\s*(?:#.*)?', line)
|
|
18
|
+
if header:
|
|
19
|
+
name = header.group(1)
|
|
20
|
+
table = project.setdefault(name.split('.')[1], {}) if name in ('project.scripts', 'project.gui-scripts') else None
|
|
21
|
+
elif table is not None:
|
|
22
|
+
pair = re.fullmatch(r'''\s*(?:"([^"\n]+)"|'([^'\n]+)'|([\w-]+))\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')\s*(?:#.*)?''', line)
|
|
23
|
+
if pair:
|
|
24
|
+
table[next(v for v in pair.groups()[:3] if v is not None)] = next(v for v in pair.groups()[3:] if v is not None)
|
|
25
|
+
# Name is needed solely for documented-command matching.
|
|
26
|
+
section = re.search(r'(?ms)^\s*\[project\]\s*\n(.*?)(?=^\s*\[|\Z)', text)
|
|
27
|
+
if section:
|
|
28
|
+
name = re.search(r'''(?m)^\s*name\s*=\s*["']([^"']+)["']''', section.group(1))
|
|
29
|
+
if name:
|
|
30
|
+
project['name'] = name.group(1)
|
|
31
|
+
return {'project': project}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_module(root, module):
|
|
35
|
+
parts = module.split('.')
|
|
36
|
+
if any(p in {'tests', '.venv', '__pycache__', 'dist', '.git', '.egg-info'} or p.endswith('.egg-info') for p in parts):
|
|
37
|
+
return None
|
|
38
|
+
relative = Path(*parts)
|
|
39
|
+
for base in (root / 'src', root):
|
|
40
|
+
for candidate in (base / relative.with_suffix('.py'), base / relative / '__init__.py'):
|
|
41
|
+
if candidate.is_file() and candidate.resolve().is_relative_to(root.resolve()):
|
|
42
|
+
return candidate
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def symbols(path):
|
|
47
|
+
tree = ast.parse(path.read_text(encoding='utf-8-sig'), filename=str(path))
|
|
48
|
+
result = {}
|
|
49
|
+
for node in tree.body:
|
|
50
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
51
|
+
methods = {n.name for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))} if isinstance(node, ast.ClassDef) else None
|
|
52
|
+
result[node.name] = (True, methods)
|
|
53
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
54
|
+
for alias in node.names:
|
|
55
|
+
result[alias.asname or alias.name.split('.')[0]] = (True, None)
|
|
56
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
57
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
58
|
+
for target in targets:
|
|
59
|
+
if isinstance(target, ast.Name):
|
|
60
|
+
result[target.id] = (isinstance(node.value, (ast.Lambda, ast.Call)), None)
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def code_lines(text):
|
|
65
|
+
"""Yield fenced and inline code with their original document line numbers."""
|
|
66
|
+
fence = None
|
|
67
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
68
|
+
marker = re.match(r'^\s*(`{3,}|~{3,})', line)
|
|
69
|
+
if marker:
|
|
70
|
+
token = marker.group(1)
|
|
71
|
+
if fence is None:
|
|
72
|
+
fence = token
|
|
73
|
+
elif token[0] == fence[0] and len(token) >= len(fence):
|
|
74
|
+
fence = None
|
|
75
|
+
continue
|
|
76
|
+
if fence:
|
|
77
|
+
yield number, line.strip()
|
|
78
|
+
else:
|
|
79
|
+
for match in re.finditer(r'(`+)(.+?)\1', line):
|
|
80
|
+
yield number, match.group(2).strip()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def audit(path='.', fail_on='MEDIUM', ignore=()):
|
|
84
|
+
root = Path(path)
|
|
85
|
+
if not root.is_dir():
|
|
86
|
+
raise ValueError('project path must be a directory')
|
|
87
|
+
metadata = root / 'pyproject.toml'
|
|
88
|
+
data = {}
|
|
89
|
+
if metadata.is_file():
|
|
90
|
+
raw = metadata.read_text(encoding='utf-8')
|
|
91
|
+
data = tomllib.loads(raw) if tomllib else fallback_metadata(raw)
|
|
92
|
+
project = data.get('project', {})
|
|
93
|
+
if not isinstance(project, dict):
|
|
94
|
+
raise ValueError('project must be a table')
|
|
95
|
+
if not any(table in project for table in ('scripts', 'gui-scripts')):
|
|
96
|
+
return dict(status='PASS', findings=[], verified_scripts=[])
|
|
97
|
+
findings, verified = [], []
|
|
98
|
+
ignored = set(ignore)
|
|
99
|
+
def add(rule, severity, file, line, message):
|
|
100
|
+
if rule not in ignored:
|
|
101
|
+
findings.append(dict(rule=rule, severity=severity, file=file, line=line, message=message))
|
|
102
|
+
scripts = []
|
|
103
|
+
for table in ('scripts', 'gui-scripts'):
|
|
104
|
+
entries = project.get(table, {})
|
|
105
|
+
if not isinstance(entries, dict):
|
|
106
|
+
raise ValueError('entry-point declarations must be tables')
|
|
107
|
+
scripts.extend((name, table, target) for name, target in entries.items())
|
|
108
|
+
declared = {name for name, _, _ in scripts}
|
|
109
|
+
for name in sorted(set(project.get('scripts', {})) & set(project.get('gui-scripts', {}))):
|
|
110
|
+
add('script-name-duplicate', 'MEDIUM', 'pyproject.toml', 0, f'{name}: declared in scripts and gui-scripts')
|
|
111
|
+
for name, table, raw in scripts:
|
|
112
|
+
before = len(findings)
|
|
113
|
+
prefix = f'{name} ({table})'
|
|
114
|
+
target = raw.strip() if isinstance(raw, str) else ''
|
|
115
|
+
pattern = r'[^\W\d]\w*(?:\.[^\W\d]\w*)*:[^\W\d]\w*(?:\.[^\W\d]\w*)*'
|
|
116
|
+
if not re.fullmatch(pattern, target):
|
|
117
|
+
add('script-target-malformed', 'HIGH', 'pyproject.toml', 0, f'{prefix}: malformed target {raw!r}')
|
|
118
|
+
continue
|
|
119
|
+
module, obj = target.split(':')
|
|
120
|
+
source = resolve_module(root, module)
|
|
121
|
+
if source is None:
|
|
122
|
+
add('script-target-module-missing', 'HIGH', 'pyproject.toml', 0, f'{prefix}: module {module} missing')
|
|
123
|
+
continue
|
|
124
|
+
names = symbols(source)
|
|
125
|
+
segments = obj.split('.')
|
|
126
|
+
symbol = segments[0]
|
|
127
|
+
if symbol not in names:
|
|
128
|
+
add('script-target-symbol-missing', 'HIGH', 'pyproject.toml', 0, f'{prefix}: {module} symbol {symbol} missing')
|
|
129
|
+
continue
|
|
130
|
+
callable_, methods = names[symbol]
|
|
131
|
+
if methods is not None and len(segments) > 1 and segments[1] not in methods:
|
|
132
|
+
add('script-target-attr-missing', 'HIGH', 'pyproject.toml', 0, f'{prefix}: {module}:{symbol} method {segments[1]} missing')
|
|
133
|
+
if not callable_:
|
|
134
|
+
add('script-target-not-callable', 'MEDIUM', 'pyproject.toml', 0, f'{prefix}: {module}:{symbol} is a data assignment')
|
|
135
|
+
if len(findings) == before:
|
|
136
|
+
verified.append(dict(script=name, table=table, target=raw))
|
|
137
|
+
docs = set(root.glob('*.md'))
|
|
138
|
+
docs.update((root / 'docs').rglob('*.md'))
|
|
139
|
+
texts = [(p, p.read_text(encoding='utf-8')) for p in sorted(docs) if p.is_file() and p.resolve().is_relative_to(root.resolve())]
|
|
140
|
+
normalize = lambda value: value.lower().replace('_', '-')
|
|
141
|
+
project_name = project.get('name')
|
|
142
|
+
for doc, text in texts:
|
|
143
|
+
if project_name:
|
|
144
|
+
for line, command in code_lines(text):
|
|
145
|
+
tokens = command.split()
|
|
146
|
+
if tokens and normalize(tokens[0]) == normalize(project_name) and tokens[0] not in declared:
|
|
147
|
+
add('documented-command-missing', 'HIGH', str(doc.relative_to(root)), line, f'{tokens[0]} is not declared; declared scripts: {", ".join(sorted(declared)) or "(none)"}')
|
|
148
|
+
has_docs = (root / 'README.md').is_file() or any(p.is_relative_to(root / 'docs') for p, _ in texts)
|
|
149
|
+
if has_docs:
|
|
150
|
+
for name in sorted(declared):
|
|
151
|
+
if not any(re.search(r'(?<!\w)' + re.escape(name) + r'(?!\w)', text) for _, text in texts):
|
|
152
|
+
add('script-undocumented', 'MEDIUM', 'pyproject.toml', 0, f'{name}: never mentioned in scanned docs')
|
|
153
|
+
findings.sort(key=lambda f: (f['rule'], f['file'], f['line'], f['message']))
|
|
154
|
+
failed = any(f['severity'] == 'HIGH' or fail_on == 'MEDIUM' for f in findings)
|
|
155
|
+
return dict(status='FAIL' if failed else 'PASS', findings=findings, verified_scripts=sorted(verified, key=lambda s: (s['script'], s['table'], s['target'])))
|
|
@@ -0,0 +1,38 @@
|
|
|
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, f'{self.prog}: error: {message}\n')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None):
|
|
16
|
+
parser = Parser(prog='entry-claim-audit')
|
|
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, args.ignore.split(','))
|
|
26
|
+
except (OSError, ValueError, SyntaxError) as exc:
|
|
27
|
+
# Never echo source fragments or unrelated file contents.
|
|
28
|
+
parser.exit(1, f'entry-claim-audit: unable to read or parse project ({type(exc).__name__})\n')
|
|
29
|
+
if args.json:
|
|
30
|
+
print(json.dumps(result))
|
|
31
|
+
else:
|
|
32
|
+
for f in result['findings']:
|
|
33
|
+
print(f"{f['severity']} {f['rule']} {f['file']}:{f['line']} {f['message']}")
|
|
34
|
+
if args.verbose:
|
|
35
|
+
for script in result['verified_scripts']:
|
|
36
|
+
print(f"verified {script['script']} ({script['table']}): {script['target']}")
|
|
37
|
+
print(f"{result['status']}: {len(result['findings'])} finding(s)")
|
|
38
|
+
return 2 if result['status'] == 'FAIL' else 0
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: entry-claim-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline static audits of Python entry-point claims
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Dynamic: license-file
|
|
10
|
+
|
|
11
|
+
# entry-claim-audit
|
|
12
|
+
|
|
13
|
+
Offline static CLI that checks Python packaging entry-point claims against source on disk. It checks `[project.scripts]` and `[project.gui-scripts]` targets for a module, symbol, and callability, and checks documented command names. It never executes project code, accesses the network, or reads .env contents. Reports contain rule names, paths, script names and targets, not unrelated file contents.
|
|
14
|
+
|
|
15
|
+
Requires Python 3.9 or later. Runtime dependencies: none.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pip install .
|
|
21
|
+
entry-claim-audit .
|
|
22
|
+
entry-claim-audit /path/to/project --json
|
|
23
|
+
entry-claim-audit . --fail-on HIGH
|
|
24
|
+
entry-claim-audit . --ignore script-undocumented,script-target-not-callable
|
|
25
|
+
entry-claim-audit . --verbose
|
|
26
|
+
entry-claim-audit --version
|
|
27
|
+
python -m entry_claim_audit .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The path defaults to `.`. Exit codes: 0 PASS, 2 FAIL, 1 usage/input error. Default failure threshold is MEDIUM; `--fail-on HIGH` still prints MEDIUM findings but does not fail for them. `--ignore RULE,...` drops those rules entirely, for example when scripts are documented elsewhere or a data-object target is intentional. `--verbose` adds verified scripts to human output.
|
|
31
|
+
|
|
32
|
+
Human output has one `SEVERITY rule file:line message` line per finding followed by PASS/FAIL. Metadata findings use `pyproject.toml:0`. JSON contains `status`, `findings` (severity, rule, file, line, message) sorted by rule/file/line, and `verified_scripts` (script, table, target).
|
|
33
|
+
|
|
34
|
+
For example, `mytool = "pkg.cli:main"` passes if `pkg/cli.py` defines `def main()` and README mentions `mytool`. Renaming that file without updating the target yields `script-target-module-missing` and exit 2.
|
|
35
|
+
|
|
36
|
+
## Full rule table
|
|
37
|
+
|
|
38
|
+
| Rule | Severity | Trigger and report |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `script-target-malformed` | HIGH | Target has no colon, an empty module/object, internal whitespace, or invalid dotted identifiers. Surrounding whitespace is stripped. Reports script, table and raw target; no further target checks for that script. |
|
|
41
|
+
| `script-target-module-missing` | HIGH | Target module does not resolve to a source file in the supported layouts. Reports script, table and module. |
|
|
42
|
+
| `script-target-symbol-missing` | HIGH | Resolved module lacks the first object segment. Reports script, table, module and missing symbol. |
|
|
43
|
+
| `script-target-attr-missing` | HIGH | First object is a class but the next segment is absent from its directly defined methods. Reports script, table and missing method. Chains beyond that method, or attributes of non-class objects, are unchecked. |
|
|
44
|
+
| `script-target-not-callable` | MEDIUM | First object is a plain data assignment. Functions, async functions, classes, imports, lambdas and call-result assignments pass. Only the first object segment is checked. |
|
|
45
|
+
| `documented-command-missing` | HIGH | First token of a fenced or inline code command equals the project name (case/dash/underscore normalized), but is not an exactly declared script name. Reports doc file:line, token and declared names. Arbitrary other commands are ignored; skipped without a project name. |
|
|
46
|
+
| `script-name-duplicate` | MEDIUM | Same name is declared in both scripts and gui-scripts. |
|
|
47
|
+
| `script-undocumented` | MEDIUM | Script name never occurs as a whole word anywhere in scanned docs, including prose. Skipped when there is neither README.md nor docs markdown. |
|
|
48
|
+
|
|
49
|
+
Verified targets are not findings. Both script tables receive the same checks. No pyproject.toml, no project table, or no script tables yields PASS with zero findings.
|
|
50
|
+
|
|
51
|
+
## Static interpretation
|
|
52
|
+
|
|
53
|
+
Modules resolve in this order: `src/pkg/mod.py`, `src/pkg/mod/__init__.py`, `pkg/mod.py`, `pkg/mod/__init__.py`. Single-segment modules and package `__init__.py` targets are supported. Namespace parent directories are allowed. The directories tests, .venv, __pycache__, dist, .git and .egg-info are not module evidence.
|
|
54
|
+
|
|
55
|
+
Only the resolved module is parsed. Top-level functions, async functions, classes, Name assignment targets (including annotations), and imported names count as symbols. Re-exports count without importing or following them. Direct class methods include `__init__`. Assignments to lambda/call expressions count as callable or unknown; all other assigned values, including names and attributes, count as data. Definitions nested inside functions or conditional blocks are not collected.
|
|
56
|
+
|
|
57
|
+
Docs scanned: top-level `*.md` including README.md, and recursive `docs/**/*.md`. Command claims come only from fenced code and inline code spans. Undocumented checks use full text. TOML uses stdlib tomllib on Python 3.11+, with a minimal quoted key/value regex fallback for script tables on older Python (and a simple project-name lookup).
|
|
58
|
+
|
|
59
|
+
## Differentiation
|
|
60
|
+
|
|
61
|
+
- **readme-claim-audit** audits pip install names against pyproject and docs flags, links, versions and make targets. This checks whether installed commands resolve to modules, symbols and callables; the rules do not overlap.
|
|
62
|
+
- **api-claim-audit** audits symbols promised in docs code examples. This checks symbols promised in packaging metadata, using similar AST machinery with a different promise source; the rules do not overlap.
|
|
63
|
+
- **dep-claim-audit** audits dependency declarations against imports. This audits entry-point declarations against source.
|
|
64
|
+
- **pip-check, pipdeptree and importlib.metadata checks** inspect an installed environment at runtime. This tool is static and offline and works on a repository that has never been installed.
|
|
65
|
+
- **twine check** validates package metadata for upload; it does not resolve entry-point targets to source symbols.
|
|
66
|
+
|
|
67
|
+
## Honest limitations
|
|
68
|
+
|
|
69
|
+
Static scanning misses dynamic symbols created by importlib, module `__getattr__` hooks, exec or eval. An existing symbol still counts even if a failing top-level import prevents actual import. Imported/re-exported names and call-result assignments have unknown runtime callability and pass. Decorators and runtime behavior are not evaluated.
|
|
70
|
+
|
|
71
|
+
Attribute chains resolve only one class-method step. Further attributes and non-class attributes remain unchecked. setup.cfg and setup.py entry_points are not parsed. The documented-command rule catches only the project's own name, not arbitrary mistyped command names. It examines the first whitespace-separated code token, so shell prompts or wrappers can hide commands. script-undocumented is heuristic: a script documented only outside README/top-level markdown/docs is flagged; use `--ignore script-undocumented`.
|
|
72
|
+
|
|
73
|
+
The fallback parser supports simple single-line quoted declarations, not full TOML syntax. Invalid TOML (with tomllib), unreadable files and invalid Python syntax produce input-error exit 1. The tool establishes static evidence, not that a built distribution includes the module or that a command will execute successfully.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
79
|
+
python3 -m build
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
MIT licensed.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/entry_claim_audit/__init__.py
|
|
5
|
+
src/entry_claim_audit/__main__.py
|
|
6
|
+
src/entry_claim_audit/audit.py
|
|
7
|
+
src/entry_claim_audit/cli.py
|
|
8
|
+
src/entry_claim_audit.egg-info/PKG-INFO
|
|
9
|
+
src/entry_claim_audit.egg-info/SOURCES.txt
|
|
10
|
+
src/entry_claim_audit.egg-info/dependency_links.txt
|
|
11
|
+
src/entry_claim_audit.egg-info/entry_points.txt
|
|
12
|
+
src/entry_claim_audit.egg-info/top_level.txt
|
|
13
|
+
tests/test_audit.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
entry_claim_audit
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
from entry_claim_audit.audit import audit, fallback_metadata
|
|
9
|
+
from entry_claim_audit.cli import main
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuditTests(unittest.TestCase):
|
|
13
|
+
def setUp(self):
|
|
14
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
15
|
+
self.addCleanup(self.temp.cleanup)
|
|
16
|
+
self.root = Path(self.temp.name)
|
|
17
|
+
self.write('pyproject.toml', '[project]\nname="my-tool"\n[project.scripts]\nmytool="pkg.cli:main"\n')
|
|
18
|
+
self.write('pkg/cli.py', 'def main(): pass\n')
|
|
19
|
+
self.write('README.md', '`mytool --help`\n')
|
|
20
|
+
|
|
21
|
+
def write(self, name, text):
|
|
22
|
+
path = self.root / name
|
|
23
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
path.write_text(text, encoding='utf-8')
|
|
25
|
+
|
|
26
|
+
def target(self, target, table='scripts', name='mytool'):
|
|
27
|
+
self.write('pyproject.toml', '[project]\nname="my-tool"\n[project.' + table + ']\n' + name + '=' + json.dumps(target) + '\n')
|
|
28
|
+
|
|
29
|
+
def check(self, rule=None, severity=None, **kwargs):
|
|
30
|
+
result = audit(self.root, **kwargs)
|
|
31
|
+
self.assertEqual([f['rule'] for f in result['findings']], [rule] if rule else [])
|
|
32
|
+
if severity:
|
|
33
|
+
self.assertEqual(result['findings'][0]['severity'], severity)
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
def cli(self, *args):
|
|
37
|
+
output = io.StringIO()
|
|
38
|
+
with contextlib.redirect_stdout(output):
|
|
39
|
+
code = main([str(self.root), *args])
|
|
40
|
+
return code, output.getvalue()
|
|
41
|
+
|
|
42
|
+
def test_good(self):
|
|
43
|
+
self.assertEqual(self.check()['status'], 'PASS')
|
|
44
|
+
|
|
45
|
+
def test_good_cli(self):
|
|
46
|
+
self.assertEqual(self.cli()[0], 0)
|
|
47
|
+
|
|
48
|
+
def test_verified(self):
|
|
49
|
+
self.assertEqual(self.check()['verified_scripts'], [dict(script='mytool', table='scripts', target='pkg.cli:main')])
|
|
50
|
+
|
|
51
|
+
def test_missing_module(self):
|
|
52
|
+
self.target('pkg.nosuch:main')
|
|
53
|
+
self.check('script-target-module-missing', 'HIGH')
|
|
54
|
+
self.assertEqual(self.cli()[0], 2)
|
|
55
|
+
|
|
56
|
+
def test_missing_symbol(self):
|
|
57
|
+
self.write('pkg/cli.py', 'other = 1')
|
|
58
|
+
self.check('script-target-symbol-missing', 'HIGH')
|
|
59
|
+
|
|
60
|
+
def test_constant(self):
|
|
61
|
+
self.write('pkg/cli.py', 'main = 42')
|
|
62
|
+
self.assertEqual(self.check('script-target-not-callable', 'MEDIUM')['status'], 'FAIL')
|
|
63
|
+
|
|
64
|
+
def test_high_threshold(self):
|
|
65
|
+
self.write('pkg/cli.py', 'main = 42')
|
|
66
|
+
self.assertEqual(self.check('script-target-not-callable', fail_on='HIGH')['status'], 'PASS')
|
|
67
|
+
self.assertEqual(self.cli('--fail-on', 'HIGH')[0], 0)
|
|
68
|
+
|
|
69
|
+
def test_no_colon(self):
|
|
70
|
+
self.target('pkg.cli.main')
|
|
71
|
+
self.check('script-target-malformed', 'HIGH')
|
|
72
|
+
|
|
73
|
+
def test_empty_module(self):
|
|
74
|
+
self.target(':main')
|
|
75
|
+
self.check('script-target-malformed')
|
|
76
|
+
|
|
77
|
+
def test_empty_object(self):
|
|
78
|
+
self.target('pkg.cli:')
|
|
79
|
+
self.check('script-target-malformed')
|
|
80
|
+
|
|
81
|
+
def test_internal_space(self):
|
|
82
|
+
self.target('pkg.cli:ma in')
|
|
83
|
+
self.check('script-target-malformed')
|
|
84
|
+
|
|
85
|
+
def test_surrounding_space(self):
|
|
86
|
+
self.target(' pkg.cli:main ')
|
|
87
|
+
self.check()
|
|
88
|
+
|
|
89
|
+
def test_double_colon(self):
|
|
90
|
+
self.target('pkg.cli::main')
|
|
91
|
+
self.check('script-target-malformed')
|
|
92
|
+
|
|
93
|
+
def test_invalid_dotted(self):
|
|
94
|
+
self.target('pkg..cli:main')
|
|
95
|
+
self.check('script-target-malformed')
|
|
96
|
+
|
|
97
|
+
def test_attr_missing(self):
|
|
98
|
+
self.write('pkg/cli.py', 'class Runner: pass')
|
|
99
|
+
self.target('pkg.cli:Runner.run')
|
|
100
|
+
self.check('script-target-attr-missing', 'HIGH')
|
|
101
|
+
|
|
102
|
+
def test_attr_present(self):
|
|
103
|
+
self.write('pkg/cli.py', 'class Runner:\n def run(self): pass')
|
|
104
|
+
self.target('pkg.cli:Runner.run')
|
|
105
|
+
self.check()
|
|
106
|
+
|
|
107
|
+
def test_deep_attr(self):
|
|
108
|
+
self.write('pkg/cli.py', 'class Runner:\n def run(self): pass')
|
|
109
|
+
self.target('pkg.cli:Runner.run.unknown')
|
|
110
|
+
self.check()
|
|
111
|
+
|
|
112
|
+
def test_function_attr(self):
|
|
113
|
+
self.target('pkg.cli:main.unknown')
|
|
114
|
+
self.check()
|
|
115
|
+
|
|
116
|
+
def test_class_callable(self):
|
|
117
|
+
self.write('pkg/cli.py', 'class main: pass')
|
|
118
|
+
self.check()
|
|
119
|
+
|
|
120
|
+
def test_async_callable(self):
|
|
121
|
+
self.write('pkg/cli.py', 'async def main(): pass')
|
|
122
|
+
self.check()
|
|
123
|
+
|
|
124
|
+
def test_reexport(self):
|
|
125
|
+
self.write('pkg/__init__.py', 'from .cli import main')
|
|
126
|
+
self.target('pkg:main')
|
|
127
|
+
self.check()
|
|
128
|
+
|
|
129
|
+
def test_import_alias(self):
|
|
130
|
+
self.write('pkg/cli.py', 'import nonexistent as main')
|
|
131
|
+
self.check()
|
|
132
|
+
|
|
133
|
+
def test_import_name(self):
|
|
134
|
+
self.write('pkg/cli.py', 'import main.submodule')
|
|
135
|
+
self.check()
|
|
136
|
+
|
|
137
|
+
def test_lambda(self):
|
|
138
|
+
self.write('pkg/cli.py', 'main = lambda: None')
|
|
139
|
+
self.check()
|
|
140
|
+
|
|
141
|
+
def test_call(self):
|
|
142
|
+
self.write('pkg/cli.py', 'main = factory()')
|
|
143
|
+
self.check()
|
|
144
|
+
|
|
145
|
+
def test_name_assignment(self):
|
|
146
|
+
self.write('pkg/cli.py', 'main = other')
|
|
147
|
+
self.check('script-target-not-callable')
|
|
148
|
+
|
|
149
|
+
def test_attribute_assignment(self):
|
|
150
|
+
self.write('pkg/cli.py', 'main = other.attr')
|
|
151
|
+
self.check('script-target-not-callable')
|
|
152
|
+
|
|
153
|
+
def test_annotated(self):
|
|
154
|
+
self.write('pkg/cli.py', 'main: int = 42')
|
|
155
|
+
self.check('script-target-not-callable')
|
|
156
|
+
|
|
157
|
+
def test_tests_only(self):
|
|
158
|
+
self.write('tests/cli.py', 'def main(): pass')
|
|
159
|
+
self.target('tests.cli:main')
|
|
160
|
+
self.check('script-target-module-missing', 'HIGH')
|
|
161
|
+
|
|
162
|
+
def test_src_module(self):
|
|
163
|
+
(self.root / 'pkg/cli.py').unlink()
|
|
164
|
+
self.write('src/pkg/cli.py', 'def main(): pass')
|
|
165
|
+
self.check()
|
|
166
|
+
|
|
167
|
+
def test_src_package(self):
|
|
168
|
+
self.write('src/other/__init__.py', 'def main(): pass')
|
|
169
|
+
self.target('other:main')
|
|
170
|
+
self.check()
|
|
171
|
+
|
|
172
|
+
def test_flat_single(self):
|
|
173
|
+
self.write('single.py', 'def main(): pass')
|
|
174
|
+
self.target('single:main')
|
|
175
|
+
self.check()
|
|
176
|
+
|
|
177
|
+
def test_src_single(self):
|
|
178
|
+
self.write('src/single.py', 'def main(): pass')
|
|
179
|
+
self.target('single:main')
|
|
180
|
+
self.check()
|
|
181
|
+
|
|
182
|
+
def test_nested_package(self):
|
|
183
|
+
self.write('pkg/sub/__init__.py', 'def main(): pass')
|
|
184
|
+
self.target('pkg.sub:main')
|
|
185
|
+
self.check()
|
|
186
|
+
|
|
187
|
+
def test_documented_missing(self):
|
|
188
|
+
self.write('README.md', 'mytool\n```sh\nmy-tool --help\n```')
|
|
189
|
+
self.check('documented-command-missing', 'HIGH')
|
|
190
|
+
|
|
191
|
+
def test_documented_inline(self):
|
|
192
|
+
self.write('README.md', 'mytool and `my-tool --help`')
|
|
193
|
+
self.check('documented-command-missing')
|
|
194
|
+
|
|
195
|
+
def test_normalized_name(self):
|
|
196
|
+
self.write('README.md', 'mytool and `MY_TOOL --help`')
|
|
197
|
+
self.check('documented-command-missing')
|
|
198
|
+
|
|
199
|
+
def test_prose_no_command(self):
|
|
200
|
+
self.write('README.md', 'mytool my-tool --help')
|
|
201
|
+
self.check()
|
|
202
|
+
|
|
203
|
+
def test_arbitrary_command(self):
|
|
204
|
+
self.write('README.md', 'mytool `pip install x` `git status`')
|
|
205
|
+
self.check()
|
|
206
|
+
|
|
207
|
+
def test_undocumented(self):
|
|
208
|
+
self.write('README.md', 'Nothing here')
|
|
209
|
+
self.check('script-undocumented', 'MEDIUM')
|
|
210
|
+
|
|
211
|
+
def test_docsless(self):
|
|
212
|
+
(self.root / 'README.md').unlink()
|
|
213
|
+
self.check()
|
|
214
|
+
|
|
215
|
+
def test_duplicate(self):
|
|
216
|
+
self.write('pyproject.toml', '[project.scripts]\nmytool="pkg.cli:main"\n[project.gui-scripts]\nmytool="pkg.cli:main"')
|
|
217
|
+
self.check('script-name-duplicate', 'MEDIUM')
|
|
218
|
+
|
|
219
|
+
def test_gui_bad(self):
|
|
220
|
+
self.target('pkg.nosuch:main', 'gui-scripts')
|
|
221
|
+
self.check('script-target-module-missing', 'HIGH')
|
|
222
|
+
|
|
223
|
+
def test_gui_good(self):
|
|
224
|
+
self.target('pkg.cli:main', 'gui-scripts')
|
|
225
|
+
self.check()
|
|
226
|
+
|
|
227
|
+
def test_no_scripts(self):
|
|
228
|
+
self.write('pyproject.toml', '[project]\nname="library"')
|
|
229
|
+
self.check()
|
|
230
|
+
self.assertEqual(self.cli()[0], 0)
|
|
231
|
+
|
|
232
|
+
def test_no_pyproject(self):
|
|
233
|
+
(self.root / 'pyproject.toml').unlink()
|
|
234
|
+
self.check()
|
|
235
|
+
self.assertEqual(self.cli()[0], 0)
|
|
236
|
+
|
|
237
|
+
def test_no_project(self):
|
|
238
|
+
self.write('pyproject.toml', '[tool.example]\na=1')
|
|
239
|
+
self.check()
|
|
240
|
+
|
|
241
|
+
def test_ignore(self):
|
|
242
|
+
self.target('missing:main')
|
|
243
|
+
self.check(ignore=['script-target-module-missing'])
|
|
244
|
+
self.assertEqual(self.cli('--ignore', 'script-target-module-missing')[0], 0)
|
|
245
|
+
|
|
246
|
+
def test_json_sorted(self):
|
|
247
|
+
self.target('missing:main')
|
|
248
|
+
self.write('README.md', '`my-tool --help`')
|
|
249
|
+
code, output = self.cli('--json')
|
|
250
|
+
result = json.loads(output)
|
|
251
|
+
self.assertEqual(code, 2)
|
|
252
|
+
self.assertEqual(result['findings'], sorted(result['findings'], key=lambda f: (f['rule'], f['file'], f['line'])))
|
|
253
|
+
self.assertEqual(output, self.cli('--json')[1])
|
|
254
|
+
|
|
255
|
+
def test_version(self):
|
|
256
|
+
with self.assertRaises(SystemExit) as caught:
|
|
257
|
+
with contextlib.redirect_stdout(io.StringIO()) as output:
|
|
258
|
+
main(['--version'])
|
|
259
|
+
self.assertEqual(caught.exception.code, 0)
|
|
260
|
+
self.assertEqual(output.getvalue().strip(), '0.1.0')
|
|
261
|
+
|
|
262
|
+
def test_usage_error(self):
|
|
263
|
+
with self.assertRaises(SystemExit) as caught:
|
|
264
|
+
with contextlib.redirect_stderr(io.StringIO()):
|
|
265
|
+
main(['--fail-on', 'LOW'])
|
|
266
|
+
self.assertEqual(caught.exception.code, 1)
|
|
267
|
+
|
|
268
|
+
def test_nested_doc(self):
|
|
269
|
+
self.write('README.md', 'Introduction')
|
|
270
|
+
self.write('docs/deep/usage.md', '`mytool --help`')
|
|
271
|
+
self.check()
|
|
272
|
+
|
|
273
|
+
def test_whole_word(self):
|
|
274
|
+
self.write('README.md', 'mytools')
|
|
275
|
+
self.check('script-undocumented')
|
|
276
|
+
|
|
277
|
+
def test_no_execution(self):
|
|
278
|
+
self.write('pkg/cli.py', 'raise RuntimeError()\ndef main(): pass')
|
|
279
|
+
self.check()
|
|
280
|
+
|
|
281
|
+
def test_nested_symbol_excluded(self):
|
|
282
|
+
self.write('pkg/cli.py', 'def outer():\n def main(): pass')
|
|
283
|
+
self.check('script-target-symbol-missing')
|
|
284
|
+
|
|
285
|
+
def test_fallback(self):
|
|
286
|
+
with patch('entry_claim_audit.audit.tomllib', None):
|
|
287
|
+
self.check()
|
|
288
|
+
|
|
289
|
+
def test_fallback_gui(self):
|
|
290
|
+
self.target('pkg.cli:main', 'gui-scripts')
|
|
291
|
+
with patch('entry_claim_audit.audit.tomllib', None):
|
|
292
|
+
self.check()
|
|
293
|
+
|
|
294
|
+
def test_invalid_toml(self):
|
|
295
|
+
self.write('pyproject.toml', '[broken')
|
|
296
|
+
with self.assertRaises(SystemExit) as caught:
|
|
297
|
+
with contextlib.redirect_stderr(io.StringIO()):
|
|
298
|
+
self.cli()
|
|
299
|
+
self.assertEqual(caught.exception.code, 1)
|
|
300
|
+
|
|
301
|
+
def test_verbose(self):
|
|
302
|
+
self.assertIn('verified mytool', self.cli('--verbose')[1])
|
|
303
|
+
|
|
304
|
+
def test_tilde_fence(self):
|
|
305
|
+
self.write('README.md', 'mytool\n~~~sh\nmy-tool --help\n~~~')
|
|
306
|
+
self.check('documented-command-missing')
|
|
307
|
+
|
|
308
|
+
def test_no_scripts_with_project_command(self):
|
|
309
|
+
self.write('pyproject.toml', '[project]\nname="my-tool"')
|
|
310
|
+
self.write('README.md', '`my-tool --help`')
|
|
311
|
+
self.check()
|
|
312
|
+
|
|
313
|
+
def test_env_never_read(self):
|
|
314
|
+
self.write('.env', 'placeholder only')
|
|
315
|
+
original = Path.read_text
|
|
316
|
+
def guarded(path, *args, **kwargs):
|
|
317
|
+
self.assertNotEqual(path.name, '.env')
|
|
318
|
+
return original(path, *args, **kwargs)
|
|
319
|
+
with patch.object(Path, 'read_text', guarded):
|
|
320
|
+
self.check()
|
|
321
|
+
|
|
322
|
+
def test_dist_not_evidence(self):
|
|
323
|
+
self.write('dist/cli.py', 'def main(): pass')
|
|
324
|
+
self.target('dist.cli:main')
|
|
325
|
+
self.check('script-target-module-missing')
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
if __name__ == '__main__':
|
|
329
|
+
unittest.main()
|