encoding-claim-audit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- encoding_claim_audit/__init__.py +2 -0
- encoding_claim_audit/__main__.py +3 -0
- encoding_claim_audit/audit.py +132 -0
- encoding_claim_audit/cli.py +46 -0
- encoding_claim_audit-0.1.0.dist-info/METADATA +83 -0
- encoding_claim_audit-0.1.0.dist-info/RECORD +10 -0
- encoding_claim_audit-0.1.0.dist-info/WHEEL +5 -0
- encoding_claim_audit-0.1.0.dist-info/entry_points.txt +2 -0
- encoding_claim_audit-0.1.0.dist-info/licenses/LICENSE +21 -0
- encoding_claim_audit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Offline documentation and AST encoding audit."""
|
|
2
|
+
import ast
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
RULE = 'documented-encoding-claim-false'
|
|
7
|
+
TEST_RULE = RULE + '-test-only'
|
|
8
|
+
FAMILIES = {'UTF-8': r'\butf[-_]?8\b', 'Unicode': r'\bunicode\b'}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def claims_in(text):
|
|
12
|
+
"""Return claim families in unfenced, non-negated prose."""
|
|
13
|
+
prose = []
|
|
14
|
+
fence = None
|
|
15
|
+
for line in text.splitlines():
|
|
16
|
+
match = re.match(r'^\s{0,3}(`{3,}|~{3,})', line)
|
|
17
|
+
if match:
|
|
18
|
+
marker = match.group(1)
|
|
19
|
+
if fence is None:
|
|
20
|
+
fence = marker
|
|
21
|
+
elif marker[0] == fence[0] and len(marker) >= len(fence):
|
|
22
|
+
fence = None
|
|
23
|
+
continue
|
|
24
|
+
if fence is None:
|
|
25
|
+
prose.append(line)
|
|
26
|
+
result = set()
|
|
27
|
+
for sentence in re.split(r'[.!?\n]+', '\n'.join(prose)):
|
|
28
|
+
if re.search(r"\b(no|not|never|without)\b|\b(?:doesn’t|doesn't|cannot|can't)\b", sentence, re.I):
|
|
29
|
+
continue
|
|
30
|
+
for family, pattern in FAMILIES.items():
|
|
31
|
+
if re.search(pattern, sentence, re.I):
|
|
32
|
+
result.add(family)
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _name(node):
|
|
37
|
+
if isinstance(node, ast.Name):
|
|
38
|
+
return node.id
|
|
39
|
+
if isinstance(node, ast.Attribute):
|
|
40
|
+
return _name(node.value) + '.' + node.attr
|
|
41
|
+
return ''
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def inspect_source(text, path):
|
|
45
|
+
"""Return evidence and number of statically recognized text sites."""
|
|
46
|
+
tree = ast.parse(text, filename=path)
|
|
47
|
+
aliases = {}
|
|
48
|
+
for node in ast.walk(tree):
|
|
49
|
+
if isinstance(node, ast.Import):
|
|
50
|
+
for item in node.names:
|
|
51
|
+
aliases[item.asname or item.name] = item.name
|
|
52
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
53
|
+
for item in node.names:
|
|
54
|
+
aliases[item.asname or item.name] = node.module + '.' + item.name
|
|
55
|
+
evidence = []
|
|
56
|
+
sites = 0
|
|
57
|
+
for node in ast.walk(tree):
|
|
58
|
+
if not isinstance(node, ast.Call):
|
|
59
|
+
continue
|
|
60
|
+
name = _name(node.func)
|
|
61
|
+
first, _, rest = name.partition('.')
|
|
62
|
+
name = aliases.get(first, first) + ('.' + rest if rest else '')
|
|
63
|
+
keywords = {k.arg: k.value for k in node.keywords}
|
|
64
|
+
encoding = keywords.get('encoding')
|
|
65
|
+
if name in ('open', 'io.open', 'builtins.open', 'codecs.open'):
|
|
66
|
+
mode = keywords.get('mode', node.args[1] if len(node.args) > 1 else ast.Constant('r'))
|
|
67
|
+
if not isinstance(mode, ast.Constant) or not isinstance(mode.value, str):
|
|
68
|
+
continue
|
|
69
|
+
if 'b' in mode.value:
|
|
70
|
+
continue
|
|
71
|
+
position = 2 if name == 'codecs.open' else 3
|
|
72
|
+
if encoding is None and len(node.args) > position:
|
|
73
|
+
encoding = node.args[position]
|
|
74
|
+
elif name == 'io.TextIOWrapper' or (isinstance(node.func, ast.Attribute) and node.func.attr in ('read_text', 'write_text')):
|
|
75
|
+
pass
|
|
76
|
+
else:
|
|
77
|
+
continue
|
|
78
|
+
sites += 1
|
|
79
|
+
if encoding is None:
|
|
80
|
+
kind = 'missing-encoding'
|
|
81
|
+
elif isinstance(encoding, ast.Constant):
|
|
82
|
+
if isinstance(encoding.value, str) and encoding.value.lower() in ('utf-8', 'utf8', 'utf_8'):
|
|
83
|
+
continue
|
|
84
|
+
kind = 'wrong-encoding'
|
|
85
|
+
else:
|
|
86
|
+
continue
|
|
87
|
+
evidence.append({'path': path, 'line': node.lineno, 'kind': kind})
|
|
88
|
+
return evidence, sites
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def audit(root, ignore=()):
|
|
92
|
+
"""Audit a directory without importing or executing its source."""
|
|
93
|
+
root = Path(root)
|
|
94
|
+
if not root.is_dir():
|
|
95
|
+
raise ValueError('input path must be a directory')
|
|
96
|
+
claims = set()
|
|
97
|
+
docs = [root / 'README.md']
|
|
98
|
+
if (root / 'docs').is_dir() and not (root / 'docs').is_symlink():
|
|
99
|
+
docs.extend((root / 'docs').rglob('*.md'))
|
|
100
|
+
def safe(path):
|
|
101
|
+
return not any(p.is_symlink() for p in [path] + list(path.parents) if p != root.parent)
|
|
102
|
+
for path in docs:
|
|
103
|
+
if path.is_file() and safe(path):
|
|
104
|
+
claims.update(claims_in(path.read_text(encoding='utf-8')))
|
|
105
|
+
if not claims:
|
|
106
|
+
return {'findings': [], 'verified_claims': [], 'summary': {'claims': 0, 'findings': 0, 'python_files': 0}}
|
|
107
|
+
production, testonly = [], []
|
|
108
|
+
sources = sites = 0
|
|
109
|
+
for path in sorted(root.rglob('*.py')):
|
|
110
|
+
relative = path.relative_to(root)
|
|
111
|
+
if not safe(path) or 'examples' in relative.parts:
|
|
112
|
+
continue
|
|
113
|
+
sources += 1
|
|
114
|
+
evidence, count = inspect_source(path.read_text(encoding='utf-8-sig'), relative.as_posix())
|
|
115
|
+
is_test = bool(set(relative.parts[:-1]) & {'tests', 'test', 'fixtures'})
|
|
116
|
+
(testonly if is_test else production).extend(evidence)
|
|
117
|
+
if not is_test:
|
|
118
|
+
sites += count
|
|
119
|
+
findings, verified = [], []
|
|
120
|
+
for family in sorted(claims):
|
|
121
|
+
if production or not sources:
|
|
122
|
+
rule, severity, evidence = RULE, 'HIGH', production
|
|
123
|
+
note = 'Production text I/O contradicts the claim.' if sources else 'No Python source files found.'
|
|
124
|
+
elif testonly:
|
|
125
|
+
rule, severity, evidence = TEST_RULE, 'MEDIUM', testonly
|
|
126
|
+
note = 'Encoding evidence occurs only in tests or fixtures.'
|
|
127
|
+
else:
|
|
128
|
+
verified.append({'family': family, 'note': 'All recognized production text I/O carries UTF-8 encoding or an unknown encoding.' if sites else 'No production text I/O; claim is moot, not falsified.'})
|
|
129
|
+
continue
|
|
130
|
+
if rule not in ignore:
|
|
131
|
+
findings.append({'rule': rule, 'severity': severity, 'family': family, 'message': note, 'evidence': evidence})
|
|
132
|
+
return {'findings': findings, 'verified_claims': verified, 'summary': {'claims': len(claims), 'findings': len(findings), 'python_files': sources}}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from . import __version__
|
|
6
|
+
from .audit import RULE, TEST_RULE, audit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Parser(argparse.ArgumentParser):
|
|
10
|
+
def error(self, message):
|
|
11
|
+
self.print_usage(sys.stderr)
|
|
12
|
+
self.exit(1, '%s: error: %s\n' % (self.prog, message))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv=None):
|
|
16
|
+
parser = Parser(prog='encoding-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
|
+
ignored = set(filter(None, args.ignore.split(',')))
|
|
25
|
+
if ignored - {RULE, TEST_RULE}:
|
|
26
|
+
parser.error('unknown ignored rule: ' + ', '.join(sorted(ignored - {RULE, TEST_RULE})))
|
|
27
|
+
try:
|
|
28
|
+
result = audit(args.path, ignored)
|
|
29
|
+
except (OSError, ValueError, SyntaxError) as exc:
|
|
30
|
+
print('encoding-claim-audit: ' + str(exc), file=sys.stderr)
|
|
31
|
+
return 1
|
|
32
|
+
failed = any(f['severity'] == 'HIGH' or args.fail_on == 'MEDIUM' for f in result['findings'])
|
|
33
|
+
result['summary']['status'] = 'FAIL' if failed else 'PASS'
|
|
34
|
+
if args.json:
|
|
35
|
+
print(json.dumps(result, indent=2))
|
|
36
|
+
else:
|
|
37
|
+
print(result['summary']['status'])
|
|
38
|
+
for finding in result['findings']:
|
|
39
|
+
print('{severity} {rule} [{family}]: {message}'.format(**finding))
|
|
40
|
+
for item in finding['evidence']:
|
|
41
|
+
print(' {path}:{line}: {kind}'.format(**item))
|
|
42
|
+
if args.verbose:
|
|
43
|
+
for claim in result['verified_claims']:
|
|
44
|
+
print('{family}: {note}'.format(**claim))
|
|
45
|
+
print('Python files: ' + str(result['summary']['python_files']))
|
|
46
|
+
return 2 if failed else 0
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: encoding-claim-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline audit of documented text encoding claims against Python source
|
|
5
|
+
Author: encoding-claim-audit contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# encoding-claim-audit
|
|
13
|
+
|
|
14
|
+
Version 0.1.0. Offline static audit of documented UTF-8 and Unicode claims
|
|
15
|
+
against Python text file I/O. Python >=3.9; no runtime dependencies.
|
|
16
|
+
Audited code is never executed and no network requests are made.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
python3 -m pip install .
|
|
22
|
+
encoding-claim-audit [path]
|
|
23
|
+
encoding-claim-audit ./project --json --fail-on HIGH
|
|
24
|
+
encoding-claim-audit ./project --ignore documented-encoding-claim-false-test-only
|
|
25
|
+
encoding-claim-audit --version
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Path defaults to `.`. `--verbose` prints verified claim notes and source counts.
|
|
29
|
+
`--json` emits findings, verified_claims, and summary. Each finding has a family,
|
|
30
|
+
rule, severity, message, and evidence items with path, line, and kind.
|
|
31
|
+
Exit codes: 0 PASS at the selected threshold, 2 FAIL, 1 usage/input error.
|
|
32
|
+
The default threshold is MEDIUM. `--fail-on HIGH` still prints MEDIUM findings.
|
|
33
|
+
Unknown ignore rules are input errors. `--ignore RULE,...` accepts:
|
|
34
|
+
|
|
35
|
+
- `documented-encoding-claim-false`: HIGH for production evidence or no source.
|
|
36
|
+
- `documented-encoding-claim-false-test-only`: MEDIUM for test-only evidence.
|
|
37
|
+
|
|
38
|
+
Each family produces at most one finding. Production evidence takes precedence.
|
|
39
|
+
Ignored findings are omitted, not relabeled as verified.
|
|
40
|
+
|
|
41
|
+
## Scope and limitations
|
|
42
|
+
|
|
43
|
+
Claims come from root README.md and docs/**/*.md prose, including inline code.
|
|
44
|
+
Backtick and tilde fenced code is excluded; sentences containing negation are
|
|
45
|
+
suppressed. Positive sentences elsewhere can independently establish a claim.
|
|
46
|
+
UTF-8, utf8, utf_8, and Unicode are recognized case-insensitively.
|
|
47
|
+
|
|
48
|
+
AST checks cover open/io.open, codecs.open, io.TextIOWrapper, and read_text/
|
|
49
|
+
write_text methods. Missing or non-UTF-8 literal encodings are evidence.
|
|
50
|
+
Standard open positional encoding and codecs.open third positional encoding
|
|
51
|
+
are recognized; Path methods and TextIOWrapper require keyword encoding.
|
|
52
|
+
Binary I/O is excluded. Tests are identified by tests/test/fixtures directory
|
|
53
|
+
components. examples directories and symlinks are skipped. Only Python source
|
|
54
|
+
and Markdown documentation are read; .env contents are never opened.
|
|
55
|
+
No claims yields empty findings and verified claims. No Python source yields
|
|
56
|
+
HIGH per family. No production text I/O makes the claim moot, not falsified.
|
|
57
|
+
|
|
58
|
+
This is static pattern matching, not semantic judgement. Dead code counts.
|
|
59
|
+
Variable modes/encodings are skipped as unknown. Dynamic file opens are missed.
|
|
60
|
+
Alias tracking is per-file only; shadowing and reassignment are not resolved.
|
|
61
|
+
Text method matching does not infer receiver types and may flag unrelated methods.
|
|
62
|
+
Negation is conservative and sentence based, so unrelated negative words can
|
|
63
|
+
suppress claims. Clean results with unknown encodings are qualified rather than
|
|
64
|
+
proof of runtime behavior. Source must be UTF-8 (optional BOM), docs UTF-8;
|
|
65
|
+
malformed or unreadable input yields exit 1.
|
|
66
|
+
|
|
67
|
+
## Differentiation
|
|
68
|
+
|
|
69
|
+
- privacy-claim-audit: network/telemetry evidence, never text encodings.
|
|
70
|
+
- config-claim-audit: documented default values, never encoding arguments.
|
|
71
|
+
- readme-claim-audit: install/flag/link/version, never encoding.
|
|
72
|
+
- bandit/ruff: code issues, never documentation promises.
|
|
73
|
+
- file/chardet: actual bytes on disk, never documentation versus code truth.
|
|
74
|
+
|
|
75
|
+
## Development
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
79
|
+
python3 -m build
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The source distribution includes tests and good/bad/testonly fixtures, including
|
|
83
|
+
a marker-only .env read guard. Licensed under MIT.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
encoding_claim_audit/__init__.py,sha256=BZoVM8SxwB_k43aw_jbMSOogMX3Iumm8-UCRbrR3U8o,74
|
|
2
|
+
encoding_claim_audit/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
encoding_claim_audit/audit.py,sha256=l8UldmDYbMQA22AiV1C47M35z-P2VFhlXSXGikEZGBw,5616
|
|
4
|
+
encoding_claim_audit/cli.py,sha256=5SYPrt-kag0C0OJsqKoqjUSaPUadtVMqbNt_PGkbO7w,1939
|
|
5
|
+
encoding_claim_audit-0.1.0.dist-info/licenses/LICENSE,sha256=0wppIt49xM1AQe-H0_B0TVS6Rm8iRfGPI5p0aebGueM,1090
|
|
6
|
+
encoding_claim_audit-0.1.0.dist-info/METADATA,sha256=IhPzV0WZQUqStXX22yChjUSE4cXHrOvEJud7wXOzpk0,3740
|
|
7
|
+
encoding_claim_audit-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
encoding_claim_audit-0.1.0.dist-info/entry_points.txt,sha256=7m523uGzJJP6r-tzJKo2Ieui0kr8KJBKtXtogD6wBxU,71
|
|
9
|
+
encoding_claim_audit-0.1.0.dist-info/top_level.txt,sha256=LaTEuo01-CVUY_i6LwLVEdryNRVvMXmy1APdiRjJqTw,21
|
|
10
|
+
encoding_claim_audit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 encoding-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 @@
|
|
|
1
|
+
encoding_claim_audit
|