encoding-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.
- encoding_claim_audit-0.1.0/LICENSE +21 -0
- encoding_claim_audit-0.1.0/MANIFEST.in +2 -0
- encoding_claim_audit-0.1.0/PKG-INFO +83 -0
- encoding_claim_audit-0.1.0/README.md +72 -0
- encoding_claim_audit-0.1.0/pyproject.toml +19 -0
- encoding_claim_audit-0.1.0/setup.cfg +4 -0
- encoding_claim_audit-0.1.0/src/encoding_claim_audit/__init__.py +2 -0
- encoding_claim_audit-0.1.0/src/encoding_claim_audit/__main__.py +3 -0
- encoding_claim_audit-0.1.0/src/encoding_claim_audit/audit.py +132 -0
- encoding_claim_audit-0.1.0/src/encoding_claim_audit/cli.py +46 -0
- encoding_claim_audit-0.1.0/src/encoding_claim_audit.egg-info/PKG-INFO +83 -0
- encoding_claim_audit-0.1.0/src/encoding_claim_audit.egg-info/SOURCES.txt +22 -0
- encoding_claim_audit-0.1.0/src/encoding_claim_audit.egg-info/dependency_links.txt +1 -0
- encoding_claim_audit-0.1.0/src/encoding_claim_audit.egg-info/entry_points.txt +2 -0
- encoding_claim_audit-0.1.0/src/encoding_claim_audit.egg-info/top_level.txt +1 -0
- encoding_claim_audit-0.1.0/tests/fixtures/bad/.env +1 -0
- encoding_claim_audit-0.1.0/tests/fixtures/bad/README.md +1 -0
- encoding_claim_audit-0.1.0/tests/fixtures/bad/app.py +3 -0
- encoding_claim_audit-0.1.0/tests/fixtures/good/README.md +1 -0
- encoding_claim_audit-0.1.0/tests/fixtures/good/app.py +8 -0
- encoding_claim_audit-0.1.0/tests/fixtures/testonly/README.md +1 -0
- encoding_claim_audit-0.1.0/tests/fixtures/testonly/app.py +1 -0
- encoding_claim_audit-0.1.0/tests/fixtures/testonly/tests/test_io.py +1 -0
- encoding_claim_audit-0.1.0/tests/test_audit.py +279 -0
|
@@ -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,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,72 @@
|
|
|
1
|
+
# encoding-claim-audit
|
|
2
|
+
|
|
3
|
+
Version 0.1.0. Offline static audit of documented UTF-8 and Unicode claims
|
|
4
|
+
against Python text file I/O. Python >=3.9; no runtime dependencies.
|
|
5
|
+
Audited code is never executed and no network requests are made.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
python3 -m pip install .
|
|
11
|
+
encoding-claim-audit [path]
|
|
12
|
+
encoding-claim-audit ./project --json --fail-on HIGH
|
|
13
|
+
encoding-claim-audit ./project --ignore documented-encoding-claim-false-test-only
|
|
14
|
+
encoding-claim-audit --version
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Path defaults to `.`. `--verbose` prints verified claim notes and source counts.
|
|
18
|
+
`--json` emits findings, verified_claims, and summary. Each finding has a family,
|
|
19
|
+
rule, severity, message, and evidence items with path, line, and kind.
|
|
20
|
+
Exit codes: 0 PASS at the selected threshold, 2 FAIL, 1 usage/input error.
|
|
21
|
+
The default threshold is MEDIUM. `--fail-on HIGH` still prints MEDIUM findings.
|
|
22
|
+
Unknown ignore rules are input errors. `--ignore RULE,...` accepts:
|
|
23
|
+
|
|
24
|
+
- `documented-encoding-claim-false`: HIGH for production evidence or no source.
|
|
25
|
+
- `documented-encoding-claim-false-test-only`: MEDIUM for test-only evidence.
|
|
26
|
+
|
|
27
|
+
Each family produces at most one finding. Production evidence takes precedence.
|
|
28
|
+
Ignored findings are omitted, not relabeled as verified.
|
|
29
|
+
|
|
30
|
+
## Scope and limitations
|
|
31
|
+
|
|
32
|
+
Claims come from root README.md and docs/**/*.md prose, including inline code.
|
|
33
|
+
Backtick and tilde fenced code is excluded; sentences containing negation are
|
|
34
|
+
suppressed. Positive sentences elsewhere can independently establish a claim.
|
|
35
|
+
UTF-8, utf8, utf_8, and Unicode are recognized case-insensitively.
|
|
36
|
+
|
|
37
|
+
AST checks cover open/io.open, codecs.open, io.TextIOWrapper, and read_text/
|
|
38
|
+
write_text methods. Missing or non-UTF-8 literal encodings are evidence.
|
|
39
|
+
Standard open positional encoding and codecs.open third positional encoding
|
|
40
|
+
are recognized; Path methods and TextIOWrapper require keyword encoding.
|
|
41
|
+
Binary I/O is excluded. Tests are identified by tests/test/fixtures directory
|
|
42
|
+
components. examples directories and symlinks are skipped. Only Python source
|
|
43
|
+
and Markdown documentation are read; .env contents are never opened.
|
|
44
|
+
No claims yields empty findings and verified claims. No Python source yields
|
|
45
|
+
HIGH per family. No production text I/O makes the claim moot, not falsified.
|
|
46
|
+
|
|
47
|
+
This is static pattern matching, not semantic judgement. Dead code counts.
|
|
48
|
+
Variable modes/encodings are skipped as unknown. Dynamic file opens are missed.
|
|
49
|
+
Alias tracking is per-file only; shadowing and reassignment are not resolved.
|
|
50
|
+
Text method matching does not infer receiver types and may flag unrelated methods.
|
|
51
|
+
Negation is conservative and sentence based, so unrelated negative words can
|
|
52
|
+
suppress claims. Clean results with unknown encodings are qualified rather than
|
|
53
|
+
proof of runtime behavior. Source must be UTF-8 (optional BOM), docs UTF-8;
|
|
54
|
+
malformed or unreadable input yields exit 1.
|
|
55
|
+
|
|
56
|
+
## Differentiation
|
|
57
|
+
|
|
58
|
+
- privacy-claim-audit: network/telemetry evidence, never text encodings.
|
|
59
|
+
- config-claim-audit: documented default values, never encoding arguments.
|
|
60
|
+
- readme-claim-audit: install/flag/link/version, never encoding.
|
|
61
|
+
- bandit/ruff: code issues, never documentation promises.
|
|
62
|
+
- file/chardet: actual bytes on disk, never documentation versus code truth.
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
68
|
+
python3 -m build
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The source distribution includes tests and good/bad/testonly fixtures, including
|
|
72
|
+
a marker-only .env read guard. Licensed under MIT.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "encoding-claim-audit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Offline audit of documented text encoding claims against Python source"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "encoding-claim-audit contributors"}]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
encoding-claim-audit = "encoding_claim_audit.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
|
@@ -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,22 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
src/encoding_claim_audit/__init__.py
|
|
6
|
+
src/encoding_claim_audit/__main__.py
|
|
7
|
+
src/encoding_claim_audit/audit.py
|
|
8
|
+
src/encoding_claim_audit/cli.py
|
|
9
|
+
src/encoding_claim_audit.egg-info/PKG-INFO
|
|
10
|
+
src/encoding_claim_audit.egg-info/SOURCES.txt
|
|
11
|
+
src/encoding_claim_audit.egg-info/dependency_links.txt
|
|
12
|
+
src/encoding_claim_audit.egg-info/entry_points.txt
|
|
13
|
+
src/encoding_claim_audit.egg-info/top_level.txt
|
|
14
|
+
tests/test_audit.py
|
|
15
|
+
tests/fixtures/bad/.env
|
|
16
|
+
tests/fixtures/bad/README.md
|
|
17
|
+
tests/fixtures/bad/app.py
|
|
18
|
+
tests/fixtures/good/README.md
|
|
19
|
+
tests/fixtures/good/app.py
|
|
20
|
+
tests/fixtures/testonly/README.md
|
|
21
|
+
tests/fixtures/testonly/app.py
|
|
22
|
+
tests/fixtures/testonly/tests/test_io.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
encoding_claim_audit
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ENCODING_AUDIT_ENV_MUST_NEVER_BE_READ_73491
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Supports UTF-8.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Supports UTF-8 and Unicode.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Supports UTF-8 and Unicode.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""No production file I/O."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
open('text')
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
from encoding_claim_audit.audit import audit, claims_in, inspect_source, RULE, TEST_RULE
|
|
13
|
+
from encoding_claim_audit.cli import main
|
|
14
|
+
|
|
15
|
+
FIXTURES = Path(__file__).parent / 'fixtures'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuditTests(unittest.TestCase):
|
|
19
|
+
def source(self, code):
|
|
20
|
+
return inspect_source(code, 'app.py')[0]
|
|
21
|
+
|
|
22
|
+
def project(self, readme='UTF-8', source="open('x')", filename='app.py'):
|
|
23
|
+
temp = tempfile.TemporaryDirectory()
|
|
24
|
+
self.addCleanup(temp.cleanup)
|
|
25
|
+
root = Path(temp.name)
|
|
26
|
+
(root / 'README.md').write_text(readme, encoding='utf-8')
|
|
27
|
+
if source is not None:
|
|
28
|
+
path = root / filename
|
|
29
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
path.write_text(source, encoding='utf-8')
|
|
31
|
+
return root
|
|
32
|
+
|
|
33
|
+
def cli(self, *args):
|
|
34
|
+
output, error = io.StringIO(), io.StringIO()
|
|
35
|
+
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error):
|
|
36
|
+
try:
|
|
37
|
+
code = main(list(map(str, args)))
|
|
38
|
+
except SystemExit as exc:
|
|
39
|
+
code = exc.code
|
|
40
|
+
return code, output.getvalue(), error.getvalue()
|
|
41
|
+
|
|
42
|
+
def test_good(self):
|
|
43
|
+
result = audit(FIXTURES / 'good')
|
|
44
|
+
self.assertEqual(result['findings'], [])
|
|
45
|
+
self.assertEqual(len(result['verified_claims']), 2)
|
|
46
|
+
|
|
47
|
+
def test_bad(self):
|
|
48
|
+
findings = audit(FIXTURES / 'bad')['findings']
|
|
49
|
+
self.assertEqual(len(findings), 1)
|
|
50
|
+
self.assertEqual(findings[0]['severity'], 'HIGH')
|
|
51
|
+
self.assertEqual([e['kind'] for e in findings[0]['evidence']], ['missing-encoding', 'wrong-encoding'])
|
|
52
|
+
|
|
53
|
+
def test_testonly(self):
|
|
54
|
+
self.assertEqual({f['severity'] for f in audit(FIXTURES / 'testonly')['findings']}, {'MEDIUM'})
|
|
55
|
+
|
|
56
|
+
def test_good_cli(self):
|
|
57
|
+
self.assertEqual(self.cli(FIXTURES / 'good')[0], 0)
|
|
58
|
+
|
|
59
|
+
def test_bad_cli(self):
|
|
60
|
+
self.assertEqual(self.cli(FIXTURES / 'bad')[0], 2)
|
|
61
|
+
|
|
62
|
+
def test_testonly_cli(self):
|
|
63
|
+
self.assertEqual(self.cli(FIXTURES / 'testonly')[0], 2)
|
|
64
|
+
|
|
65
|
+
def test_high_threshold(self):
|
|
66
|
+
code, output, _ = self.cli(FIXTURES / 'testonly', '--fail-on', 'HIGH')
|
|
67
|
+
self.assertEqual(code, 0)
|
|
68
|
+
self.assertIn('MEDIUM', output)
|
|
69
|
+
|
|
70
|
+
def test_json(self):
|
|
71
|
+
code, output, _ = self.cli(FIXTURES / 'bad', '--json')
|
|
72
|
+
self.assertEqual(code, 2)
|
|
73
|
+
self.assertEqual(json.loads(output)['summary']['status'], 'FAIL')
|
|
74
|
+
|
|
75
|
+
def test_version(self):
|
|
76
|
+
self.assertEqual(self.cli('--version')[:2], (0, '0.1.0\n'))
|
|
77
|
+
|
|
78
|
+
def test_ignore_high(self):
|
|
79
|
+
self.assertEqual(self.cli(FIXTURES / 'bad', '--ignore', RULE)[0], 0)
|
|
80
|
+
|
|
81
|
+
def test_ignore_medium(self):
|
|
82
|
+
self.assertEqual(self.cli(FIXTURES / 'testonly', '--ignore', TEST_RULE)[0], 0)
|
|
83
|
+
|
|
84
|
+
def test_ignore_multiple(self):
|
|
85
|
+
self.assertEqual(self.cli(FIXTURES / 'bad', '--ignore', RULE + ',' + TEST_RULE)[0], 0)
|
|
86
|
+
|
|
87
|
+
def test_unknown_ignore(self):
|
|
88
|
+
self.assertEqual(self.cli('--ignore', 'typo')[0], 1)
|
|
89
|
+
|
|
90
|
+
def test_bad_option(self):
|
|
91
|
+
self.assertEqual(self.cli('--no-such-option')[0], 1)
|
|
92
|
+
|
|
93
|
+
def test_bad_threshold(self):
|
|
94
|
+
self.assertEqual(self.cli('--fail-on', 'LOW')[0], 1)
|
|
95
|
+
|
|
96
|
+
def test_bad_path(self):
|
|
97
|
+
self.assertEqual(self.cli('/no/such/project')[0], 1)
|
|
98
|
+
|
|
99
|
+
def test_verbose(self):
|
|
100
|
+
self.assertIn('Python files:', self.cli(FIXTURES / 'good', '--verbose')[1])
|
|
101
|
+
|
|
102
|
+
def test_default_path(self):
|
|
103
|
+
previous = os.getcwd()
|
|
104
|
+
try:
|
|
105
|
+
os.chdir(FIXTURES / 'good')
|
|
106
|
+
self.assertEqual(self.cli()[0], 0)
|
|
107
|
+
finally:
|
|
108
|
+
os.chdir(previous)
|
|
109
|
+
|
|
110
|
+
def test_utf8_variant(self):
|
|
111
|
+
self.assertEqual(claims_in('utf8'), {'UTF-8'})
|
|
112
|
+
|
|
113
|
+
def test_utfunderscore_variant(self):
|
|
114
|
+
self.assertEqual(claims_in('UTF_8'), {'UTF-8'})
|
|
115
|
+
|
|
116
|
+
def test_unicode_case(self):
|
|
117
|
+
self.assertEqual(claims_in('uNiCoDe'), {'Unicode'})
|
|
118
|
+
|
|
119
|
+
def test_family_dedup(self):
|
|
120
|
+
self.assertEqual(claims_in('UTF-8 utf8 utf_8'), {'UTF-8'})
|
|
121
|
+
|
|
122
|
+
def test_inline_code(self):
|
|
123
|
+
self.assertEqual(claims_in('Supports `UTF-8`'), {'UTF-8'})
|
|
124
|
+
|
|
125
|
+
def test_backtick_fence(self):
|
|
126
|
+
self.assertEqual(claims_in('```python\nUTF-8 Unicode\n```'), set())
|
|
127
|
+
|
|
128
|
+
def test_tilde_fence(self):
|
|
129
|
+
self.assertEqual(claims_in('~~~\nUTF-8\n~~~\nUnicode'), {'Unicode'})
|
|
130
|
+
|
|
131
|
+
def test_fence_short_close(self):
|
|
132
|
+
self.assertEqual(claims_in('````\n```\nUTF-8\n````'), set())
|
|
133
|
+
|
|
134
|
+
def test_negated(self):
|
|
135
|
+
self.assertEqual(claims_in('Does not support UTF-8. No Unicode support.'), set())
|
|
136
|
+
|
|
137
|
+
def test_positive_sentence_after_negative(self):
|
|
138
|
+
self.assertEqual(claims_in('No Unicode support. Supports UTF-8.'), {'UTF-8'})
|
|
139
|
+
|
|
140
|
+
def test_no_claim(self):
|
|
141
|
+
result = audit(self.project('A package.'))
|
|
142
|
+
self.assertEqual(result['findings'], [])
|
|
143
|
+
self.assertEqual(result['verified_claims'], [])
|
|
144
|
+
|
|
145
|
+
def test_no_source(self):
|
|
146
|
+
self.assertEqual(len(audit(self.project('UTF-8 and Unicode', None))['findings']), 2)
|
|
147
|
+
|
|
148
|
+
def test_unicode_evidence(self):
|
|
149
|
+
self.assertEqual(audit(self.project('Unicode'))['findings'][0]['family'], 'Unicode')
|
|
150
|
+
|
|
151
|
+
def test_moot(self):
|
|
152
|
+
result = audit(self.project(source='x = 1'))
|
|
153
|
+
self.assertIn('moot', result['verified_claims'][0]['note'])
|
|
154
|
+
|
|
155
|
+
def test_docs_nested(self):
|
|
156
|
+
root = self.project('A package.')
|
|
157
|
+
doc = root / 'docs' / 'nested' / 'guide.md'
|
|
158
|
+
doc.parent.mkdir(parents=True)
|
|
159
|
+
doc.write_text('Unicode', encoding='utf-8')
|
|
160
|
+
self.assertEqual(len(audit(root)['findings']), 1)
|
|
161
|
+
|
|
162
|
+
def test_open_missing(self):
|
|
163
|
+
self.assertEqual(self.source("open('x')")[0]['kind'], 'missing-encoding')
|
|
164
|
+
|
|
165
|
+
def test_io_open(self):
|
|
166
|
+
self.assertEqual(len(self.source("import io\nio.open('x')")), 1)
|
|
167
|
+
|
|
168
|
+
def test_open_positional_encoding(self):
|
|
169
|
+
self.assertEqual(self.source("open('x', 'r', -1, 'UTF-8')"), [])
|
|
170
|
+
|
|
171
|
+
def test_binary(self):
|
|
172
|
+
self.assertEqual(self.source("open('x', 'rb'); open('x', mode='wb+')"), [])
|
|
173
|
+
|
|
174
|
+
def test_variable_mode(self):
|
|
175
|
+
self.assertEqual(self.source("open('x', mode=mode)"), [])
|
|
176
|
+
|
|
177
|
+
def test_variable_encoding(self):
|
|
178
|
+
self.assertEqual(self.source("open('x', encoding=encoding)"), [])
|
|
179
|
+
|
|
180
|
+
def test_text_modes(self):
|
|
181
|
+
for mode in ('r', 'w', 'a', 'x', 'r+', 'w+', 'a+', 'x+'):
|
|
182
|
+
with self.subTest(mode=mode):
|
|
183
|
+
self.assertEqual(len(self.source("open('x', %r)" % mode)), 1)
|
|
184
|
+
|
|
185
|
+
def test_codecs_positional(self):
|
|
186
|
+
self.assertEqual(self.source("import codecs\ncodecs.open('x', 'r', 'utf8')"), [])
|
|
187
|
+
|
|
188
|
+
def test_codecs_wrong_positional(self):
|
|
189
|
+
self.assertEqual(self.source("import codecs\ncodecs.open('x', 'r', 'latin-1')")[0]['kind'], 'wrong-encoding')
|
|
190
|
+
|
|
191
|
+
def test_codecs_missing(self):
|
|
192
|
+
self.assertEqual(len(self.source("import codecs\ncodecs.open('x')")), 1)
|
|
193
|
+
|
|
194
|
+
def test_codecs_keyword(self):
|
|
195
|
+
self.assertEqual(self.source("import codecs\ncodecs.open('x', encoding='utf_8')"), [])
|
|
196
|
+
|
|
197
|
+
def test_wrapper_missing(self):
|
|
198
|
+
self.assertEqual(len(self.source('import io\nio.TextIOWrapper(stream)')), 1)
|
|
199
|
+
|
|
200
|
+
def test_wrapper_wrong(self):
|
|
201
|
+
self.assertEqual(self.source("import io\nio.TextIOWrapper(stream, encoding='ascii')")[0]['kind'], 'wrong-encoding')
|
|
202
|
+
|
|
203
|
+
def test_wrapper_good(self):
|
|
204
|
+
self.assertEqual(self.source("import io\nio.TextIOWrapper(stream, encoding='utf8')"), [])
|
|
205
|
+
|
|
206
|
+
def test_path_methods(self):
|
|
207
|
+
for method in ('read_text', 'write_text'):
|
|
208
|
+
self.assertEqual(len(self.source("path.%s()" % method)), 1)
|
|
209
|
+
|
|
210
|
+
def test_path_wrong(self):
|
|
211
|
+
self.assertEqual(self.source("path.write_text('x', encoding='latin-1')")[0]['kind'], 'wrong-encoding')
|
|
212
|
+
|
|
213
|
+
def test_path_variable(self):
|
|
214
|
+
self.assertEqual(self.source('path.read_text(encoding=enc)'), [])
|
|
215
|
+
|
|
216
|
+
def test_bytes(self):
|
|
217
|
+
self.assertEqual(self.source("p.read_bytes(); p.write_bytes(b'x')"), [])
|
|
218
|
+
|
|
219
|
+
def test_streams(self):
|
|
220
|
+
self.assertEqual(self.source("import sys\nsys.stdin.read(); sys.stdout.write('x'); print('x', file=sys.stdout)"), [])
|
|
221
|
+
|
|
222
|
+
def test_aliases(self):
|
|
223
|
+
self.assertEqual(len(self.source('import io as streams\nfrom codecs import open as copen\nstreams.open("x")\ncopen("x")')), 2)
|
|
224
|
+
|
|
225
|
+
def test_from_wrapper(self):
|
|
226
|
+
self.assertEqual(len(self.source('from io import TextIOWrapper as W\nW(stream)')), 1)
|
|
227
|
+
|
|
228
|
+
def test_symlink(self):
|
|
229
|
+
root = self.project(source='x = 1')
|
|
230
|
+
(root / 'linked.py').symlink_to(FIXTURES.resolve() / 'bad' / 'app.py')
|
|
231
|
+
self.assertEqual(audit(root)['findings'], [])
|
|
232
|
+
|
|
233
|
+
def test_symlink_directory(self):
|
|
234
|
+
root = self.project(source='x = 1')
|
|
235
|
+
(root / 'linked').symlink_to(FIXTURES.resolve() / 'bad', target_is_directory=True)
|
|
236
|
+
self.assertEqual(audit(root)['findings'], [])
|
|
237
|
+
|
|
238
|
+
def test_examples(self):
|
|
239
|
+
self.assertEqual(audit(self.project(filename='examples/demo.py'))['summary']['python_files'], 0)
|
|
240
|
+
|
|
241
|
+
def test_test_components(self):
|
|
242
|
+
for directory in ('tests', 'test', 'fixtures'):
|
|
243
|
+
self.assertEqual(audit(self.project(filename=directory + '/app.py'))['findings'][0]['severity'], 'MEDIUM')
|
|
244
|
+
|
|
245
|
+
def test_production_wins(self):
|
|
246
|
+
root = self.project()
|
|
247
|
+
(root / 'tests').mkdir()
|
|
248
|
+
(root / 'tests' / 'test_x.py').write_text("open('x')", encoding='utf-8')
|
|
249
|
+
finding = audit(root)['findings'][0]
|
|
250
|
+
self.assertEqual(finding['severity'], 'HIGH')
|
|
251
|
+
self.assertEqual(len(finding['evidence']), 1)
|
|
252
|
+
|
|
253
|
+
def test_env_never_read(self):
|
|
254
|
+
original = Path.open
|
|
255
|
+
def guarded(path, *args, **kwargs):
|
|
256
|
+
if path.name == '.env':
|
|
257
|
+
raise AssertionError('.env opened')
|
|
258
|
+
return original(path, *args, **kwargs)
|
|
259
|
+
with patch.object(Path, 'open', guarded):
|
|
260
|
+
code, output, error = self.cli(FIXTURES / 'bad', '--json')
|
|
261
|
+
self.assertEqual(code, 2)
|
|
262
|
+
self.assertNotIn('ENCODING_AUDIT_ENV_MUST_NEVER_BE_READ_73491', output + error)
|
|
263
|
+
|
|
264
|
+
def test_none_encoding(self):
|
|
265
|
+
self.assertEqual(self.source("open('x', encoding=None)")[0]['kind'], 'wrong-encoding')
|
|
266
|
+
|
|
267
|
+
def test_no_claim_invalid_source(self):
|
|
268
|
+
self.assertEqual(audit(self.project('No promises.', 'broken('))['findings'], [])
|
|
269
|
+
|
|
270
|
+
def test_invalid_python(self):
|
|
271
|
+
self.assertEqual(self.cli(self.project(source='def broken('))[0], 1)
|
|
272
|
+
|
|
273
|
+
def test_module_entrypoint(self):
|
|
274
|
+
result = subprocess.run([sys.executable, '-m', 'encoding_claim_audit', '--version'], capture_output=True, text=True)
|
|
275
|
+
self.assertEqual((result.returncode, result.stdout.strip()), (0, '0.1.0'))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == '__main__':
|
|
279
|
+
unittest.main()
|