thread-safety-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.
- thread_safety_claim_audit-0.1.0/LICENSE +21 -0
- thread_safety_claim_audit-0.1.0/MANIFEST.in +3 -0
- thread_safety_claim_audit-0.1.0/PKG-INFO +173 -0
- thread_safety_claim_audit-0.1.0/README.md +163 -0
- thread_safety_claim_audit-0.1.0/pyproject.toml +18 -0
- thread_safety_claim_audit-0.1.0/setup.cfg +4 -0
- thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit/__init__.py +2 -0
- thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit/__main__.py +4 -0
- thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit/audit.py +208 -0
- thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit.egg-info/PKG-INFO +173 -0
- thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit.egg-info/SOURCES.txt +24 -0
- thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit.egg-info/dependency_links.txt +1 -0
- thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit.egg-info/entry_points.txt +2 -0
- thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit.egg-info/top_level.txt +1 -0
- thread_safety_claim_audit-0.1.0/tests/fixtures/bad/notsafe/README.md +1 -0
- thread_safety_claim_audit-0.1.0/tests/fixtures/bad/notsafe/main.py +4 -0
- thread_safety_claim_audit-0.1.0/tests/fixtures/bad/threadsafe/README.md +1 -0
- thread_safety_claim_audit-0.1.0/tests/fixtures/bad/threadsafe/main.py +3 -0
- thread_safety_claim_audit-0.1.0/tests/fixtures/good/notsafe/README.md +1 -0
- thread_safety_claim_audit-0.1.0/tests/fixtures/good/notsafe/main.py +3 -0
- thread_safety_claim_audit-0.1.0/tests/fixtures/good/threadsafe/README.md +1 -0
- thread_safety_claim_audit-0.1.0/tests/fixtures/good/threadsafe/main.py +4 -0
- thread_safety_claim_audit-0.1.0/tests/fixtures/testonly/README.md +1 -0
- thread_safety_claim_audit-0.1.0/tests/fixtures/testonly/main.py +1 -0
- thread_safety_claim_audit-0.1.0/tests/fixtures/testonly/tests/test_work.py +2 -0
- thread_safety_claim_audit-0.1.0/tests/test_audit.py +266 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thread-safety-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,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thread-safety-claim-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline audit of documented thread-safety 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
|
+
# thread-safety-claim-audit
|
|
12
|
+
|
|
13
|
+
Offline audit of documented thread-safety claims against Python source.
|
|
14
|
+
|
|
15
|
+
Version 0.1.0. A standard-library-only CLI for comparing documentation promises
|
|
16
|
+
about concurrent safety with static synchronisation evidence. No audited code
|
|
17
|
+
is executed, no network is used, and `.env` contents are never opened.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
Requires Python 3.9 or later. Install and run:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
python3 -m pip install .
|
|
25
|
+
thread-safety-claim-audit path/to/project
|
|
26
|
+
python3 -m thread_safety_claim_audit path/to/project --json
|
|
27
|
+
thread-safety-claim-audit . --verbose --ignore 'vendor/*' --fail-on HIGH
|
|
28
|
+
python3 -m thread_safety_claim_audit --version
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The path defaults to the current directory. `--ignore GLOB` is repeatable and
|
|
32
|
+
matches relative paths. `--verbose` includes evidence and verified families.
|
|
33
|
+
`--json` emits an object with `findings` and `verified_claims` arrays. Findings
|
|
34
|
+
contain `rule`, `severity`, `family`, `claims`, and `evidence`; claim records
|
|
35
|
+
contain a path, line, sentence, and family. Evidence records contain a path,
|
|
36
|
+
line, and kind, except the `no-source` sentinel. Verified records contain a
|
|
37
|
+
family, claims, and evidence. Exit 0 means PASS; exit 2 means findings at or
|
|
38
|
+
above the selected threshold; exit 1 means a usage, reading, or parsing error.
|
|
39
|
+
The default threshold is MEDIUM. HIGH leaves MEDIUM findings visible while
|
|
40
|
+
allowing exit 0.
|
|
41
|
+
|
|
42
|
+
## Claims
|
|
43
|
+
|
|
44
|
+
Only root `README.md` and `docs/**/*.md` supply claims. Fenced code blocks are
|
|
45
|
+
excluded; inline code counts as prose. Sentences are lowercased, `n't` becomes
|
|
46
|
+
` not`, hyphens become spaces, and whitespace collapses. Exact phrases, with
|
|
47
|
+
word boundaries, are matched; there is no optional-word expansion.
|
|
48
|
+
|
|
49
|
+
The complete normalized phrase vocabulary is below:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
THREADSAFE:
|
|
53
|
+
thread safe
|
|
54
|
+
safe for concurrent use
|
|
55
|
+
safe to call from multiple threads
|
|
56
|
+
safe to use from multiple threads
|
|
57
|
+
safe to use concurrently
|
|
58
|
+
safe across threads
|
|
59
|
+
safe for multithreaded use
|
|
60
|
+
safe for multi threaded use
|
|
61
|
+
thread safe by design
|
|
62
|
+
|
|
63
|
+
NOTSAFE:
|
|
64
|
+
not thread safe
|
|
65
|
+
not safe for concurrent use
|
|
66
|
+
not safe to call from multiple threads
|
|
67
|
+
not safe to use from multiple threads
|
|
68
|
+
not safe to use concurrently
|
|
69
|
+
not safe across threads
|
|
70
|
+
not safe for multithreaded use
|
|
71
|
+
not safe for multi threaded use
|
|
72
|
+
no thread safety
|
|
73
|
+
must not be called from multiple threads
|
|
74
|
+
must not be used from multiple threads
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
NOTSAFE spans are matched and masked first to prevent substring overlap.
|
|
78
|
+
Negation words (`no`, `not`, `never`, `without`, including normalized
|
|
79
|
+
contractions) suppress THREADSAFE for the whole sentence; listed NOTSAFE
|
|
80
|
+
phrases still count. This asymmetry means the following examples behave as
|
|
81
|
+
shown:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
is not thread-safe => notsafe only
|
|
85
|
+
isn't thread-safe => notsafe only
|
|
86
|
+
never thread safe => no claim
|
|
87
|
+
does not claim to be thread safe => no claim
|
|
88
|
+
thread safety => no claim (bare noun)
|
|
89
|
+
threadsafe => no claim (one word)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Rules
|
|
93
|
+
|
|
94
|
+
Each family is evaluated independently, including contradictory documentation.
|
|
95
|
+
Each finding lists all family claim sentences and all applicable evidence.
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
| Rule | Severity | Trigger |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| `documented-thread-safe-claim-false` | HIGH | THREADSAFE with no production evidence |
|
|
101
|
+
| `documented-thread-safe-claim-false-test-only` | MEDIUM | THREADSAFE with evidence only in tests/fixtures |
|
|
102
|
+
| `documented-not-thread-safe-claim-false` | HIGH | NOTSAFE with production evidence |
|
|
103
|
+
| `documented-not-thread-safe-claim-false-test-only` | MEDIUM | NOTSAFE with evidence only in tests/fixtures |
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
No production Python source produces one HIGH finding per claimed family with
|
|
108
|
+
`no-source` evidence. THREADSAFE with production evidence is verified; NOTSAFE
|
|
109
|
+
with no evidence anywhere is verified. No claim yields PASS regardless of
|
|
110
|
+
source evidence. There are no other reverse rules.
|
|
111
|
+
|
|
112
|
+
## Named evidence
|
|
113
|
+
|
|
114
|
+
| Kind | Recognized syntax |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `lock-constructor` | Calls to threading Lock, RLock, Semaphore, BoundedSemaphore, Condition; multiprocessing Lock or RLock |
|
|
117
|
+
| `thread-local` | Call to threading.local |
|
|
118
|
+
| `acquire` | Attribute call `.acquire(...)` on any receiver |
|
|
119
|
+
| `with-lock` | With context Name or Attribute whose name contains `lock`, case-insensitively |
|
|
120
|
+
|
|
121
|
+
Module and from-import aliases are tracked per file. Bare constructor calls
|
|
122
|
+
need a matching import from threading or multiprocessing (multiprocessing
|
|
123
|
+
bare imports count only for Lock and RLock, matching the attribute form).
|
|
124
|
+
Evidence is deduplicated by file, line, and kind.
|
|
125
|
+
Production excludes tests directories, test filename patterns, fixtures,
|
|
126
|
+
docs, examples, and symlinked files. Test filename patterns and tests/fixtures
|
|
127
|
+
are collected separately; docs and examples never supply evidence. Symlinked
|
|
128
|
+
directories are not traversed. Build outputs, virtual environments, and Git
|
|
129
|
+
metadata are skipped. Ignored files supply neither claims nor evidence.
|
|
130
|
+
|
|
131
|
+
## Differentiation
|
|
132
|
+
|
|
133
|
+
- concurrency-claim-audit checks existence of parallel execution, not synchronisation.
|
|
134
|
+
- determinism-claim-audit checks run-to-run stability, not concurrent safety.
|
|
135
|
+
- security-claim-audit checks eval, shell, and pickle hazards, not locks.
|
|
136
|
+
- bandit and ruff inspect code issues without comparing documentation promises.
|
|
137
|
+
- The two PyPI neighbours named below are multi-language concurrency issue
|
|
138
|
+
linters. This tool compares docs promises with source evidence.
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
thread-safety / thread-safe-check
|
|
142
|
+
```
|
|
143
|
+
- Python threading documentation describes primitives rather than auditing
|
|
144
|
+
documentation against implementation.
|
|
145
|
+
|
|
146
|
+
## Honest limitations
|
|
147
|
+
|
|
148
|
+
This is a static heuristic, not a race detector or proof. Dead code counts:
|
|
149
|
+
constructors in uncalled code still supply evidence. Receiver types, import
|
|
150
|
+
shadowing, guard coverage, correct lock use, and actual shared-state mutation
|
|
151
|
+
are not inferred. Even `.acquire` on a clearly unrelated object counts.
|
|
152
|
+
A constructor does not prove that every shared access is guarded.
|
|
153
|
+
|
|
154
|
+
Bare threading imports and Thread imports provide no evidence. Thread, Pool,
|
|
155
|
+
Executor creation and concurrent.futures use concern execution, not protection.
|
|
156
|
+
asyncio.Lock is excluded because it is a single-threaded coroutine primitive.
|
|
157
|
+
queue.Queue is excluded because its internal protection says nothing about the
|
|
158
|
+
surrounding object. A bare Lock name, import, annotation, or attribute access
|
|
159
|
+
without a call does not count. threading.Barrier and threading.Event are
|
|
160
|
+
coordination primitives rather than mutual exclusion and are excluded.
|
|
161
|
+
Dynamic imports and custom guard wrappers may be missed. Exact-phrase claims
|
|
162
|
+
miss paraphrases. Markdown and sentence parsing are deliberately lightweight;
|
|
163
|
+
claim line numbers identify the start of the containing prose paragraph.
|
|
164
|
+
Invalid source or unreadable input results in exit 1 rather than a silent PASS.
|
|
165
|
+
|
|
166
|
+
## Development
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
170
|
+
python3 -m build
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The sdist includes the regression fixtures. Runtime dependencies: none.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# thread-safety-claim-audit
|
|
2
|
+
|
|
3
|
+
Offline audit of documented thread-safety claims against Python source.
|
|
4
|
+
|
|
5
|
+
Version 0.1.0. A standard-library-only CLI for comparing documentation promises
|
|
6
|
+
about concurrent safety with static synchronisation evidence. No audited code
|
|
7
|
+
is executed, no network is used, and `.env` contents are never opened.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Requires Python 3.9 or later. Install and run:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
python3 -m pip install .
|
|
15
|
+
thread-safety-claim-audit path/to/project
|
|
16
|
+
python3 -m thread_safety_claim_audit path/to/project --json
|
|
17
|
+
thread-safety-claim-audit . --verbose --ignore 'vendor/*' --fail-on HIGH
|
|
18
|
+
python3 -m thread_safety_claim_audit --version
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The path defaults to the current directory. `--ignore GLOB` is repeatable and
|
|
22
|
+
matches relative paths. `--verbose` includes evidence and verified families.
|
|
23
|
+
`--json` emits an object with `findings` and `verified_claims` arrays. Findings
|
|
24
|
+
contain `rule`, `severity`, `family`, `claims`, and `evidence`; claim records
|
|
25
|
+
contain a path, line, sentence, and family. Evidence records contain a path,
|
|
26
|
+
line, and kind, except the `no-source` sentinel. Verified records contain a
|
|
27
|
+
family, claims, and evidence. Exit 0 means PASS; exit 2 means findings at or
|
|
28
|
+
above the selected threshold; exit 1 means a usage, reading, or parsing error.
|
|
29
|
+
The default threshold is MEDIUM. HIGH leaves MEDIUM findings visible while
|
|
30
|
+
allowing exit 0.
|
|
31
|
+
|
|
32
|
+
## Claims
|
|
33
|
+
|
|
34
|
+
Only root `README.md` and `docs/**/*.md` supply claims. Fenced code blocks are
|
|
35
|
+
excluded; inline code counts as prose. Sentences are lowercased, `n't` becomes
|
|
36
|
+
` not`, hyphens become spaces, and whitespace collapses. Exact phrases, with
|
|
37
|
+
word boundaries, are matched; there is no optional-word expansion.
|
|
38
|
+
|
|
39
|
+
The complete normalized phrase vocabulary is below:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
THREADSAFE:
|
|
43
|
+
thread safe
|
|
44
|
+
safe for concurrent use
|
|
45
|
+
safe to call from multiple threads
|
|
46
|
+
safe to use from multiple threads
|
|
47
|
+
safe to use concurrently
|
|
48
|
+
safe across threads
|
|
49
|
+
safe for multithreaded use
|
|
50
|
+
safe for multi threaded use
|
|
51
|
+
thread safe by design
|
|
52
|
+
|
|
53
|
+
NOTSAFE:
|
|
54
|
+
not thread safe
|
|
55
|
+
not safe for concurrent use
|
|
56
|
+
not safe to call from multiple threads
|
|
57
|
+
not safe to use from multiple threads
|
|
58
|
+
not safe to use concurrently
|
|
59
|
+
not safe across threads
|
|
60
|
+
not safe for multithreaded use
|
|
61
|
+
not safe for multi threaded use
|
|
62
|
+
no thread safety
|
|
63
|
+
must not be called from multiple threads
|
|
64
|
+
must not be used from multiple threads
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
NOTSAFE spans are matched and masked first to prevent substring overlap.
|
|
68
|
+
Negation words (`no`, `not`, `never`, `without`, including normalized
|
|
69
|
+
contractions) suppress THREADSAFE for the whole sentence; listed NOTSAFE
|
|
70
|
+
phrases still count. This asymmetry means the following examples behave as
|
|
71
|
+
shown:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
is not thread-safe => notsafe only
|
|
75
|
+
isn't thread-safe => notsafe only
|
|
76
|
+
never thread safe => no claim
|
|
77
|
+
does not claim to be thread safe => no claim
|
|
78
|
+
thread safety => no claim (bare noun)
|
|
79
|
+
threadsafe => no claim (one word)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Rules
|
|
83
|
+
|
|
84
|
+
Each family is evaluated independently, including contradictory documentation.
|
|
85
|
+
Each finding lists all family claim sentences and all applicable evidence.
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
| Rule | Severity | Trigger |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| `documented-thread-safe-claim-false` | HIGH | THREADSAFE with no production evidence |
|
|
91
|
+
| `documented-thread-safe-claim-false-test-only` | MEDIUM | THREADSAFE with evidence only in tests/fixtures |
|
|
92
|
+
| `documented-not-thread-safe-claim-false` | HIGH | NOTSAFE with production evidence |
|
|
93
|
+
| `documented-not-thread-safe-claim-false-test-only` | MEDIUM | NOTSAFE with evidence only in tests/fixtures |
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
No production Python source produces one HIGH finding per claimed family with
|
|
98
|
+
`no-source` evidence. THREADSAFE with production evidence is verified; NOTSAFE
|
|
99
|
+
with no evidence anywhere is verified. No claim yields PASS regardless of
|
|
100
|
+
source evidence. There are no other reverse rules.
|
|
101
|
+
|
|
102
|
+
## Named evidence
|
|
103
|
+
|
|
104
|
+
| Kind | Recognized syntax |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `lock-constructor` | Calls to threading Lock, RLock, Semaphore, BoundedSemaphore, Condition; multiprocessing Lock or RLock |
|
|
107
|
+
| `thread-local` | Call to threading.local |
|
|
108
|
+
| `acquire` | Attribute call `.acquire(...)` on any receiver |
|
|
109
|
+
| `with-lock` | With context Name or Attribute whose name contains `lock`, case-insensitively |
|
|
110
|
+
|
|
111
|
+
Module and from-import aliases are tracked per file. Bare constructor calls
|
|
112
|
+
need a matching import from threading or multiprocessing (multiprocessing
|
|
113
|
+
bare imports count only for Lock and RLock, matching the attribute form).
|
|
114
|
+
Evidence is deduplicated by file, line, and kind.
|
|
115
|
+
Production excludes tests directories, test filename patterns, fixtures,
|
|
116
|
+
docs, examples, and symlinked files. Test filename patterns and tests/fixtures
|
|
117
|
+
are collected separately; docs and examples never supply evidence. Symlinked
|
|
118
|
+
directories are not traversed. Build outputs, virtual environments, and Git
|
|
119
|
+
metadata are skipped. Ignored files supply neither claims nor evidence.
|
|
120
|
+
|
|
121
|
+
## Differentiation
|
|
122
|
+
|
|
123
|
+
- concurrency-claim-audit checks existence of parallel execution, not synchronisation.
|
|
124
|
+
- determinism-claim-audit checks run-to-run stability, not concurrent safety.
|
|
125
|
+
- security-claim-audit checks eval, shell, and pickle hazards, not locks.
|
|
126
|
+
- bandit and ruff inspect code issues without comparing documentation promises.
|
|
127
|
+
- The two PyPI neighbours named below are multi-language concurrency issue
|
|
128
|
+
linters. This tool compares docs promises with source evidence.
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
thread-safety / thread-safe-check
|
|
132
|
+
```
|
|
133
|
+
- Python threading documentation describes primitives rather than auditing
|
|
134
|
+
documentation against implementation.
|
|
135
|
+
|
|
136
|
+
## Honest limitations
|
|
137
|
+
|
|
138
|
+
This is a static heuristic, not a race detector or proof. Dead code counts:
|
|
139
|
+
constructors in uncalled code still supply evidence. Receiver types, import
|
|
140
|
+
shadowing, guard coverage, correct lock use, and actual shared-state mutation
|
|
141
|
+
are not inferred. Even `.acquire` on a clearly unrelated object counts.
|
|
142
|
+
A constructor does not prove that every shared access is guarded.
|
|
143
|
+
|
|
144
|
+
Bare threading imports and Thread imports provide no evidence. Thread, Pool,
|
|
145
|
+
Executor creation and concurrent.futures use concern execution, not protection.
|
|
146
|
+
asyncio.Lock is excluded because it is a single-threaded coroutine primitive.
|
|
147
|
+
queue.Queue is excluded because its internal protection says nothing about the
|
|
148
|
+
surrounding object. A bare Lock name, import, annotation, or attribute access
|
|
149
|
+
without a call does not count. threading.Barrier and threading.Event are
|
|
150
|
+
coordination primitives rather than mutual exclusion and are excluded.
|
|
151
|
+
Dynamic imports and custom guard wrappers may be missed. Exact-phrase claims
|
|
152
|
+
miss paraphrases. Markdown and sentence parsing are deliberately lightweight;
|
|
153
|
+
claim line numbers identify the start of the containing prose paragraph.
|
|
154
|
+
Invalid source or unreadable input results in exit 1 rather than a silent PASS.
|
|
155
|
+
|
|
156
|
+
## Development
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
160
|
+
python3 -m build
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The sdist includes the regression fixtures. Runtime dependencies: none.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "thread-safety-claim-audit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Offline audit of documented thread-safety claims against Python source."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
thread-safety-claim-audit = "thread_safety_claim_audit.__main__:main"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["src"]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Offline documentation contract checks using static syntax only."""
|
|
2
|
+
import argparse
|
|
3
|
+
import ast
|
|
4
|
+
import fnmatch
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
THREADSAFE = (
|
|
12
|
+
'thread safe', 'safe for concurrent use',
|
|
13
|
+
'safe to call from multiple threads', 'safe to use from multiple threads',
|
|
14
|
+
'safe to use concurrently', 'safe across threads',
|
|
15
|
+
'safe for multithreaded use', 'safe for multi threaded use',
|
|
16
|
+
'thread safe by design',
|
|
17
|
+
)
|
|
18
|
+
NOTSAFE = (
|
|
19
|
+
'not thread safe', 'not safe for concurrent use',
|
|
20
|
+
'not safe to call from multiple threads', 'not safe to use from multiple threads',
|
|
21
|
+
'not safe to use concurrently', 'not safe across threads',
|
|
22
|
+
'not safe for multithreaded use', 'not safe for multi threaded use',
|
|
23
|
+
'no thread safety', 'must not be called from multiple threads',
|
|
24
|
+
'must not be used from multiple threads',
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize(text):
|
|
29
|
+
return re.sub(r'\s+', ' ', text.lower().replace("n't", ' not').replace('-', ' ')).strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def extract_claims(text, path='README.md'):
|
|
33
|
+
claims = []
|
|
34
|
+
fence = None
|
|
35
|
+
pending = []
|
|
36
|
+
|
|
37
|
+
def flush():
|
|
38
|
+
if not pending:
|
|
39
|
+
return
|
|
40
|
+
start = pending[0][0]
|
|
41
|
+
prose = ' '.join(value for _, value in pending)
|
|
42
|
+
for sentence in re.split(r'(?<=[.!?])\s+', prose):
|
|
43
|
+
normalized = normalize(sentence)
|
|
44
|
+
masked = normalized
|
|
45
|
+
found_not = False
|
|
46
|
+
for phrase in sorted(NOTSAFE, key=len, reverse=True):
|
|
47
|
+
pattern = r'\b' + re.escape(phrase) + r'\b'
|
|
48
|
+
if re.search(pattern, masked):
|
|
49
|
+
found_not = True
|
|
50
|
+
masked = re.sub(pattern, lambda m: ' ' * len(m.group()), masked)
|
|
51
|
+
families = ['notsafe'] if found_not else []
|
|
52
|
+
if not re.search(r'\b(no|not|never|without)\b', normalized):
|
|
53
|
+
if any(re.search(r'\b' + re.escape(p) + r'\b', masked) for p in THREADSAFE):
|
|
54
|
+
families.append('threadsafe')
|
|
55
|
+
for family in families:
|
|
56
|
+
claims.append(dict(family=family, path=path, line=start, sentence=sentence.strip()))
|
|
57
|
+
pending.clear()
|
|
58
|
+
|
|
59
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
60
|
+
marker = re.match(r'^\s{0,3}(`{3,}|~{3,})', line)
|
|
61
|
+
if marker:
|
|
62
|
+
token = marker.group(1)
|
|
63
|
+
if fence is None:
|
|
64
|
+
flush()
|
|
65
|
+
fence = token
|
|
66
|
+
elif token[0] == fence[0] and len(token) >= len(fence):
|
|
67
|
+
fence = None
|
|
68
|
+
continue
|
|
69
|
+
if fence:
|
|
70
|
+
continue
|
|
71
|
+
if not line.strip():
|
|
72
|
+
flush()
|
|
73
|
+
else:
|
|
74
|
+
pending.append((number, line.strip()))
|
|
75
|
+
flush()
|
|
76
|
+
return claims
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def extract_evidence(text, path):
|
|
80
|
+
tree = ast.parse(text, filename=path)
|
|
81
|
+
modules = {}
|
|
82
|
+
imported = {}
|
|
83
|
+
constructors = {'Lock', 'RLock', 'Semaphore', 'BoundedSemaphore', 'Condition'}
|
|
84
|
+
for node in ast.walk(tree):
|
|
85
|
+
if isinstance(node, ast.Import):
|
|
86
|
+
for alias in node.names:
|
|
87
|
+
if alias.name in ('threading', 'multiprocessing'):
|
|
88
|
+
modules[alias.asname or alias.name] = alias.name
|
|
89
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0:
|
|
90
|
+
if node.module in ('threading', 'multiprocessing'):
|
|
91
|
+
for alias in node.names:
|
|
92
|
+
imported[alias.asname or alias.name] = (node.module, alias.name)
|
|
93
|
+
|
|
94
|
+
def kind_for(module, name):
|
|
95
|
+
if module == 'threading' and name == 'local':
|
|
96
|
+
return 'thread-local'
|
|
97
|
+
allowed = constructors if module == 'threading' else {'Lock', 'RLock'}
|
|
98
|
+
return 'lock-constructor' if name in allowed else None
|
|
99
|
+
|
|
100
|
+
items = set()
|
|
101
|
+
for node in ast.walk(tree):
|
|
102
|
+
kind = None
|
|
103
|
+
if isinstance(node, ast.Call):
|
|
104
|
+
func = node.func
|
|
105
|
+
if isinstance(func, ast.Attribute):
|
|
106
|
+
if func.attr == 'acquire':
|
|
107
|
+
kind = 'acquire'
|
|
108
|
+
elif isinstance(func.value, ast.Name) and func.value.id in modules:
|
|
109
|
+
kind = kind_for(modules[func.value.id], func.attr)
|
|
110
|
+
elif isinstance(func, ast.Name) and func.id in imported:
|
|
111
|
+
kind = kind_for(*imported[func.id])
|
|
112
|
+
if kind:
|
|
113
|
+
items.add((node.lineno, kind))
|
|
114
|
+
if isinstance(node, ast.With):
|
|
115
|
+
for item in node.items:
|
|
116
|
+
context = item.context_expr
|
|
117
|
+
name = context.id if isinstance(context, ast.Name) else context.attr if isinstance(context, ast.Attribute) else ''
|
|
118
|
+
if 'lock' in name.lower():
|
|
119
|
+
items.add((node.lineno, 'with-lock'))
|
|
120
|
+
return [dict(path=path, line=line, kind=kind) for line, kind in sorted(items)]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def audit(root, ignore=()):
|
|
124
|
+
root = Path(root)
|
|
125
|
+
if not root.is_dir():
|
|
126
|
+
raise ValueError('audit path must be a directory')
|
|
127
|
+
claims, production, test_only = [], [], []
|
|
128
|
+
source_count = 0
|
|
129
|
+
for directory, directories, files in os.walk(root, followlinks=False):
|
|
130
|
+
directories[:] = sorted(d for d in directories if not (Path(directory) / d).is_symlink() and d not in {'.git', '__pycache__', '.venv', 'venv', 'build', 'dist'} and not d.endswith('.egg-info'))
|
|
131
|
+
for filename in sorted(files):
|
|
132
|
+
path = Path(directory) / filename
|
|
133
|
+
relative = path.relative_to(root).as_posix()
|
|
134
|
+
if path.is_symlink() or filename == '.env' or any(fnmatch.fnmatchcase(relative, p) for p in ignore):
|
|
135
|
+
continue
|
|
136
|
+
parts = Path(relative).parts
|
|
137
|
+
is_doc = relative == 'README.md' or (parts[0] == 'docs' and path.suffix == '.md')
|
|
138
|
+
is_source = path.suffix == '.py' and 'docs' not in parts[:-1] and 'examples' not in parts[:-1]
|
|
139
|
+
if not is_doc and not is_source:
|
|
140
|
+
continue
|
|
141
|
+
text = path.read_text(encoding='utf-8')
|
|
142
|
+
if is_doc:
|
|
143
|
+
claims.extend(extract_claims(text, relative))
|
|
144
|
+
if is_source:
|
|
145
|
+
testing = 'tests' in parts[:-1] or 'fixtures' in parts[:-1] or filename.startswith('test_') or filename.endswith('_test.py')
|
|
146
|
+
evidence = extract_evidence(text, relative)
|
|
147
|
+
if testing:
|
|
148
|
+
test_only.extend(evidence)
|
|
149
|
+
else:
|
|
150
|
+
source_count += 1
|
|
151
|
+
production.extend(evidence)
|
|
152
|
+
findings, verified = [], []
|
|
153
|
+
for family in ('threadsafe', 'notsafe'):
|
|
154
|
+
family_claims = [c for c in claims if c['family'] == family]
|
|
155
|
+
if not family_claims:
|
|
156
|
+
continue
|
|
157
|
+
rule = 'documented-' + ('thread-safe' if family == 'threadsafe' else 'not-thread-safe') + '-claim-false'
|
|
158
|
+
evidence = production
|
|
159
|
+
severity = 'HIGH'
|
|
160
|
+
if source_count == 0:
|
|
161
|
+
evidence = [dict(kind='no-source')]
|
|
162
|
+
elif not production and test_only:
|
|
163
|
+
rule += '-test-only'
|
|
164
|
+
severity = 'MEDIUM'
|
|
165
|
+
evidence = test_only
|
|
166
|
+
elif (family == 'threadsafe' and production) or (family == 'notsafe' and not production):
|
|
167
|
+
verified.append(dict(family=family, claims=family_claims, evidence=production))
|
|
168
|
+
continue
|
|
169
|
+
findings.append(dict(rule=rule, severity=severity, family=family, claims=family_claims, evidence=evidence))
|
|
170
|
+
return dict(findings=findings, verified_claims=verified)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Parser(argparse.ArgumentParser):
|
|
174
|
+
def error(self, message):
|
|
175
|
+
self.print_usage(sys.stderr)
|
|
176
|
+
self.exit(1, self.prog + ': error: ' + message + '\n')
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main(argv=None):
|
|
180
|
+
parser = Parser(prog='thread-safety-claim-audit', description='Offline audit of documented thread-safety claims against Python source.')
|
|
181
|
+
parser.add_argument('path', nargs='?', default='.')
|
|
182
|
+
parser.add_argument('--json', action='store_true')
|
|
183
|
+
parser.add_argument('--verbose', action='store_true')
|
|
184
|
+
parser.add_argument('--ignore', action='append', default=[], metavar='GLOB')
|
|
185
|
+
parser.add_argument('--fail-on', choices=('MEDIUM', 'HIGH'), default='MEDIUM')
|
|
186
|
+
parser.add_argument('--version', action='version', version='0.1.0')
|
|
187
|
+
args = parser.parse_args(argv)
|
|
188
|
+
try:
|
|
189
|
+
result = audit(args.path, args.ignore)
|
|
190
|
+
except (OSError, UnicodeError, SyntaxError, ValueError) as exc:
|
|
191
|
+
print('thread-safety-claim-audit: ' + str(exc), file=sys.stderr)
|
|
192
|
+
return 1
|
|
193
|
+
failed = any(f['severity'] == 'HIGH' or args.fail_on == 'MEDIUM' for f in result['findings'])
|
|
194
|
+
if args.json:
|
|
195
|
+
print(json.dumps(result, indent=2))
|
|
196
|
+
else:
|
|
197
|
+
print('FAIL' if failed else 'PASS')
|
|
198
|
+
for finding in result['findings']:
|
|
199
|
+
print(finding['severity'] + ' ' + finding['rule'])
|
|
200
|
+
for claim in finding['claims']:
|
|
201
|
+
print(' {path}:{line}: {sentence}'.format(**claim))
|
|
202
|
+
if args.verbose:
|
|
203
|
+
for evidence in finding['evidence']:
|
|
204
|
+
print(' ' + json.dumps(evidence, sort_keys=True))
|
|
205
|
+
if args.verbose:
|
|
206
|
+
for claim in result['verified_claims']:
|
|
207
|
+
print('verified: ' + claim['family'])
|
|
208
|
+
return 2 if failed else 0
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thread-safety-claim-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline audit of documented thread-safety 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
|
+
# thread-safety-claim-audit
|
|
12
|
+
|
|
13
|
+
Offline audit of documented thread-safety claims against Python source.
|
|
14
|
+
|
|
15
|
+
Version 0.1.0. A standard-library-only CLI for comparing documentation promises
|
|
16
|
+
about concurrent safety with static synchronisation evidence. No audited code
|
|
17
|
+
is executed, no network is used, and `.env` contents are never opened.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
Requires Python 3.9 or later. Install and run:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
python3 -m pip install .
|
|
25
|
+
thread-safety-claim-audit path/to/project
|
|
26
|
+
python3 -m thread_safety_claim_audit path/to/project --json
|
|
27
|
+
thread-safety-claim-audit . --verbose --ignore 'vendor/*' --fail-on HIGH
|
|
28
|
+
python3 -m thread_safety_claim_audit --version
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The path defaults to the current directory. `--ignore GLOB` is repeatable and
|
|
32
|
+
matches relative paths. `--verbose` includes evidence and verified families.
|
|
33
|
+
`--json` emits an object with `findings` and `verified_claims` arrays. Findings
|
|
34
|
+
contain `rule`, `severity`, `family`, `claims`, and `evidence`; claim records
|
|
35
|
+
contain a path, line, sentence, and family. Evidence records contain a path,
|
|
36
|
+
line, and kind, except the `no-source` sentinel. Verified records contain a
|
|
37
|
+
family, claims, and evidence. Exit 0 means PASS; exit 2 means findings at or
|
|
38
|
+
above the selected threshold; exit 1 means a usage, reading, or parsing error.
|
|
39
|
+
The default threshold is MEDIUM. HIGH leaves MEDIUM findings visible while
|
|
40
|
+
allowing exit 0.
|
|
41
|
+
|
|
42
|
+
## Claims
|
|
43
|
+
|
|
44
|
+
Only root `README.md` and `docs/**/*.md` supply claims. Fenced code blocks are
|
|
45
|
+
excluded; inline code counts as prose. Sentences are lowercased, `n't` becomes
|
|
46
|
+
` not`, hyphens become spaces, and whitespace collapses. Exact phrases, with
|
|
47
|
+
word boundaries, are matched; there is no optional-word expansion.
|
|
48
|
+
|
|
49
|
+
The complete normalized phrase vocabulary is below:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
THREADSAFE:
|
|
53
|
+
thread safe
|
|
54
|
+
safe for concurrent use
|
|
55
|
+
safe to call from multiple threads
|
|
56
|
+
safe to use from multiple threads
|
|
57
|
+
safe to use concurrently
|
|
58
|
+
safe across threads
|
|
59
|
+
safe for multithreaded use
|
|
60
|
+
safe for multi threaded use
|
|
61
|
+
thread safe by design
|
|
62
|
+
|
|
63
|
+
NOTSAFE:
|
|
64
|
+
not thread safe
|
|
65
|
+
not safe for concurrent use
|
|
66
|
+
not safe to call from multiple threads
|
|
67
|
+
not safe to use from multiple threads
|
|
68
|
+
not safe to use concurrently
|
|
69
|
+
not safe across threads
|
|
70
|
+
not safe for multithreaded use
|
|
71
|
+
not safe for multi threaded use
|
|
72
|
+
no thread safety
|
|
73
|
+
must not be called from multiple threads
|
|
74
|
+
must not be used from multiple threads
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
NOTSAFE spans are matched and masked first to prevent substring overlap.
|
|
78
|
+
Negation words (`no`, `not`, `never`, `without`, including normalized
|
|
79
|
+
contractions) suppress THREADSAFE for the whole sentence; listed NOTSAFE
|
|
80
|
+
phrases still count. This asymmetry means the following examples behave as
|
|
81
|
+
shown:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
is not thread-safe => notsafe only
|
|
85
|
+
isn't thread-safe => notsafe only
|
|
86
|
+
never thread safe => no claim
|
|
87
|
+
does not claim to be thread safe => no claim
|
|
88
|
+
thread safety => no claim (bare noun)
|
|
89
|
+
threadsafe => no claim (one word)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Rules
|
|
93
|
+
|
|
94
|
+
Each family is evaluated independently, including contradictory documentation.
|
|
95
|
+
Each finding lists all family claim sentences and all applicable evidence.
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
| Rule | Severity | Trigger |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| `documented-thread-safe-claim-false` | HIGH | THREADSAFE with no production evidence |
|
|
101
|
+
| `documented-thread-safe-claim-false-test-only` | MEDIUM | THREADSAFE with evidence only in tests/fixtures |
|
|
102
|
+
| `documented-not-thread-safe-claim-false` | HIGH | NOTSAFE with production evidence |
|
|
103
|
+
| `documented-not-thread-safe-claim-false-test-only` | MEDIUM | NOTSAFE with evidence only in tests/fixtures |
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
No production Python source produces one HIGH finding per claimed family with
|
|
108
|
+
`no-source` evidence. THREADSAFE with production evidence is verified; NOTSAFE
|
|
109
|
+
with no evidence anywhere is verified. No claim yields PASS regardless of
|
|
110
|
+
source evidence. There are no other reverse rules.
|
|
111
|
+
|
|
112
|
+
## Named evidence
|
|
113
|
+
|
|
114
|
+
| Kind | Recognized syntax |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `lock-constructor` | Calls to threading Lock, RLock, Semaphore, BoundedSemaphore, Condition; multiprocessing Lock or RLock |
|
|
117
|
+
| `thread-local` | Call to threading.local |
|
|
118
|
+
| `acquire` | Attribute call `.acquire(...)` on any receiver |
|
|
119
|
+
| `with-lock` | With context Name or Attribute whose name contains `lock`, case-insensitively |
|
|
120
|
+
|
|
121
|
+
Module and from-import aliases are tracked per file. Bare constructor calls
|
|
122
|
+
need a matching import from threading or multiprocessing (multiprocessing
|
|
123
|
+
bare imports count only for Lock and RLock, matching the attribute form).
|
|
124
|
+
Evidence is deduplicated by file, line, and kind.
|
|
125
|
+
Production excludes tests directories, test filename patterns, fixtures,
|
|
126
|
+
docs, examples, and symlinked files. Test filename patterns and tests/fixtures
|
|
127
|
+
are collected separately; docs and examples never supply evidence. Symlinked
|
|
128
|
+
directories are not traversed. Build outputs, virtual environments, and Git
|
|
129
|
+
metadata are skipped. Ignored files supply neither claims nor evidence.
|
|
130
|
+
|
|
131
|
+
## Differentiation
|
|
132
|
+
|
|
133
|
+
- concurrency-claim-audit checks existence of parallel execution, not synchronisation.
|
|
134
|
+
- determinism-claim-audit checks run-to-run stability, not concurrent safety.
|
|
135
|
+
- security-claim-audit checks eval, shell, and pickle hazards, not locks.
|
|
136
|
+
- bandit and ruff inspect code issues without comparing documentation promises.
|
|
137
|
+
- The two PyPI neighbours named below are multi-language concurrency issue
|
|
138
|
+
linters. This tool compares docs promises with source evidence.
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
thread-safety / thread-safe-check
|
|
142
|
+
```
|
|
143
|
+
- Python threading documentation describes primitives rather than auditing
|
|
144
|
+
documentation against implementation.
|
|
145
|
+
|
|
146
|
+
## Honest limitations
|
|
147
|
+
|
|
148
|
+
This is a static heuristic, not a race detector or proof. Dead code counts:
|
|
149
|
+
constructors in uncalled code still supply evidence. Receiver types, import
|
|
150
|
+
shadowing, guard coverage, correct lock use, and actual shared-state mutation
|
|
151
|
+
are not inferred. Even `.acquire` on a clearly unrelated object counts.
|
|
152
|
+
A constructor does not prove that every shared access is guarded.
|
|
153
|
+
|
|
154
|
+
Bare threading imports and Thread imports provide no evidence. Thread, Pool,
|
|
155
|
+
Executor creation and concurrent.futures use concern execution, not protection.
|
|
156
|
+
asyncio.Lock is excluded because it is a single-threaded coroutine primitive.
|
|
157
|
+
queue.Queue is excluded because its internal protection says nothing about the
|
|
158
|
+
surrounding object. A bare Lock name, import, annotation, or attribute access
|
|
159
|
+
without a call does not count. threading.Barrier and threading.Event are
|
|
160
|
+
coordination primitives rather than mutual exclusion and are excluded.
|
|
161
|
+
Dynamic imports and custom guard wrappers may be missed. Exact-phrase claims
|
|
162
|
+
miss paraphrases. Markdown and sentence parsing are deliberately lightweight;
|
|
163
|
+
claim line numbers identify the start of the containing prose paragraph.
|
|
164
|
+
Invalid source or unreadable input results in exit 1 rather than a silent PASS.
|
|
165
|
+
|
|
166
|
+
## Development
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
170
|
+
python3 -m build
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The sdist includes the regression fixtures. Runtime dependencies: none.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
src/thread_safety_claim_audit/__init__.py
|
|
6
|
+
src/thread_safety_claim_audit/__main__.py
|
|
7
|
+
src/thread_safety_claim_audit/audit.py
|
|
8
|
+
src/thread_safety_claim_audit.egg-info/PKG-INFO
|
|
9
|
+
src/thread_safety_claim_audit.egg-info/SOURCES.txt
|
|
10
|
+
src/thread_safety_claim_audit.egg-info/dependency_links.txt
|
|
11
|
+
src/thread_safety_claim_audit.egg-info/entry_points.txt
|
|
12
|
+
src/thread_safety_claim_audit.egg-info/top_level.txt
|
|
13
|
+
tests/test_audit.py
|
|
14
|
+
tests/fixtures/bad/notsafe/README.md
|
|
15
|
+
tests/fixtures/bad/notsafe/main.py
|
|
16
|
+
tests/fixtures/bad/threadsafe/README.md
|
|
17
|
+
tests/fixtures/bad/threadsafe/main.py
|
|
18
|
+
tests/fixtures/good/notsafe/README.md
|
|
19
|
+
tests/fixtures/good/notsafe/main.py
|
|
20
|
+
tests/fixtures/good/threadsafe/README.md
|
|
21
|
+
tests/fixtures/good/threadsafe/main.py
|
|
22
|
+
tests/fixtures/testonly/README.md
|
|
23
|
+
tests/fixtures/testonly/main.py
|
|
24
|
+
tests/fixtures/testonly/tests/test_work.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
thread_safety_claim_audit
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This library is not thread-safe.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This library is thread-safe.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This library is not thread-safe.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This library is thread-safe.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This library is thread-safe.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
value = []
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
from thread_safety_claim_audit.audit import (
|
|
10
|
+
THREADSAFE, NOTSAFE, audit, extract_claims, extract_evidence, main, normalize,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
FIXTURES = Path(__file__).parent / 'fixtures'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Claims(unittest.TestCase):
|
|
17
|
+
def test_normalize(self):
|
|
18
|
+
self.assertEqual(normalize(" ISN'T thread-safe "), 'is not thread safe')
|
|
19
|
+
|
|
20
|
+
def test_fences(self):
|
|
21
|
+
self.assertEqual(extract_claims('```text\nis thread-safe\n```'), [])
|
|
22
|
+
|
|
23
|
+
def test_tilde_fences(self):
|
|
24
|
+
self.assertEqual(extract_claims('~~~text\nis thread-safe\n~~~'), [])
|
|
25
|
+
|
|
26
|
+
def test_inline(self):
|
|
27
|
+
self.assertEqual(extract_claims('This is `thread-safe`.')[0]['family'], 'threadsafe')
|
|
28
|
+
|
|
29
|
+
def test_overlap(self):
|
|
30
|
+
self.assertEqual([c['family'] for c in extract_claims('is not thread-safe')], ['notsafe'])
|
|
31
|
+
|
|
32
|
+
def test_contraction(self):
|
|
33
|
+
self.assertEqual([c['family'] for c in extract_claims("isn't thread-safe")], ['notsafe'])
|
|
34
|
+
|
|
35
|
+
def test_multiple_sentences(self):
|
|
36
|
+
self.assertEqual(len(extract_claims('is thread safe. It is not thread safe.')), 2)
|
|
37
|
+
|
|
38
|
+
def test_boundaries(self):
|
|
39
|
+
self.assertEqual(extract_claims('unthread safeish'), [])
|
|
40
|
+
|
|
41
|
+
def test_self_audit(self):
|
|
42
|
+
self.assertEqual(audit(Path(__file__).parents[1])['findings'], [])
|
|
43
|
+
self.assertEqual(extract_claims((Path(__file__).parents[1] / 'README.md').read_text()), [])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
for index, phrase in enumerate(THREADSAFE):
|
|
47
|
+
def check(self, phrase=phrase):
|
|
48
|
+
self.assertEqual([c['family'] for c in extract_claims(phrase)], ['threadsafe'])
|
|
49
|
+
self.assertEqual([c['family'] for c in extract_claims(phrase.replace(' ', '-'))], ['threadsafe'])
|
|
50
|
+
setattr(Claims, 'test_positive_phrase_%02d' % index, check)
|
|
51
|
+
for index, phrase in enumerate(NOTSAFE):
|
|
52
|
+
def check(self, phrase=phrase):
|
|
53
|
+
self.assertEqual([c['family'] for c in extract_claims(phrase)], ['notsafe'])
|
|
54
|
+
setattr(Claims, 'test_negative_phrase_%02d' % index, check)
|
|
55
|
+
for index, phrase in enumerate(('thread safety', 'threadsafe', 'never thread safe', 'does not claim to be thread safe', 'without being thread safe', 'no promise of thread safe', 'safe to call concurrently')):
|
|
56
|
+
def check(self, phrase=phrase):
|
|
57
|
+
self.assertEqual(extract_claims(phrase), [])
|
|
58
|
+
setattr(Claims, 'test_nonclaim_%02d' % index, check)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Evidence(unittest.TestCase):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
CASES = [
|
|
66
|
+
('import threading\nx = threading.Lock()', ['lock-constructor']),
|
|
67
|
+
('import threading as t\nx = t.RLock()', ['lock-constructor']),
|
|
68
|
+
('from threading import Lock\nx = Lock()', ['lock-constructor']),
|
|
69
|
+
('from threading import Lock as L\nx = L()', ['lock-constructor']),
|
|
70
|
+
('x = Lock()', []),
|
|
71
|
+
('threading.Lock()', []),
|
|
72
|
+
('# threading.Lock()\ns = \"lock.acquire()\"', []),
|
|
73
|
+
('import multiprocessing\nx = multiprocessing.Lock()', ['lock-constructor']),
|
|
74
|
+
('from multiprocessing import RLock as R\nx = R()', ['lock-constructor']),
|
|
75
|
+
('from multiprocessing import Semaphore as S\nx = S()', []),
|
|
76
|
+
('thing.acquire()', ['acquire']),
|
|
77
|
+
('with lock:\n pass', ['with-lock']),
|
|
78
|
+
('with self.myLOCK:\n pass', ['with-lock']),
|
|
79
|
+
('with open("a"):\n pass', []),
|
|
80
|
+
('with resource:\n pass', []),
|
|
81
|
+
('import threading\nx = threading.local()', ['thread-local']),
|
|
82
|
+
('from threading import local as L\nx = L()', ['thread-local']),
|
|
83
|
+
('import threading', []),
|
|
84
|
+
('from threading import Thread\nt = Thread()', []),
|
|
85
|
+
('import asyncio\nx = asyncio.Lock()', []),
|
|
86
|
+
('import queue\nx = queue.Queue()', []),
|
|
87
|
+
('from threading import Lock\nx: Lock', []),
|
|
88
|
+
('import threading\nx = threading.Lock', []),
|
|
89
|
+
('import threading\nx = threading.Barrier(2)\ny = threading.Event()', []),
|
|
90
|
+
('import multiprocessing\nx = multiprocessing.Semaphore()', []),
|
|
91
|
+
('def unused():\n import threading\n return threading.Lock()', ['lock-constructor']),
|
|
92
|
+
('with lock, another_lock:\n pass', ['with-lock']),
|
|
93
|
+
]
|
|
94
|
+
for index, (source, kinds) in enumerate(CASES):
|
|
95
|
+
def check(self, source=source, kinds=kinds):
|
|
96
|
+
self.assertEqual([e['kind'] for e in extract_evidence(source, 'main.py')], kinds)
|
|
97
|
+
setattr(Evidence, 'test_evidence_%02d' % index, check)
|
|
98
|
+
for name in ('Semaphore', 'BoundedSemaphore', 'Condition', 'RLock'):
|
|
99
|
+
def check(self, name=name):
|
|
100
|
+
self.assertEqual(len(extract_evidence('import threading\nx = threading.' + name + '()', 'main.py')), 1)
|
|
101
|
+
setattr(Evidence, 'test_constructor_' + name, check)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Integration(unittest.TestCase):
|
|
105
|
+
def setUp(self):
|
|
106
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
107
|
+
self.addCleanup(self.temp.cleanup)
|
|
108
|
+
self.root = Path(self.temp.name)
|
|
109
|
+
|
|
110
|
+
def write(self, path, text):
|
|
111
|
+
p = self.root / path
|
|
112
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
p.write_text(text)
|
|
114
|
+
|
|
115
|
+
def run_cli(self, *args):
|
|
116
|
+
output = io.StringIO()
|
|
117
|
+
with contextlib.redirect_stdout(output):
|
|
118
|
+
code = main(list(args))
|
|
119
|
+
return code, output.getvalue()
|
|
120
|
+
|
|
121
|
+
def test_fixtures(self):
|
|
122
|
+
for name, code, rule in (
|
|
123
|
+
('good/threadsafe', 0, None), ('good/notsafe', 0, None),
|
|
124
|
+
('bad/threadsafe', 2, 'documented-thread-safe-claim-false'),
|
|
125
|
+
('bad/notsafe', 2, 'documented-not-thread-safe-claim-false'),
|
|
126
|
+
('testonly', 2, 'documented-thread-safe-claim-false-test-only'),
|
|
127
|
+
):
|
|
128
|
+
with self.subTest(name=name):
|
|
129
|
+
actual, output = self.run_cli(str(FIXTURES / name), '--json')
|
|
130
|
+
self.assertEqual(actual, code)
|
|
131
|
+
result = json.loads(output)
|
|
132
|
+
self.assertEqual(set(result), {'findings', 'verified_claims'})
|
|
133
|
+
self.assertEqual(len(result['findings']), int(rule is not None))
|
|
134
|
+
if rule:
|
|
135
|
+
self.assertEqual(result['findings'][0]['rule'], rule)
|
|
136
|
+
self.assertEqual(result['findings'][0]['severity'], 'MEDIUM' if name == 'testonly' else 'HIGH')
|
|
137
|
+
else:
|
|
138
|
+
self.assertEqual(len(result['verified_claims']), 1)
|
|
139
|
+
|
|
140
|
+
def test_threshold(self):
|
|
141
|
+
code, output = self.run_cli(str(FIXTURES / 'testonly'), '--fail-on', 'HIGH', '--json')
|
|
142
|
+
self.assertEqual(code, 0)
|
|
143
|
+
self.assertEqual(json.loads(output)['findings'][0]['severity'], 'MEDIUM')
|
|
144
|
+
|
|
145
|
+
def test_ignore(self):
|
|
146
|
+
self.assertEqual(len(audit(FIXTURES / 'bad/notsafe', ['main.py'])['findings']), 1)
|
|
147
|
+
self.write('README.md', 'is thread safe')
|
|
148
|
+
self.write('main.py', 'x = 1')
|
|
149
|
+
self.write('guard.py', 'x.acquire()')
|
|
150
|
+
self.assertEqual(audit(self.root)['findings'], [])
|
|
151
|
+
self.assertEqual(audit(self.root, ['guard.py'])['findings'][0]['rule'], 'documented-thread-safe-claim-false')
|
|
152
|
+
|
|
153
|
+
def test_no_source_both(self):
|
|
154
|
+
self.write('README.md', 'is thread safe. It is not thread safe.')
|
|
155
|
+
findings = audit(self.root)['findings']
|
|
156
|
+
self.assertEqual(len(findings), 2)
|
|
157
|
+
self.assertTrue(all(f['severity'] == 'HIGH' and f['evidence'] == [{'kind': 'no-source'}] for f in findings))
|
|
158
|
+
|
|
159
|
+
def test_both_independent_testonly(self):
|
|
160
|
+
self.write('README.md', 'is thread safe. It is not thread safe.')
|
|
161
|
+
self.write('main.py', 'x=1')
|
|
162
|
+
self.write('tests/test_guard.py', 'lock.acquire()')
|
|
163
|
+
findings = audit(self.root)['findings']
|
|
164
|
+
self.assertEqual(len(findings), 2)
|
|
165
|
+
self.assertTrue(all(f['severity'] == 'MEDIUM' for f in findings))
|
|
166
|
+
|
|
167
|
+
def test_notsafe_testonly(self):
|
|
168
|
+
self.write('README.md', 'is not thread safe')
|
|
169
|
+
self.write('main.py', 'x=1')
|
|
170
|
+
self.write('fixtures/a.py', 'lock.acquire()')
|
|
171
|
+
self.assertEqual(audit(self.root)['findings'][0]['rule'], 'documented-not-thread-safe-claim-false-test-only')
|
|
172
|
+
|
|
173
|
+
def test_no_claim(self):
|
|
174
|
+
self.write('main.py', 'lock.acquire()')
|
|
175
|
+
self.assertEqual(audit(self.root), {'findings': [], 'verified_claims': []})
|
|
176
|
+
|
|
177
|
+
def test_docs_only_sources(self):
|
|
178
|
+
self.write('README.md', 'No promises.')
|
|
179
|
+
self.write('docs/nested/guide.md', 'is thread-safe')
|
|
180
|
+
self.write('OTHER.md', 'is not thread-safe')
|
|
181
|
+
self.write('main.py', 'x=1')
|
|
182
|
+
result = audit(self.root)
|
|
183
|
+
self.assertEqual(len(result['findings']), 1)
|
|
184
|
+
self.assertEqual(result['findings'][0]['claims'][0]['path'], 'docs/nested/guide.md')
|
|
185
|
+
|
|
186
|
+
def test_exclusions(self):
|
|
187
|
+
self.write('README.md', 'is thread safe')
|
|
188
|
+
self.write('main.py', 'x=1')
|
|
189
|
+
for path in ('docs/guard.py', 'examples/guard.py'):
|
|
190
|
+
self.write(path, 'lock.acquire()')
|
|
191
|
+
self.assertEqual(audit(self.root)['findings'][0]['severity'], 'HIGH')
|
|
192
|
+
|
|
193
|
+
def test_test_patterns(self):
|
|
194
|
+
for path in ('test_guard.py', 'guard_test.py', 'nested/tests/guard.py', 'fixtures/guard.py'):
|
|
195
|
+
with self.subTest(path=path):
|
|
196
|
+
self.write(path, 'lock.acquire()')
|
|
197
|
+
self.write('main.py', 'x=1')
|
|
198
|
+
self.write('README.md', 'is thread safe')
|
|
199
|
+
self.assertEqual(audit(self.root)['findings'][0]['severity'], 'MEDIUM')
|
|
200
|
+
(self.root / path).unlink()
|
|
201
|
+
|
|
202
|
+
def test_symlinks(self):
|
|
203
|
+
self.write('README.md', 'is thread safe')
|
|
204
|
+
self.write('main.py', 'x=1')
|
|
205
|
+
with tempfile.TemporaryDirectory() as other:
|
|
206
|
+
outside = Path(other)
|
|
207
|
+
(outside / 'guard.py').write_text('lock.acquire()')
|
|
208
|
+
(self.root / 'guard.py').symlink_to(outside / 'guard.py')
|
|
209
|
+
(self.root / 'linked').symlink_to(outside, target_is_directory=True)
|
|
210
|
+
self.assertEqual(audit(self.root)['findings'][0]['severity'], 'HIGH')
|
|
211
|
+
|
|
212
|
+
def test_env_never_opened(self):
|
|
213
|
+
marker = 'ENV_MARKER_MUST_NOT_LEAK'
|
|
214
|
+
self.write('.env', marker)
|
|
215
|
+
self.write('README.md', 'is thread safe')
|
|
216
|
+
self.write('main.py', 'x=1')
|
|
217
|
+
original = Path.open
|
|
218
|
+
opened = []
|
|
219
|
+
def guarded(path, *args, **kwargs):
|
|
220
|
+
opened.append(path.name)
|
|
221
|
+
if path.name == '.env':
|
|
222
|
+
raise AssertionError('opened .env')
|
|
223
|
+
return original(path, *args, **kwargs)
|
|
224
|
+
with patch.object(Path, 'open', guarded):
|
|
225
|
+
code, output = self.run_cli(str(self.root), '--json')
|
|
226
|
+
self.assertEqual(code, 2)
|
|
227
|
+
self.assertNotIn('.env', opened)
|
|
228
|
+
self.assertNotIn(marker, output)
|
|
229
|
+
|
|
230
|
+
def test_all_evidence(self):
|
|
231
|
+
self.write('README.md', 'is not thread safe. It is not thread safe.')
|
|
232
|
+
self.write('main.py', 'import threading\nlock=threading.Lock()\nwith lock:\n lock.acquire()')
|
|
233
|
+
result = audit(self.root)['findings']
|
|
234
|
+
self.assertEqual(len(result), 1)
|
|
235
|
+
self.assertEqual(len(result[0]['evidence']), 3)
|
|
236
|
+
self.assertEqual(len(result[0]['claims']), 2)
|
|
237
|
+
|
|
238
|
+
def test_version(self):
|
|
239
|
+
output = io.StringIO()
|
|
240
|
+
with contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as exc:
|
|
241
|
+
main(['--version'])
|
|
242
|
+
self.assertEqual(exc.exception.code, 0)
|
|
243
|
+
self.assertEqual(output.getvalue().strip(), '0.1.0')
|
|
244
|
+
|
|
245
|
+
def test_usage_error(self):
|
|
246
|
+
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as exc:
|
|
247
|
+
main(['--unknown'])
|
|
248
|
+
self.assertEqual(exc.exception.code, 1)
|
|
249
|
+
|
|
250
|
+
def test_invalid_tree(self):
|
|
251
|
+
with contextlib.redirect_stderr(io.StringIO()):
|
|
252
|
+
self.assertEqual(main([str(self.root / 'missing')]), 1)
|
|
253
|
+
|
|
254
|
+
def test_bad_source(self):
|
|
255
|
+
self.write('main.py', 'def !!!')
|
|
256
|
+
with contextlib.redirect_stderr(io.StringIO()):
|
|
257
|
+
self.assertEqual(main([str(self.root)]), 1)
|
|
258
|
+
|
|
259
|
+
def test_verbose(self):
|
|
260
|
+
code, output = self.run_cli(str(FIXTURES / 'good/threadsafe'), '--verbose')
|
|
261
|
+
self.assertEqual(code, 0)
|
|
262
|
+
self.assertIn('verified: threadsafe', output)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == '__main__':
|
|
266
|
+
unittest.main()
|