atomic-claim-audit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 atomic-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
+ recursive-include tests *.py *.md
2
+ include LICENSE
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: atomic-claim-audit
3
+ Version: 0.1.0
4
+ Summary: Offline audit of documented atomic-write claims against Python source.
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # atomic-claim-audit
12
+
13
+ Offline audit of documented atomic-write claims against Python source.
14
+ Version 0.1.0; Python 3.9 or newer; MIT; no runtime dependencies.
15
+
16
+ The CLI reads documentation and parses Python ASTs. It never executes audited
17
+ code, uses no network, never reads `.env` file contents, and never writes to
18
+ the audited tree. Symlinked files and directories are skipped.
19
+
20
+ ## Usage
21
+
22
+ ```sh
23
+ python3 -m pip install .
24
+ atomic-claim-audit /path/to/project
25
+ python3 -m atomic_claim_audit /path/to/project --json
26
+ atomic-claim-audit . --ignore 'vendor/*' --ignore 'generated.py' --verbose
27
+ atomic-claim-audit tests/fixtures/testonly --fail-on HIGH
28
+ atomic-claim-audit --version
29
+ ```
30
+
31
+ The path defaults to the current directory. `--ignore` is repeatable and matches
32
+ case-sensitive relative-path globs. `--verbose` also shows verified families.
33
+ `--json` returns `findings`, `verified_claims`, and `errors` arrays; findings
34
+ include `rule`, `severity`, `family`, `claims`, and all `evidence` records
35
+ (`path`, `line`, `kind`). Verified records include `family` and `claims`.
36
+ Unreadable, invalid UTF-8, and syntactically invalid input files are reported
37
+ as errors, with exit 1, rather than silently treated as clean scans.
38
+
39
+ ## Rules
40
+
41
+ | Condition | Rule | Severity |
42
+ | --- | --- | --- |
43
+ | Claim and production direct writes | documented-atomic-claim-false | HIGH |
44
+ | Claim and no production Python source | documented-atomic-claim-false (`no-source`) | HIGH |
45
+ | Claim, production source, only test/fixture writes | documented-atomic-claim-false-test-only | MEDIUM |
46
+ | Claim and no direct-write evidence | verified atomic-write family | PASS |
47
+ | No claim | no finding, regardless of writes | PASS |
48
+
49
+ One finding contains every relevant evidence item; multiple phrases still form
50
+ one `atomic-write` family. Exit codes are 0 for PASS, 2 for findings at the
51
+ threshold, and 1 for usage/input errors. The default `--fail-on MEDIUM` includes
52
+ both severities. `--fail-on HIGH` retains MEDIUM findings but exits 0 for them.
53
+
54
+ Claims come only from root `README.md` and `docs/**/*.md`. Fenced code is
55
+ excluded; inline code counts. A sentence containing no, not, never, without,
56
+ doesn't, don't, or isn't suppresses every claim in that sentence.
57
+ The exact case-insensitive phrases are: atomic write(s), atomically writes,
58
+ atomically saves, atomically updates, atomic save(s), atomic update(s),
59
+ crash-safe write(s), crash safe write(s), all-or-nothing write(s), and atomic
60
+ file write(s). Parenthetical `(s)` here means the singular and plural forms.
61
+
62
+ Direct-write evidence consists of `open`, `io.open`, or any `.open` call with
63
+ a literal mode containing `w`, `a`, `x`, or `+`; any `.write_text` or
64
+ `.write_bytes` call; and `shutil.copy`, `shutil.copy2`, or `shutil.move`.
65
+ Production excludes tests/ and fixtures/ directories, test_*.py, *_test.py,
66
+ docs/, and examples/. Test/fixture evidence is collected separately; docs and
67
+ examples never contribute source evidence.
68
+
69
+ ## Differentiation
70
+
71
+ | Neighbour | Different question |
72
+ | --- | --- |
73
+ | readonly-claim-audit | Whether writes exist, never their atomicity |
74
+ | determinism-claim-audit | Run-to-run stability, never write safety |
75
+ | security-claim-audit | eval/shell/pickle hazards, never write patterns |
76
+ | bandit / ruff | Code issues, never documentation promises |
77
+ | atomicwrites / filelock | Runtime helpers that perform writes or locking; this tool statically audits promises |
78
+
79
+ ## Honest limitations
80
+
81
+ Only the exact listed phrases count: bare “atomic”, “atomic operations”,
82
+ “atomic config writes”, and “atomic JSON writes” do not. Sentence splitting
83
+ uses `.`, `!`, and `?`; this is a heuristic, not natural-language understanding.
84
+ Static evidence is not a crash-safety proof. Dead code counts. Receiver types,
85
+ import aliases, dynamic calls, and descriptor provenance are not inferred.
86
+ Variable modes and absent modes are skipped as UNKNOWN, never evidence.
87
+
88
+ `print(..., file=...)`, `os.replace`, `os.rename`, deletion and directory
89
+ operations (`remove`, `unlink`, `mkdir`, `rmdir`), and `os.write` are excluded.
90
+ Replace/rename are the atomic step themselves; descriptor writes are untracked.
91
+
92
+ Temp-var tracking is per-file and by variable name, without scope or order
93
+ analysis. Names assigned from `tempfile.NamedTemporaryFile`, `mkstemp`,
94
+ `TemporaryFile`, or `SpooledTemporaryFile` (including with bindings and tuple
95
+ unpacking) are tracked. A write whose target is such a name or an attribute
96
+ based on it is excluded. Other direct writes in the same function still count.
97
+ This can miss writes after reassignment or name reuse. No replace-step proof
98
+ is required: a temp write with no replace can leak a file, but does not corrupt
99
+ a final path, so this deliberate leniency is outside the corruption audit.
100
+ Filesystem durability, fsync, cross-filesystem rename, concurrency, and locking
101
+ are not proven. Copy/move destinations count unless they are tracked temp vars.
102
+
103
+ ## Development
104
+
105
+ ```sh
106
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
107
+ python3 -m build
108
+ ```
109
+
110
+ The build produces a wheel and sdist under `dist/`. Fixtures ship in the sdist.
111
+ If isolated builds are unavailable offline, use `python3 -m build --no-isolation`.
@@ -0,0 +1,101 @@
1
+ # atomic-claim-audit
2
+
3
+ Offline audit of documented atomic-write claims against Python source.
4
+ Version 0.1.0; Python 3.9 or newer; MIT; no runtime dependencies.
5
+
6
+ The CLI reads documentation and parses Python ASTs. It never executes audited
7
+ code, uses no network, never reads `.env` file contents, and never writes to
8
+ the audited tree. Symlinked files and directories are skipped.
9
+
10
+ ## Usage
11
+
12
+ ```sh
13
+ python3 -m pip install .
14
+ atomic-claim-audit /path/to/project
15
+ python3 -m atomic_claim_audit /path/to/project --json
16
+ atomic-claim-audit . --ignore 'vendor/*' --ignore 'generated.py' --verbose
17
+ atomic-claim-audit tests/fixtures/testonly --fail-on HIGH
18
+ atomic-claim-audit --version
19
+ ```
20
+
21
+ The path defaults to the current directory. `--ignore` is repeatable and matches
22
+ case-sensitive relative-path globs. `--verbose` also shows verified families.
23
+ `--json` returns `findings`, `verified_claims`, and `errors` arrays; findings
24
+ include `rule`, `severity`, `family`, `claims`, and all `evidence` records
25
+ (`path`, `line`, `kind`). Verified records include `family` and `claims`.
26
+ Unreadable, invalid UTF-8, and syntactically invalid input files are reported
27
+ as errors, with exit 1, rather than silently treated as clean scans.
28
+
29
+ ## Rules
30
+
31
+ | Condition | Rule | Severity |
32
+ | --- | --- | --- |
33
+ | Claim and production direct writes | documented-atomic-claim-false | HIGH |
34
+ | Claim and no production Python source | documented-atomic-claim-false (`no-source`) | HIGH |
35
+ | Claim, production source, only test/fixture writes | documented-atomic-claim-false-test-only | MEDIUM |
36
+ | Claim and no direct-write evidence | verified atomic-write family | PASS |
37
+ | No claim | no finding, regardless of writes | PASS |
38
+
39
+ One finding contains every relevant evidence item; multiple phrases still form
40
+ one `atomic-write` family. Exit codes are 0 for PASS, 2 for findings at the
41
+ threshold, and 1 for usage/input errors. The default `--fail-on MEDIUM` includes
42
+ both severities. `--fail-on HIGH` retains MEDIUM findings but exits 0 for them.
43
+
44
+ Claims come only from root `README.md` and `docs/**/*.md`. Fenced code is
45
+ excluded; inline code counts. A sentence containing no, not, never, without,
46
+ doesn't, don't, or isn't suppresses every claim in that sentence.
47
+ The exact case-insensitive phrases are: atomic write(s), atomically writes,
48
+ atomically saves, atomically updates, atomic save(s), atomic update(s),
49
+ crash-safe write(s), crash safe write(s), all-or-nothing write(s), and atomic
50
+ file write(s). Parenthetical `(s)` here means the singular and plural forms.
51
+
52
+ Direct-write evidence consists of `open`, `io.open`, or any `.open` call with
53
+ a literal mode containing `w`, `a`, `x`, or `+`; any `.write_text` or
54
+ `.write_bytes` call; and `shutil.copy`, `shutil.copy2`, or `shutil.move`.
55
+ Production excludes tests/ and fixtures/ directories, test_*.py, *_test.py,
56
+ docs/, and examples/. Test/fixture evidence is collected separately; docs and
57
+ examples never contribute source evidence.
58
+
59
+ ## Differentiation
60
+
61
+ | Neighbour | Different question |
62
+ | --- | --- |
63
+ | readonly-claim-audit | Whether writes exist, never their atomicity |
64
+ | determinism-claim-audit | Run-to-run stability, never write safety |
65
+ | security-claim-audit | eval/shell/pickle hazards, never write patterns |
66
+ | bandit / ruff | Code issues, never documentation promises |
67
+ | atomicwrites / filelock | Runtime helpers that perform writes or locking; this tool statically audits promises |
68
+
69
+ ## Honest limitations
70
+
71
+ Only the exact listed phrases count: bare “atomic”, “atomic operations”,
72
+ “atomic config writes”, and “atomic JSON writes” do not. Sentence splitting
73
+ uses `.`, `!`, and `?`; this is a heuristic, not natural-language understanding.
74
+ Static evidence is not a crash-safety proof. Dead code counts. Receiver types,
75
+ import aliases, dynamic calls, and descriptor provenance are not inferred.
76
+ Variable modes and absent modes are skipped as UNKNOWN, never evidence.
77
+
78
+ `print(..., file=...)`, `os.replace`, `os.rename`, deletion and directory
79
+ operations (`remove`, `unlink`, `mkdir`, `rmdir`), and `os.write` are excluded.
80
+ Replace/rename are the atomic step themselves; descriptor writes are untracked.
81
+
82
+ Temp-var tracking is per-file and by variable name, without scope or order
83
+ analysis. Names assigned from `tempfile.NamedTemporaryFile`, `mkstemp`,
84
+ `TemporaryFile`, or `SpooledTemporaryFile` (including with bindings and tuple
85
+ unpacking) are tracked. A write whose target is such a name or an attribute
86
+ based on it is excluded. Other direct writes in the same function still count.
87
+ This can miss writes after reassignment or name reuse. No replace-step proof
88
+ is required: a temp write with no replace can leak a file, but does not corrupt
89
+ a final path, so this deliberate leniency is outside the corruption audit.
90
+ Filesystem durability, fsync, cross-filesystem rename, concurrency, and locking
91
+ are not proven. Copy/move destinations count unless they are tracked temp vars.
92
+
93
+ ## Development
94
+
95
+ ```sh
96
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
97
+ python3 -m build
98
+ ```
99
+
100
+ The build produces a wheel and sdist under `dist/`. Fixtures ship in the sdist.
101
+ If isolated builds are unavailable offline, use `python3 -m build --no-isolation`.
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "atomic-claim-audit"
7
+ version = "0.1.0"
8
+ description = "Offline audit of documented atomic-write claims against Python source."
9
+ requires-python = ">=3.9"
10
+ license = {text = "MIT"}
11
+ readme = "README.md"
12
+ dependencies = []
13
+
14
+ [project.scripts]
15
+ atomic-claim-audit = "atomic_claim_audit.__main__:main"
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Audit documented atomic-write claims against static Python evidence."""
2
+ __version__ = '0.1.0'
@@ -0,0 +1,50 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from . import __version__
5
+ from .audit import audit
6
+
7
+
8
+ class Parser(argparse.ArgumentParser):
9
+ def error(self, message):
10
+ self.print_usage(sys.stderr)
11
+ self.exit(1, '%s: error: %s\n' % (self.prog, message))
12
+
13
+
14
+ def main(argv=None):
15
+ parser = Parser(description='Offline audit of documented atomic-write claims.')
16
+ parser.add_argument('path', nargs='?', default='.')
17
+ parser.add_argument('--json', action='store_true')
18
+ parser.add_argument('--verbose', action='store_true')
19
+ parser.add_argument('--ignore', action='append', default=[], metavar='GLOB')
20
+ parser.add_argument('--fail-on', choices=['MEDIUM', 'HIGH'], default='MEDIUM')
21
+ parser.add_argument('--version', action='version', version=__version__)
22
+ args = parser.parse_args(argv)
23
+ try:
24
+ report = audit(args.path, args.ignore)
25
+ except (ValueError, OSError) as exc:
26
+ parser.error(str(exc))
27
+ if report['errors']:
28
+ if args.json:
29
+ print(json.dumps(report, indent=2))
30
+ else:
31
+ for error in report['errors']:
32
+ print('%s: %s' % (error['path'], error['message']), file=sys.stderr)
33
+ return 1
34
+ failed = any(f['severity'] == 'HIGH' or args.fail_on == 'MEDIUM' for f in report['findings'])
35
+ if args.json:
36
+ print(json.dumps(report, indent=2))
37
+ else:
38
+ print('FAIL' if failed else 'PASS')
39
+ for finding in report['findings']:
40
+ print('%s %s' % (finding['severity'], finding['rule']))
41
+ for item in finding['evidence']:
42
+ print(' %s:%s %s' % (item['path'], item['line'], item['kind']))
43
+ if args.verbose:
44
+ for claim in report['verified_claims']:
45
+ print('Verified: ' + claim['family'])
46
+ return 2 if failed else 0
47
+
48
+
49
+ if __name__ == '__main__':
50
+ sys.exit(main())
@@ -0,0 +1,147 @@
1
+ """Offline static audit of documented atomic-write promises."""
2
+ import ast
3
+ import fnmatch
4
+ import re
5
+ from pathlib import Path
6
+
7
+ PHRASES = (
8
+ 'atomic write', 'atomic writes', 'atomically writes', 'atomically saves',
9
+ 'atomically updates', 'atomic save', 'atomic saves', 'atomic update',
10
+ 'atomic updates', 'crash-safe write', 'crash-safe writes', 'crash safe write',
11
+ 'crash safe writes', 'all-or-nothing write', 'all-or-nothing writes',
12
+ 'atomic file write', 'atomic file writes',
13
+ )
14
+ CLAIM = re.compile(r'\b(?:' + '|'.join(sorted(PHRASES, key=len, reverse=True)) + r')\b', re.I)
15
+ NEGATION = re.compile(r"\b(?:no|not|never|without|doesn't|don't|isn't)\b", re.I)
16
+ TEMP_CALLS = {'tempfile.NamedTemporaryFile', 'tempfile.mkstemp',
17
+ 'tempfile.TemporaryFile', 'tempfile.SpooledTemporaryFile'}
18
+
19
+
20
+ def dotted(node):
21
+ if isinstance(node, ast.Name):
22
+ return node.id
23
+ if isinstance(node, ast.Attribute):
24
+ return dotted(node.value) + '.' + node.attr
25
+ return ''
26
+
27
+
28
+ def base_name(node):
29
+ while isinstance(node, ast.Attribute):
30
+ node = node.value
31
+ return node.id if isinstance(node, ast.Name) else None
32
+
33
+
34
+ def claims_in(text, path):
35
+ """Extract one record per claiming sentence, preserving source lines."""
36
+ lines = []
37
+ fence = None
38
+ for line in text.splitlines():
39
+ marker = re.match(r'^\s{0,3}(`{3,}|~{3,})', line)
40
+ if fence:
41
+ if marker and marker.group(1)[0] == fence[0] and len(marker.group(1)) >= len(fence):
42
+ fence = None
43
+ lines.append('')
44
+ elif marker:
45
+ fence = marker.group(1)
46
+ lines.append('')
47
+ else:
48
+ lines.append(line)
49
+ prose = '\n'.join(lines)
50
+ result = []
51
+ for match in re.finditer(r'[^.!?]+(?:[.!?]|$)', prose):
52
+ sentence = match.group()
53
+ phrase = CLAIM.search(sentence)
54
+ if phrase and not NEGATION.search(sentence):
55
+ offset = match.start() + phrase.start()
56
+ result.append({'family': 'atomic-write', 'path': path,
57
+ 'line': prose.count('\n', 0, offset) + 1,
58
+ 'phrase': phrase.group()})
59
+ return result
60
+
61
+
62
+ def evidence_in(text, path):
63
+ tree = ast.parse(text, filename=path)
64
+ temps = set()
65
+ for node in ast.walk(tree):
66
+ value = getattr(node, 'value', None)
67
+ if isinstance(value, ast.Call) and dotted(value.func) in TEMP_CALLS:
68
+ targets = node.targets if isinstance(node, ast.Assign) else [getattr(node, 'target', None)]
69
+ for target in targets:
70
+ if target:
71
+ temps.update(n.id for n in ast.walk(target) if isinstance(n, ast.Name))
72
+ if isinstance(node, ast.withitem) and isinstance(node.context_expr, ast.Call):
73
+ if dotted(node.context_expr.func) in TEMP_CALLS and node.optional_vars:
74
+ temps.update(n.id for n in ast.walk(node.optional_vars) if isinstance(n, ast.Name))
75
+ found = {}
76
+ for node in ast.walk(tree):
77
+ if not isinstance(node, ast.Call):
78
+ continue
79
+ name = dotted(node.func)
80
+ attr = node.func.attr if isinstance(node.func, ast.Attribute) else ''
81
+ target = None
82
+ kind = None
83
+ if name in {'shutil.copy', 'shutil.copy2', 'shutil.move'}:
84
+ kind = name
85
+ target = node.args[1] if len(node.args) > 1 else next((k.value for k in node.keywords if k.arg == 'dst'), None)
86
+ elif attr in {'write_text', 'write_bytes'}:
87
+ kind = attr
88
+ target = node.func.value
89
+ elif name in {'open', 'io.open'} or attr == 'open':
90
+ builtin = name in {'open', 'io.open'}
91
+ index = 1 if builtin else 0
92
+ mode = node.args[index] if len(node.args) > index else next((k.value for k in node.keywords if k.arg == 'mode'), None)
93
+ if isinstance(mode, ast.Constant) and isinstance(mode.value, str) and any(c in mode.value for c in 'wax+'):
94
+ kind = name if builtin else 'Path.open'
95
+ target = (node.args[0] if node.args else next((k.value for k in node.keywords if k.arg == 'file'), None)) if builtin else node.func.value
96
+ if kind and base_name(target) not in temps:
97
+ found[(node.lineno, kind)] = {'path': path, 'line': node.lineno, 'kind': kind}
98
+ return sorted(found.values(), key=lambda e: (e['line'], e['kind']))
99
+
100
+
101
+ def audit(root, ignore=()):
102
+ root = Path(root)
103
+ if not root.is_dir():
104
+ raise ValueError('audit path must be a directory')
105
+ claims, production, testonly, errors = [], [], [], []
106
+ source_count = 0
107
+ for path in sorted(root.rglob('*')):
108
+ relative = path.relative_to(root).as_posix()
109
+ if path.is_symlink() or any(p.is_symlink() for p in path.parents if p != root.parent):
110
+ continue
111
+ if not path.is_file() or path.name == '.env' or path.name.startswith('.env.'):
112
+ continue
113
+ if any(fnmatch.fnmatchcase(relative, pattern) for pattern in ignore):
114
+ continue
115
+ parts = path.relative_to(root).parts
116
+ doc = relative == 'README.md' or (parts[0] == 'docs' and path.suffix == '.md')
117
+ source = path.suffix == '.py' and not any(p in {'docs', 'examples'} for p in parts[:-1])
118
+ if not doc and not source:
119
+ continue
120
+ is_test = any(p in {'tests', 'fixtures'} for p in parts[:-1]) or path.name.startswith('test_') or path.name.endswith('_test.py')
121
+ if source and not is_test:
122
+ source_count += 1
123
+ try:
124
+ text = path.read_text(encoding='utf-8')
125
+ if doc:
126
+ claims.extend(claims_in(text, relative))
127
+ if source:
128
+ (testonly if is_test else production).extend(evidence_in(text, relative))
129
+ except (OSError, UnicodeError, SyntaxError) as exc:
130
+ errors.append({'path': relative, 'message': str(exc)})
131
+ findings, verified = [], []
132
+ if claims:
133
+ evidence = production
134
+ severity = 'HIGH'
135
+ rule = 'documented-atomic-claim-false'
136
+ if not source_count:
137
+ evidence = [{'path': '.', 'line': 0, 'kind': 'no-source'}]
138
+ elif not production and testonly:
139
+ evidence = testonly
140
+ severity = 'MEDIUM'
141
+ rule += '-test-only'
142
+ if evidence:
143
+ findings.append({'rule': rule, 'severity': severity, 'family': 'atomic-write',
144
+ 'claims': claims, 'evidence': evidence})
145
+ else:
146
+ verified = [{'family': 'atomic-write', 'claims': claims}]
147
+ return {'findings': findings, 'verified_claims': verified, 'errors': errors}
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: atomic-claim-audit
3
+ Version: 0.1.0
4
+ Summary: Offline audit of documented atomic-write claims against Python source.
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # atomic-claim-audit
12
+
13
+ Offline audit of documented atomic-write claims against Python source.
14
+ Version 0.1.0; Python 3.9 or newer; MIT; no runtime dependencies.
15
+
16
+ The CLI reads documentation and parses Python ASTs. It never executes audited
17
+ code, uses no network, never reads `.env` file contents, and never writes to
18
+ the audited tree. Symlinked files and directories are skipped.
19
+
20
+ ## Usage
21
+
22
+ ```sh
23
+ python3 -m pip install .
24
+ atomic-claim-audit /path/to/project
25
+ python3 -m atomic_claim_audit /path/to/project --json
26
+ atomic-claim-audit . --ignore 'vendor/*' --ignore 'generated.py' --verbose
27
+ atomic-claim-audit tests/fixtures/testonly --fail-on HIGH
28
+ atomic-claim-audit --version
29
+ ```
30
+
31
+ The path defaults to the current directory. `--ignore` is repeatable and matches
32
+ case-sensitive relative-path globs. `--verbose` also shows verified families.
33
+ `--json` returns `findings`, `verified_claims`, and `errors` arrays; findings
34
+ include `rule`, `severity`, `family`, `claims`, and all `evidence` records
35
+ (`path`, `line`, `kind`). Verified records include `family` and `claims`.
36
+ Unreadable, invalid UTF-8, and syntactically invalid input files are reported
37
+ as errors, with exit 1, rather than silently treated as clean scans.
38
+
39
+ ## Rules
40
+
41
+ | Condition | Rule | Severity |
42
+ | --- | --- | --- |
43
+ | Claim and production direct writes | documented-atomic-claim-false | HIGH |
44
+ | Claim and no production Python source | documented-atomic-claim-false (`no-source`) | HIGH |
45
+ | Claim, production source, only test/fixture writes | documented-atomic-claim-false-test-only | MEDIUM |
46
+ | Claim and no direct-write evidence | verified atomic-write family | PASS |
47
+ | No claim | no finding, regardless of writes | PASS |
48
+
49
+ One finding contains every relevant evidence item; multiple phrases still form
50
+ one `atomic-write` family. Exit codes are 0 for PASS, 2 for findings at the
51
+ threshold, and 1 for usage/input errors. The default `--fail-on MEDIUM` includes
52
+ both severities. `--fail-on HIGH` retains MEDIUM findings but exits 0 for them.
53
+
54
+ Claims come only from root `README.md` and `docs/**/*.md`. Fenced code is
55
+ excluded; inline code counts. A sentence containing no, not, never, without,
56
+ doesn't, don't, or isn't suppresses every claim in that sentence.
57
+ The exact case-insensitive phrases are: atomic write(s), atomically writes,
58
+ atomically saves, atomically updates, atomic save(s), atomic update(s),
59
+ crash-safe write(s), crash safe write(s), all-or-nothing write(s), and atomic
60
+ file write(s). Parenthetical `(s)` here means the singular and plural forms.
61
+
62
+ Direct-write evidence consists of `open`, `io.open`, or any `.open` call with
63
+ a literal mode containing `w`, `a`, `x`, or `+`; any `.write_text` or
64
+ `.write_bytes` call; and `shutil.copy`, `shutil.copy2`, or `shutil.move`.
65
+ Production excludes tests/ and fixtures/ directories, test_*.py, *_test.py,
66
+ docs/, and examples/. Test/fixture evidence is collected separately; docs and
67
+ examples never contribute source evidence.
68
+
69
+ ## Differentiation
70
+
71
+ | Neighbour | Different question |
72
+ | --- | --- |
73
+ | readonly-claim-audit | Whether writes exist, never their atomicity |
74
+ | determinism-claim-audit | Run-to-run stability, never write safety |
75
+ | security-claim-audit | eval/shell/pickle hazards, never write patterns |
76
+ | bandit / ruff | Code issues, never documentation promises |
77
+ | atomicwrites / filelock | Runtime helpers that perform writes or locking; this tool statically audits promises |
78
+
79
+ ## Honest limitations
80
+
81
+ Only the exact listed phrases count: bare “atomic”, “atomic operations”,
82
+ “atomic config writes”, and “atomic JSON writes” do not. Sentence splitting
83
+ uses `.`, `!`, and `?`; this is a heuristic, not natural-language understanding.
84
+ Static evidence is not a crash-safety proof. Dead code counts. Receiver types,
85
+ import aliases, dynamic calls, and descriptor provenance are not inferred.
86
+ Variable modes and absent modes are skipped as UNKNOWN, never evidence.
87
+
88
+ `print(..., file=...)`, `os.replace`, `os.rename`, deletion and directory
89
+ operations (`remove`, `unlink`, `mkdir`, `rmdir`), and `os.write` are excluded.
90
+ Replace/rename are the atomic step themselves; descriptor writes are untracked.
91
+
92
+ Temp-var tracking is per-file and by variable name, without scope or order
93
+ analysis. Names assigned from `tempfile.NamedTemporaryFile`, `mkstemp`,
94
+ `TemporaryFile`, or `SpooledTemporaryFile` (including with bindings and tuple
95
+ unpacking) are tracked. A write whose target is such a name or an attribute
96
+ based on it is excluded. Other direct writes in the same function still count.
97
+ This can miss writes after reassignment or name reuse. No replace-step proof
98
+ is required: a temp write with no replace can leak a file, but does not corrupt
99
+ a final path, so this deliberate leniency is outside the corruption audit.
100
+ Filesystem durability, fsync, cross-filesystem rename, concurrency, and locking
101
+ are not proven. Copy/move destinations count unless they are tracked temp vars.
102
+
103
+ ## Development
104
+
105
+ ```sh
106
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
107
+ python3 -m build
108
+ ```
109
+
110
+ The build produces a wheel and sdist under `dist/`. Fixtures ship in the sdist.
111
+ If isolated builds are unavailable offline, use `python3 -m build --no-isolation`.
@@ -0,0 +1,20 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ src/atomic_claim_audit/__init__.py
6
+ src/atomic_claim_audit/__main__.py
7
+ src/atomic_claim_audit/audit.py
8
+ src/atomic_claim_audit.egg-info/PKG-INFO
9
+ src/atomic_claim_audit.egg-info/SOURCES.txt
10
+ src/atomic_claim_audit.egg-info/dependency_links.txt
11
+ src/atomic_claim_audit.egg-info/entry_points.txt
12
+ src/atomic_claim_audit.egg-info/top_level.txt
13
+ tests/test_audit.py
14
+ tests/fixtures/bad/README.md
15
+ tests/fixtures/bad/main.py
16
+ tests/fixtures/good/README.md
17
+ tests/fixtures/good/main.py
18
+ tests/fixtures/testonly/README.md
19
+ tests/fixtures/testonly/main.py
20
+ tests/fixtures/testonly/tests/test_write.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ atomic-claim-audit = atomic_claim_audit.__main__:main
@@ -0,0 +1 @@
1
+ atomic_claim_audit
@@ -0,0 +1 @@
1
+ This tool provides atomic writes and crash-safe writes.
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+ import shutil
3
+
4
+
5
+ def save(path, source):
6
+ Path(path).write_text('data')
7
+ with open(path, 'w') as stream:
8
+ stream.write('data')
9
+ shutil.copy2(source, path)
@@ -0,0 +1 @@
1
+ This tool provides atomic writes.
@@ -0,0 +1,8 @@
1
+ import os
2
+ import tempfile
3
+
4
+
5
+ def save(path, text):
6
+ with tempfile.NamedTemporaryFile(mode='w', delete=False) as temp:
7
+ temp.write(text)
8
+ os.replace(temp.name, path)
@@ -0,0 +1 @@
1
+ This tool provides atomic writes.
@@ -0,0 +1,2 @@
1
+ def value():
2
+ return 42
@@ -0,0 +1,3 @@
1
+ def write_example(path):
2
+ with open(path, 'w') as stream:
3
+ stream.write('data')
@@ -0,0 +1,192 @@
1
+ import contextlib
2
+ import io
3
+ import json
4
+ from pathlib import Path
5
+ import tempfile
6
+ import unittest
7
+
8
+ from atomic_claim_audit.audit import PHRASES, audit, claims_in, evidence_in
9
+ from atomic_claim_audit.__main__ import main
10
+
11
+ FIXTURES = Path(__file__).parent / 'fixtures'
12
+
13
+
14
+ class AuditTests(unittest.TestCase):
15
+ def project(self, files, ignore=()):
16
+ with tempfile.TemporaryDirectory() as directory:
17
+ root = Path(directory)
18
+ for name, text in files.items():
19
+ path = root / name
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+ path.write_text(text, encoding='utf-8')
22
+ return audit(root, ignore)
23
+
24
+ def test_good(self):
25
+ report = audit(FIXTURES / 'good')
26
+ self.assertEqual(report['findings'], [])
27
+ self.assertEqual(report['verified_claims'][0]['family'], 'atomic-write')
28
+
29
+ def test_bad(self):
30
+ findings = audit(FIXTURES / 'bad')['findings']
31
+ self.assertEqual(len(findings), 1)
32
+ self.assertEqual(findings[0]['rule'], 'documented-atomic-claim-false')
33
+ self.assertEqual(findings[0]['severity'], 'HIGH')
34
+ self.assertEqual(len(findings[0]['evidence']), 3)
35
+
36
+ def test_testonly(self):
37
+ finding = audit(FIXTURES / 'testonly')['findings'][0]
38
+ self.assertEqual(finding['severity'], 'MEDIUM')
39
+ self.assertEqual(finding['rule'], 'documented-atomic-claim-false-test-only')
40
+ self.assertEqual(len(finding['evidence']), 1)
41
+
42
+ def test_no_source(self):
43
+ r = self.project({'README.md': 'Atomic writes.'})
44
+ self.assertEqual(r['findings'][0]['evidence'][0]['kind'], 'no-source')
45
+
46
+ def test_no_claim(self):
47
+ self.assertEqual(self.project({'main.py': 'open(p,"w")'})['findings'], [])
48
+
49
+ def test_docs_claim(self):
50
+ r = self.project({'docs/sub/a.md': 'Atomic writes.', 'main.py': 'open(p,"w")'})
51
+ self.assertEqual(len(r['findings']), 1)
52
+
53
+ def test_other_docs_ignored(self):
54
+ r = self.project({'other.md': 'Atomic writes.', 'main.py': 'open(p,"w")'})
55
+ self.assertEqual(r['findings'], [])
56
+
57
+ def test_ignore(self):
58
+ r = self.project({'README.md': 'Atomic writes.', 'main.py': 'pass', 'bad.py': 'open(p,"w")'}, ['bad.py'])
59
+ self.assertTrue(r['verified_claims'])
60
+
61
+ def test_ignore_glob(self):
62
+ r = self.project({'README.md': 'Atomic writes.', 'main.py': 'pass', 'vendor/a.py': 'open(p,"w")'}, ['vendor/*'])
63
+ self.assertTrue(r['verified_claims'])
64
+
65
+ def test_temp_and_direct(self):
66
+ text = 't = tempfile.NamedTemporaryFile()\nopen(t.name,"w")\nopen(final,"w")'
67
+ self.assertEqual(len(evidence_in(text, 'a.py')), 1)
68
+
69
+ def test_temp_no_replace(self):
70
+ text = 't = tempfile.NamedTemporaryFile()\nt.write_text("data")'
71
+ self.assertEqual(evidence_in(text, 'a.py'), [])
72
+
73
+ def test_dead_code(self):
74
+ self.assertEqual(len(evidence_in('if False:\n open(p,"w")', 'a.py')), 1)
75
+
76
+ def test_dedupe(self):
77
+ self.assertEqual(len(evidence_in('open(p,"w"); open(q,"w")', 'a.py')), 1)
78
+
79
+ def test_invalid_source(self):
80
+ r = self.project({'main.py': 'def ('})
81
+ self.assertEqual(len(r['errors']), 1)
82
+
83
+ def test_env_not_read(self):
84
+ r = self.project({'.env': 'secret', '.env.py': 'invalid syntax!', 'main.py': 'pass'})
85
+ self.assertEqual(r['errors'], [])
86
+
87
+ def test_symlink(self):
88
+ with tempfile.TemporaryDirectory() as d:
89
+ root = Path(d)
90
+ (root / 'README.md').write_text('Atomic writes.')
91
+ (root / 'main.py').write_text('pass')
92
+ (root / 'link.py').symlink_to(FIXTURES / 'bad' / 'main.py')
93
+ (root / 'linked').symlink_to(FIXTURES / 'bad', target_is_directory=True)
94
+ self.assertTrue(audit(root)['verified_claims'])
95
+
96
+ def test_json(self):
97
+ output = io.StringIO()
98
+ with contextlib.redirect_stdout(output):
99
+ code = main([str(FIXTURES / 'good'), '--json'])
100
+ self.assertEqual(code, 0)
101
+ self.assertEqual(set(json.loads(output.getvalue())), {'findings', 'verified_claims', 'errors'})
102
+
103
+ def test_threshold(self):
104
+ with contextlib.redirect_stdout(io.StringIO()):
105
+ self.assertEqual(main([str(FIXTURES / 'testonly')]), 2)
106
+ self.assertEqual(main([str(FIXTURES / 'testonly'), '--fail-on', 'HIGH']), 0)
107
+ self.assertEqual(main([str(FIXTURES / 'bad'), '--fail-on', 'HIGH']), 2)
108
+
109
+ def test_version(self):
110
+ output = io.StringIO()
111
+ with contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as exc:
112
+ main(['--version'])
113
+ self.assertEqual(exc.exception.code, 0)
114
+ self.assertEqual(output.getvalue().strip(), '0.1.0')
115
+
116
+ def test_usage(self):
117
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as exc:
118
+ main(['--unknown'])
119
+ self.assertEqual(exc.exception.code, 1)
120
+
121
+ def test_missing_directory(self):
122
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as exc:
123
+ main(['/missing-atomic-claim-audit'])
124
+ self.assertEqual(exc.exception.code, 1)
125
+
126
+ def test_verbose(self):
127
+ output = io.StringIO()
128
+ with contextlib.redirect_stdout(output):
129
+ main([str(FIXTURES / 'good'), '--verbose'])
130
+ self.assertIn('Verified: atomic-write', output.getvalue())
131
+
132
+
133
+ def claim_case(text, expected):
134
+ def test(self):
135
+ self.assertEqual(bool(claims_in(text, 'README.md')), expected)
136
+ return test
137
+
138
+
139
+ for index, phrase in enumerate(PHRASES):
140
+ setattr(AuditTests, 'test_phrase_%02d' % index, claim_case('Uses ' + phrase.upper() + '.', True))
141
+ for index, text in enumerate([
142
+ 'Atomic operations.', 'Atomic counters.', 'Atomic config writes.',
143
+ 'Atomic JSON writes.', 'Atomically reads.', 'Nonatomic writes.',
144
+ '```md\nAtomic writes.\n```', '~~~\nAtomic writes.\n~~~',
145
+ 'No atomic writes.', 'Not atomic writes.', 'Never atomic writes.',
146
+ 'Without atomic writes.', "It doesn't support atomic writes.",
147
+ "Don't promise atomic writes.", "It isn't atomic writes.",
148
+ ]):
149
+ setattr(AuditTests, 'test_nonclaim_%02d' % index, claim_case(text, False))
150
+ setattr(AuditTests, 'test_inline_claim', claim_case('Supports `atomic writes`.', True))
151
+ setattr(AuditTests, 'test_sentence_negation', claim_case('No guarantees. Atomic writes.', True))
152
+
153
+
154
+ def evidence_case(source, count):
155
+ def test(self):
156
+ self.assertEqual(len(evidence_in(source, 'main.py')), count)
157
+ return test
158
+
159
+
160
+ for index, (source, count) in enumerate([
161
+ ('open(p,"w")', 1), ('open(p,"wb")', 1), ('open(p,"a+")', 1),
162
+ ('open(p,"x")', 1), ('open(p,"r+")', 1), ('open(p,"r")', 0),
163
+ ('open(p)', 0), ('open(p,mode)', 0), ('io.open(p,mode="w")', 1),
164
+ ('open(file=p,mode="w")', 1), ('p.open("wb")', 1), ('p.open(mode="a")', 1),
165
+ ('p.open()', 0), ('p.open(mode=m)', 0), ('p.write_text("x")', 1),
166
+ ('Path(p).write_bytes(b"x")', 1), ('shutil.copy(a,b)', 1),
167
+ ('shutil.copy2(a,b)', 1), ('shutil.move(a,b)', 1),
168
+ ('os.replace(a,b)', 0), ('os.rename(a,b)', 0), ('print("x",file=f)', 0),
169
+ ('os.write(fd,b"x")', 0), ('p.unlink()', 0), ('p.mkdir()', 0),
170
+ ('t=tempfile.NamedTemporaryFile()\nopen(t.name,"w")', 0),
171
+ ('fd,t=tempfile.mkstemp()\nopen(t,"wb")', 0),
172
+ ('t=tempfile.TemporaryFile()\nt.write_bytes(b"x")', 0),
173
+ ('t=tempfile.SpooledTemporaryFile()\nt.open("w")', 0),
174
+ ('with tempfile.NamedTemporaryFile() as t:\n open(t.name,"w")', 0),
175
+ ('t=tempfile.NamedTemporaryFile()\nshutil.copy2(a,t.name)', 0),
176
+ ('def f():\n t=tempfile.NamedTemporaryFile()\n open(t.name,"w")\n os.replace(t.name,p)\n p.write_text("x")', 1),
177
+ ]):
178
+ setattr(AuditTests, 'test_evidence_%02d' % index, evidence_case(source, count))
179
+
180
+ for index, path in enumerate(['tests/a.py', 'fixtures/a.py', 'test_a.py', 'a_test.py']):
181
+ def test(self, path=path):
182
+ r = self.project({'README.md': 'Atomic writes.', 'main.py': 'pass', path: 'open(p,"w")'})
183
+ self.assertEqual(r['findings'][0]['severity'], 'MEDIUM')
184
+ setattr(AuditTests, 'test_test_path_%02d' % index, test)
185
+ for index, path in enumerate(['docs/a.py', 'examples/a.py']):
186
+ def test(self, path=path):
187
+ r = self.project({'README.md': 'Atomic writes.', 'main.py': 'pass', path: 'open(p,"w")'})
188
+ self.assertTrue(r['verified_claims'])
189
+ setattr(AuditTests, 'test_excluded_path_%02d' % index, test)
190
+
191
+ if __name__ == '__main__':
192
+ unittest.main()