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.
Files changed (24) hide show
  1. encoding_claim_audit-0.1.0/LICENSE +21 -0
  2. encoding_claim_audit-0.1.0/MANIFEST.in +2 -0
  3. encoding_claim_audit-0.1.0/PKG-INFO +83 -0
  4. encoding_claim_audit-0.1.0/README.md +72 -0
  5. encoding_claim_audit-0.1.0/pyproject.toml +19 -0
  6. encoding_claim_audit-0.1.0/setup.cfg +4 -0
  7. encoding_claim_audit-0.1.0/src/encoding_claim_audit/__init__.py +2 -0
  8. encoding_claim_audit-0.1.0/src/encoding_claim_audit/__main__.py +3 -0
  9. encoding_claim_audit-0.1.0/src/encoding_claim_audit/audit.py +132 -0
  10. encoding_claim_audit-0.1.0/src/encoding_claim_audit/cli.py +46 -0
  11. encoding_claim_audit-0.1.0/src/encoding_claim_audit.egg-info/PKG-INFO +83 -0
  12. encoding_claim_audit-0.1.0/src/encoding_claim_audit.egg-info/SOURCES.txt +22 -0
  13. encoding_claim_audit-0.1.0/src/encoding_claim_audit.egg-info/dependency_links.txt +1 -0
  14. encoding_claim_audit-0.1.0/src/encoding_claim_audit.egg-info/entry_points.txt +2 -0
  15. encoding_claim_audit-0.1.0/src/encoding_claim_audit.egg-info/top_level.txt +1 -0
  16. encoding_claim_audit-0.1.0/tests/fixtures/bad/.env +1 -0
  17. encoding_claim_audit-0.1.0/tests/fixtures/bad/README.md +1 -0
  18. encoding_claim_audit-0.1.0/tests/fixtures/bad/app.py +3 -0
  19. encoding_claim_audit-0.1.0/tests/fixtures/good/README.md +1 -0
  20. encoding_claim_audit-0.1.0/tests/fixtures/good/app.py +8 -0
  21. encoding_claim_audit-0.1.0/tests/fixtures/testonly/README.md +1 -0
  22. encoding_claim_audit-0.1.0/tests/fixtures/testonly/app.py +1 -0
  23. encoding_claim_audit-0.1.0/tests/fixtures/testonly/tests/test_io.py +1 -0
  24. 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,2 @@
1
+ include LICENSE
2
+ recursive-include tests *.py *.md .env
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Audit documented Python text encoding claims."""
2
+ __version__ = '0.1.0'
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ encoding-claim-audit = encoding_claim_audit.cli:main
@@ -0,0 +1 @@
1
+ ENCODING_AUDIT_ENV_MUST_NEVER_BE_READ_73491
@@ -0,0 +1 @@
1
+ Supports UTF-8.
@@ -0,0 +1,3 @@
1
+ from pathlib import Path
2
+ open('text')
3
+ Path('text').write_text('hello', encoding='latin-1')
@@ -0,0 +1 @@
1
+ Supports UTF-8 and Unicode.
@@ -0,0 +1,8 @@
1
+ import codecs
2
+ from pathlib import Path
3
+ import io
4
+ open('text', encoding='utf-8')
5
+ codecs.open('text', 'r', 'utf8')
6
+ open('binary', 'rb')
7
+ Path('text').read_text(encoding='utf_8')
8
+ io.TextIOWrapper(None, encoding='UTF-8')
@@ -0,0 +1 @@
1
+ Supports UTF-8 and Unicode.
@@ -0,0 +1 @@
1
+ """No production file I/O."""
@@ -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()