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.
- atomic_claim_audit-0.1.0/LICENSE +21 -0
- atomic_claim_audit-0.1.0/MANIFEST.in +2 -0
- atomic_claim_audit-0.1.0/PKG-INFO +111 -0
- atomic_claim_audit-0.1.0/README.md +101 -0
- atomic_claim_audit-0.1.0/pyproject.toml +18 -0
- atomic_claim_audit-0.1.0/setup.cfg +4 -0
- atomic_claim_audit-0.1.0/src/atomic_claim_audit/__init__.py +2 -0
- atomic_claim_audit-0.1.0/src/atomic_claim_audit/__main__.py +50 -0
- atomic_claim_audit-0.1.0/src/atomic_claim_audit/audit.py +147 -0
- atomic_claim_audit-0.1.0/src/atomic_claim_audit.egg-info/PKG-INFO +111 -0
- atomic_claim_audit-0.1.0/src/atomic_claim_audit.egg-info/SOURCES.txt +20 -0
- atomic_claim_audit-0.1.0/src/atomic_claim_audit.egg-info/dependency_links.txt +1 -0
- atomic_claim_audit-0.1.0/src/atomic_claim_audit.egg-info/entry_points.txt +2 -0
- atomic_claim_audit-0.1.0/src/atomic_claim_audit.egg-info/top_level.txt +1 -0
- atomic_claim_audit-0.1.0/tests/fixtures/bad/README.md +1 -0
- atomic_claim_audit-0.1.0/tests/fixtures/bad/main.py +9 -0
- atomic_claim_audit-0.1.0/tests/fixtures/good/README.md +1 -0
- atomic_claim_audit-0.1.0/tests/fixtures/good/main.py +8 -0
- atomic_claim_audit-0.1.0/tests/fixtures/testonly/README.md +1 -0
- atomic_claim_audit-0.1.0/tests/fixtures/testonly/main.py +2 -0
- atomic_claim_audit-0.1.0/tests/fixtures/testonly/tests/test_write.py +3 -0
- atomic_claim_audit-0.1.0/tests/test_audit.py +192 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
atomic_claim_audit
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This tool provides atomic writes and crash-safe writes.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This tool provides atomic writes.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This tool provides atomic writes.
|
|
@@ -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()
|