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.
Files changed (26) hide show
  1. thread_safety_claim_audit-0.1.0/LICENSE +21 -0
  2. thread_safety_claim_audit-0.1.0/MANIFEST.in +3 -0
  3. thread_safety_claim_audit-0.1.0/PKG-INFO +173 -0
  4. thread_safety_claim_audit-0.1.0/README.md +163 -0
  5. thread_safety_claim_audit-0.1.0/pyproject.toml +18 -0
  6. thread_safety_claim_audit-0.1.0/setup.cfg +4 -0
  7. thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit/__init__.py +2 -0
  8. thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit/__main__.py +4 -0
  9. thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit/audit.py +208 -0
  10. thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit.egg-info/PKG-INFO +173 -0
  11. thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit.egg-info/SOURCES.txt +24 -0
  12. thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit.egg-info/dependency_links.txt +1 -0
  13. thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit.egg-info/entry_points.txt +2 -0
  14. thread_safety_claim_audit-0.1.0/src/thread_safety_claim_audit.egg-info/top_level.txt +1 -0
  15. thread_safety_claim_audit-0.1.0/tests/fixtures/bad/notsafe/README.md +1 -0
  16. thread_safety_claim_audit-0.1.0/tests/fixtures/bad/notsafe/main.py +4 -0
  17. thread_safety_claim_audit-0.1.0/tests/fixtures/bad/threadsafe/README.md +1 -0
  18. thread_safety_claim_audit-0.1.0/tests/fixtures/bad/threadsafe/main.py +3 -0
  19. thread_safety_claim_audit-0.1.0/tests/fixtures/good/notsafe/README.md +1 -0
  20. thread_safety_claim_audit-0.1.0/tests/fixtures/good/notsafe/main.py +3 -0
  21. thread_safety_claim_audit-0.1.0/tests/fixtures/good/threadsafe/README.md +1 -0
  22. thread_safety_claim_audit-0.1.0/tests/fixtures/good/threadsafe/main.py +4 -0
  23. thread_safety_claim_audit-0.1.0/tests/fixtures/testonly/README.md +1 -0
  24. thread_safety_claim_audit-0.1.0/tests/fixtures/testonly/main.py +1 -0
  25. thread_safety_claim_audit-0.1.0/tests/fixtures/testonly/tests/test_work.py +2 -0
  26. 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,3 @@
1
+ include LICENSE
2
+ recursive-include tests *.py *.md
3
+ recursive-include tests/fixtures *
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Documentation contract auditing."""
2
+ __version__ = '0.1.0'
@@ -0,0 +1,4 @@
1
+ from .audit import main
2
+
3
+ if __name__ == '__main__':
4
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ thread-safety-claim-audit = thread_safety_claim_audit.__main__:main
@@ -0,0 +1 @@
1
+ This library is not thread-safe.
@@ -0,0 +1,4 @@
1
+ import threading
2
+ lock = threading.Lock()
3
+ with lock:
4
+ value = 1
@@ -0,0 +1 @@
1
+ This library is thread-safe.
@@ -0,0 +1,3 @@
1
+ value = []
2
+ def add(x):
3
+ value.append(x)
@@ -0,0 +1 @@
1
+ This library is not thread-safe.
@@ -0,0 +1,3 @@
1
+ value = []
2
+ def add(x):
3
+ value.append(x)
@@ -0,0 +1 @@
1
+ This library is thread-safe.
@@ -0,0 +1,4 @@
1
+ import threading
2
+ lock = threading.Lock()
3
+ with lock:
4
+ value = 1
@@ -0,0 +1 @@
1
+ This library is thread-safe.
@@ -0,0 +1,2 @@
1
+ import threading
2
+ lock = threading.Lock()
@@ -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()