provide-testkit 0.0.0.dev0__py3-none-any.whl
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.
- provide/__init__.py +3 -0
- provide/testkit/__init__.py +248 -0
- provide/testkit/archive/__init__.py +24 -0
- provide/testkit/archive/fixtures.py +217 -0
- provide/testkit/cli.py +229 -0
- provide/testkit/common/__init__.py +32 -0
- provide/testkit/common/fixtures.py +234 -0
- provide/testkit/crypto.py +163 -0
- provide/testkit/environment.py +79 -0
- provide/testkit/file/__init__.py +40 -0
- provide/testkit/file/content_fixtures.py +275 -0
- provide/testkit/file/directory_fixtures.py +105 -0
- provide/testkit/file/fixtures.py +49 -0
- provide/testkit/file/special_fixtures.py +141 -0
- provide/testkit/fixtures.py +52 -0
- provide/testkit/harness.py +122 -0
- provide/testkit/hub.py +22 -0
- provide/testkit/logger/__init__.py +39 -0
- provide/testkit/logger/hooks.py +100 -0
- provide/testkit/logger/reset.py +230 -0
- provide/testkit/main.py +22 -0
- provide/testkit/mocking/__init__.py +46 -0
- provide/testkit/mocking/fixtures.py +340 -0
- provide/testkit/process/__init__.py +48 -0
- provide/testkit/process/async_fixtures.py +410 -0
- provide/testkit/process/fixtures.py +54 -0
- provide/testkit/process/subprocess_fixtures.py +208 -0
- provide/testkit/quality/__init__.py +101 -0
- provide/testkit/quality/artifacts.py +360 -0
- provide/testkit/quality/base.py +158 -0
- provide/testkit/quality/cli.py +394 -0
- provide/testkit/quality/complexity/__init__.py +30 -0
- provide/testkit/quality/complexity/analyzer.py +392 -0
- provide/testkit/quality/complexity/fixture.py +196 -0
- provide/testkit/quality/coverage/__init__.py +36 -0
- provide/testkit/quality/coverage/fixture.py +236 -0
- provide/testkit/quality/coverage/reporter.py +150 -0
- provide/testkit/quality/coverage/tracker.py +313 -0
- provide/testkit/quality/decorators.py +380 -0
- provide/testkit/quality/documentation/__init__.py +29 -0
- provide/testkit/quality/documentation/checker.py +361 -0
- provide/testkit/quality/documentation/fixture.py +187 -0
- provide/testkit/quality/profiling/__init__.py +30 -0
- provide/testkit/quality/profiling/fixture.py +332 -0
- provide/testkit/quality/profiling/profiler.py +428 -0
- provide/testkit/quality/report.py +266 -0
- provide/testkit/quality/runner.py +319 -0
- provide/testkit/quality/security/__init__.py +29 -0
- provide/testkit/quality/security/fixture.py +196 -0
- provide/testkit/quality/security/scanner.py +338 -0
- provide/testkit/streams.py +54 -0
- provide/testkit/threading/__init__.py +38 -0
- provide/testkit/threading/basic_fixtures.py +103 -0
- provide/testkit/threading/data_fixtures.py +101 -0
- provide/testkit/threading/execution_fixtures.py +268 -0
- provide/testkit/threading/fixtures.py +50 -0
- provide/testkit/threading/sync_fixtures.py +98 -0
- provide/testkit/time/__init__.py +32 -0
- provide/testkit/time/fixtures.py +416 -0
- provide/testkit/transport/__init__.py +30 -0
- provide/testkit/transport/fixtures.py +278 -0
- provide_testkit-0.0.0.dev0.dist-info/METADATA +145 -0
- provide_testkit-0.0.0.dev0.dist-info/RECORD +66 -0
- provide_testkit-0.0.0.dev0.dist-info/WHEEL +5 -0
- provide_testkit-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- provide_testkit-0.0.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Security vulnerability scanner implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from provide.foundation.file import atomic_write_text, ensure_dir
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import bandit # type: ignore[import-untyped]
|
|
14
|
+
from bandit.core import (
|
|
15
|
+
config as bandit_config, # type: ignore[import-untyped]
|
|
16
|
+
manager as bandit_manager,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
BANDIT_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
BANDIT_AVAILABLE = False
|
|
22
|
+
bandit = None
|
|
23
|
+
bandit_config = None
|
|
24
|
+
bandit_manager = None
|
|
25
|
+
|
|
26
|
+
from ..base import QualityResult, QualityToolError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SecurityScanner:
|
|
30
|
+
"""Security vulnerability scanner using bandit and other tools.
|
|
31
|
+
|
|
32
|
+
Provides high-level interface for security analysis with automatic
|
|
33
|
+
artifact management and integration with the quality framework.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
37
|
+
"""Initialize security scanner.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
config: Security scanner configuration options
|
|
41
|
+
"""
|
|
42
|
+
if not BANDIT_AVAILABLE:
|
|
43
|
+
raise QualityToolError("Bandit not available. Install with: pip install bandit", tool="security")
|
|
44
|
+
|
|
45
|
+
self.config = config or {}
|
|
46
|
+
self.artifact_dir: Path | None = None
|
|
47
|
+
|
|
48
|
+
def analyze(self, path: Path, **kwargs: Any) -> QualityResult:
|
|
49
|
+
"""Run security analysis on the given path.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
path: Path to analyze
|
|
53
|
+
**kwargs: Additional options including artifact_dir
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
QualityResult with security analysis data
|
|
57
|
+
"""
|
|
58
|
+
self.artifact_dir = kwargs.get("artifact_dir", Path(".security"))
|
|
59
|
+
start_time = time.time()
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
# Run bandit security scan
|
|
63
|
+
result = self._run_bandit_scan(path)
|
|
64
|
+
result.execution_time = time.time() - start_time
|
|
65
|
+
|
|
66
|
+
# Generate artifacts
|
|
67
|
+
self._generate_artifacts(result)
|
|
68
|
+
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
except Exception as e:
|
|
72
|
+
return QualityResult(
|
|
73
|
+
tool="security",
|
|
74
|
+
passed=False,
|
|
75
|
+
details={"error": str(e), "error_type": type(e).__name__},
|
|
76
|
+
execution_time=time.time() - start_time,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _run_bandit_scan(self, path: Path) -> QualityResult:
|
|
80
|
+
"""Run bandit security scan."""
|
|
81
|
+
if not BANDIT_AVAILABLE:
|
|
82
|
+
raise QualityToolError("Bandit not available", tool="security")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
# Create bandit configuration
|
|
86
|
+
conf = bandit_config.BanditConfig()
|
|
87
|
+
|
|
88
|
+
# Apply custom configuration
|
|
89
|
+
self._apply_bandit_config(conf)
|
|
90
|
+
|
|
91
|
+
# Create bandit manager
|
|
92
|
+
b_mgr = bandit_manager.BanditManager(conf, "file")
|
|
93
|
+
|
|
94
|
+
# Discover files to scan
|
|
95
|
+
if path.is_file():
|
|
96
|
+
files_list = [str(path)]
|
|
97
|
+
else:
|
|
98
|
+
files_list = self._discover_python_files(path)
|
|
99
|
+
|
|
100
|
+
if not files_list:
|
|
101
|
+
return QualityResult(
|
|
102
|
+
tool="security",
|
|
103
|
+
passed=True,
|
|
104
|
+
score=100.0,
|
|
105
|
+
details={"message": "No Python files found to scan"},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Run the scan
|
|
109
|
+
b_mgr.discover_files(files_list)
|
|
110
|
+
b_mgr.run_tests()
|
|
111
|
+
|
|
112
|
+
# Process results
|
|
113
|
+
return self._process_bandit_results(b_mgr)
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
raise QualityToolError(f"Bandit scan failed: {e!s}", tool="security")
|
|
117
|
+
|
|
118
|
+
def _apply_bandit_config(self, conf: Any) -> None:
|
|
119
|
+
"""Apply custom configuration to bandit."""
|
|
120
|
+
# Bandit configuration is set differently than expected
|
|
121
|
+
# For now, we'll use the defaults and apply filtering later
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
def _discover_python_files(self, path: Path) -> list[str]:
|
|
125
|
+
"""Discover Python files to scan."""
|
|
126
|
+
excludes = self.config.get(
|
|
127
|
+
"exclude", ["*/tests/*", "*/test_*", "*/.venv/*", "*/venv/*", "*/__pycache__/*"]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
files = []
|
|
131
|
+
for py_file in path.rglob("*.py"):
|
|
132
|
+
# Check if file should be excluded
|
|
133
|
+
if any(py_file.match(pattern) for pattern in excludes):
|
|
134
|
+
continue
|
|
135
|
+
files.append(str(py_file))
|
|
136
|
+
|
|
137
|
+
return files
|
|
138
|
+
|
|
139
|
+
def _process_bandit_results(self, manager: Any) -> QualityResult:
|
|
140
|
+
"""Process bandit scan results into QualityResult."""
|
|
141
|
+
# Get issues
|
|
142
|
+
issues = manager.get_issue_list()
|
|
143
|
+
|
|
144
|
+
# Calculate metrics
|
|
145
|
+
total_files = len(manager.files_list)
|
|
146
|
+
issue_count = len(issues)
|
|
147
|
+
|
|
148
|
+
# Categorize by severity
|
|
149
|
+
severity_counts = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
|
150
|
+
confidence_counts = {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
|
151
|
+
|
|
152
|
+
for issue in issues:
|
|
153
|
+
severity_counts[issue.severity] += 1
|
|
154
|
+
confidence_counts[issue.confidence] += 1
|
|
155
|
+
|
|
156
|
+
# Calculate security score (0-100)
|
|
157
|
+
# Start at 100, deduct points based on severity
|
|
158
|
+
score = 100.0
|
|
159
|
+
score -= severity_counts["HIGH"] * 10 # High severity: -10 points each
|
|
160
|
+
score -= severity_counts["MEDIUM"] * 5 # Medium severity: -5 points each
|
|
161
|
+
score -= severity_counts["LOW"] * 1 # Low severity: -1 point each
|
|
162
|
+
score = max(0.0, score) # Don't go below 0
|
|
163
|
+
|
|
164
|
+
# Determine if passed based on configuration
|
|
165
|
+
max_high = self.config.get("max_high_severity", 0)
|
|
166
|
+
max_medium = self.config.get("max_medium_severity", 5)
|
|
167
|
+
min_score = self.config.get("min_score", 80.0)
|
|
168
|
+
|
|
169
|
+
passed = (
|
|
170
|
+
severity_counts["HIGH"] <= max_high
|
|
171
|
+
and severity_counts["MEDIUM"] <= max_medium
|
|
172
|
+
and score >= min_score
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Create detailed results
|
|
176
|
+
details = {
|
|
177
|
+
"total_files": total_files,
|
|
178
|
+
"total_issues": issue_count,
|
|
179
|
+
"severity_breakdown": severity_counts,
|
|
180
|
+
"confidence_breakdown": confidence_counts,
|
|
181
|
+
"score": score,
|
|
182
|
+
"thresholds": {
|
|
183
|
+
"max_high_severity": max_high,
|
|
184
|
+
"max_medium_severity": max_medium,
|
|
185
|
+
"min_score": min_score,
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Add issue details for reporting
|
|
190
|
+
if issues:
|
|
191
|
+
details["issues"] = [
|
|
192
|
+
{
|
|
193
|
+
"filename": issue.fname,
|
|
194
|
+
"line_number": issue.lineno,
|
|
195
|
+
"test_id": issue.test_id,
|
|
196
|
+
"test_name": issue.test,
|
|
197
|
+
"severity": issue.severity,
|
|
198
|
+
"confidence": issue.confidence,
|
|
199
|
+
"text": issue.text.strip(),
|
|
200
|
+
"code": issue.get_code(max_lines=3, tabbed=False),
|
|
201
|
+
}
|
|
202
|
+
for issue in issues[:20] # Limit to first 20 for readability
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
return QualityResult(tool="security", passed=passed, score=score, details=details)
|
|
206
|
+
|
|
207
|
+
def _generate_artifacts(self, result: QualityResult) -> None:
|
|
208
|
+
"""Generate security analysis artifacts.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
result: Result to add artifacts to
|
|
212
|
+
"""
|
|
213
|
+
if not self.artifact_dir:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
ensure_dir(self.artifact_dir)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
# Generate JSON report
|
|
220
|
+
json_file = self.artifact_dir / "security.json"
|
|
221
|
+
json_data = {
|
|
222
|
+
"tool": result.tool,
|
|
223
|
+
"passed": result.passed,
|
|
224
|
+
"score": result.score,
|
|
225
|
+
"details": result.details,
|
|
226
|
+
"execution_time": result.execution_time,
|
|
227
|
+
}
|
|
228
|
+
atomic_write_text(json_file, json.dumps(json_data, indent=2))
|
|
229
|
+
result.artifacts.append(json_file)
|
|
230
|
+
|
|
231
|
+
# Generate text summary
|
|
232
|
+
summary_file = self.artifact_dir / "security_summary.txt"
|
|
233
|
+
summary_report = self._generate_text_report(result)
|
|
234
|
+
atomic_write_text(summary_file, summary_report)
|
|
235
|
+
result.artifacts.append(summary_file)
|
|
236
|
+
|
|
237
|
+
# Generate detailed issues report if there are issues
|
|
238
|
+
if result.details.get("issues"):
|
|
239
|
+
issues_file = self.artifact_dir / "security_issues.txt"
|
|
240
|
+
issues_report = self._generate_issues_report(result)
|
|
241
|
+
atomic_write_text(issues_file, issues_report)
|
|
242
|
+
result.artifacts.append(issues_file)
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
# Add error to result details but don't fail
|
|
246
|
+
result.details["artifact_error"] = str(e)
|
|
247
|
+
|
|
248
|
+
def _generate_text_report(self, result: QualityResult) -> str:
|
|
249
|
+
"""Generate text summary report."""
|
|
250
|
+
lines = [
|
|
251
|
+
f"Security Analysis Report - {result.tool}",
|
|
252
|
+
"=" * 50,
|
|
253
|
+
f"Status: {'✅ PASSED' if result.passed else '❌ FAILED'}",
|
|
254
|
+
f"Security Score: {result.score}%",
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
details = result.details
|
|
258
|
+
if "total_files" in details:
|
|
259
|
+
lines.extend(
|
|
260
|
+
[
|
|
261
|
+
f"Files Scanned: {details['total_files']}",
|
|
262
|
+
f"Total Issues: {details['total_issues']}",
|
|
263
|
+
"",
|
|
264
|
+
"Severity Breakdown:",
|
|
265
|
+
]
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
severity = details.get("severity_breakdown", {})
|
|
269
|
+
for level, count in severity.items():
|
|
270
|
+
lines.append(f" {level}: {count}")
|
|
271
|
+
|
|
272
|
+
lines.extend(
|
|
273
|
+
[
|
|
274
|
+
"",
|
|
275
|
+
"Confidence Breakdown:",
|
|
276
|
+
]
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
confidence = details.get("confidence_breakdown", {})
|
|
280
|
+
for level, count in confidence.items():
|
|
281
|
+
lines.append(f" {level}: {count}")
|
|
282
|
+
|
|
283
|
+
if result.execution_time:
|
|
284
|
+
lines.append(f"\nExecution Time: {result.execution_time:.2f}s")
|
|
285
|
+
|
|
286
|
+
return "\n".join(lines)
|
|
287
|
+
|
|
288
|
+
def _generate_issues_report(self, result: QualityResult) -> str:
|
|
289
|
+
"""Generate detailed issues report."""
|
|
290
|
+
lines = ["Security Issues Report", "=" * 50, ""]
|
|
291
|
+
|
|
292
|
+
issues = result.details.get("issues", [])
|
|
293
|
+
for i, issue in enumerate(issues, 1):
|
|
294
|
+
lines.extend(
|
|
295
|
+
[
|
|
296
|
+
f"Issue #{i}:",
|
|
297
|
+
f" File: {issue['filename']}:{issue['line_number']}",
|
|
298
|
+
f" Test: {issue['test_name']} ({issue['test_id']})",
|
|
299
|
+
f" Severity: {issue['severity']} | Confidence: {issue['confidence']}",
|
|
300
|
+
f" Description: {issue['text']}",
|
|
301
|
+
"",
|
|
302
|
+
" Code:",
|
|
303
|
+
]
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Add code snippet with indentation
|
|
307
|
+
for code_line in issue["code"].split("\n"):
|
|
308
|
+
if code_line.strip():
|
|
309
|
+
lines.append(f" {code_line}")
|
|
310
|
+
|
|
311
|
+
lines.append("")
|
|
312
|
+
|
|
313
|
+
return "\n".join(lines)
|
|
314
|
+
|
|
315
|
+
def report(self, result: QualityResult, format: str = "terminal") -> str:
|
|
316
|
+
"""Generate report from QualityResult (implements QualityTool protocol).
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
result: Security result
|
|
320
|
+
format: Report format
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Formatted report
|
|
324
|
+
"""
|
|
325
|
+
if format == "terminal":
|
|
326
|
+
return self._generate_text_report(result)
|
|
327
|
+
elif format == "json":
|
|
328
|
+
return json.dumps(
|
|
329
|
+
{
|
|
330
|
+
"tool": result.tool,
|
|
331
|
+
"passed": result.passed,
|
|
332
|
+
"score": result.score,
|
|
333
|
+
"details": result.details,
|
|
334
|
+
},
|
|
335
|
+
indent=2,
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
return str(result.details)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#
|
|
2
|
+
# streams.py
|
|
3
|
+
#
|
|
4
|
+
"""
|
|
5
|
+
Stream Testing Utilities for Foundation.
|
|
6
|
+
|
|
7
|
+
Provides utilities for redirecting and managing streams during testing,
|
|
8
|
+
allowing tests to capture and control Foundation's output streams.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
# Import the actual stream management variables
|
|
14
|
+
from provide.foundation.streams.core import get_log_stream
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_log_stream_for_testing(stream: TextIO | None) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Set the log stream for testing purposes.
|
|
20
|
+
|
|
21
|
+
This allows tests to redirect Foundation's log output to a custom stream
|
|
22
|
+
(like StringIO) for capturing and verifying log messages.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
stream: Stream to redirect to, or None to reset to stderr
|
|
26
|
+
"""
|
|
27
|
+
# Import the actual implementation from streams.core
|
|
28
|
+
from provide.foundation.streams.core import (
|
|
29
|
+
set_log_stream_for_testing as _set_stream,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_set_stream(stream)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_current_log_stream() -> TextIO:
|
|
36
|
+
"""
|
|
37
|
+
Get the currently active log stream.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The current log stream being used by Foundation
|
|
41
|
+
"""
|
|
42
|
+
return get_log_stream()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def reset_log_stream() -> None:
|
|
46
|
+
"""Reset log stream back to stderr."""
|
|
47
|
+
set_log_stream_for_testing(None)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"get_current_log_stream",
|
|
52
|
+
"reset_log_stream",
|
|
53
|
+
"set_log_stream_for_testing",
|
|
54
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Threading testing utilities for the provide-io ecosystem.
|
|
3
|
+
|
|
4
|
+
Fixtures and utilities for testing multi-threaded code, thread synchronization,
|
|
5
|
+
and concurrent operations across any project that depends on provide.foundation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from provide.testkit.threading.fixtures import (
|
|
9
|
+
concurrent_executor,
|
|
10
|
+
deadlock_detector,
|
|
11
|
+
mock_thread,
|
|
12
|
+
test_thread,
|
|
13
|
+
thread_barrier,
|
|
14
|
+
thread_condition,
|
|
15
|
+
thread_event,
|
|
16
|
+
thread_exception_handler,
|
|
17
|
+
thread_local_storage,
|
|
18
|
+
thread_pool,
|
|
19
|
+
thread_safe_counter,
|
|
20
|
+
thread_safe_list,
|
|
21
|
+
thread_synchronizer,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"concurrent_executor",
|
|
26
|
+
"deadlock_detector",
|
|
27
|
+
"mock_thread",
|
|
28
|
+
"test_thread",
|
|
29
|
+
"thread_barrier",
|
|
30
|
+
"thread_condition",
|
|
31
|
+
"thread_event",
|
|
32
|
+
"thread_exception_handler",
|
|
33
|
+
"thread_local_storage",
|
|
34
|
+
"thread_pool",
|
|
35
|
+
"thread_safe_counter",
|
|
36
|
+
"thread_safe_list",
|
|
37
|
+
"thread_synchronizer",
|
|
38
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Basic threading test fixtures.
|
|
3
|
+
|
|
4
|
+
Core fixtures for creating threads, thread pools, mocks, and thread-local storage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
import threading
|
|
10
|
+
from unittest.mock import Mock
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def test_thread():
|
|
17
|
+
"""
|
|
18
|
+
Create a test thread with automatic cleanup.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Function to create and manage test threads.
|
|
22
|
+
"""
|
|
23
|
+
threads = []
|
|
24
|
+
|
|
25
|
+
def _create_thread(
|
|
26
|
+
target: Callable, args: tuple = (), kwargs: dict = None, daemon: bool = True
|
|
27
|
+
) -> threading.Thread:
|
|
28
|
+
"""
|
|
29
|
+
Create a test thread.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
target: Function to run in thread
|
|
33
|
+
args: Positional arguments for target
|
|
34
|
+
kwargs: Keyword arguments for target
|
|
35
|
+
daemon: Whether thread should be daemon
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Started thread instance
|
|
39
|
+
"""
|
|
40
|
+
kwargs = kwargs or {}
|
|
41
|
+
thread = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=daemon)
|
|
42
|
+
threads.append(thread)
|
|
43
|
+
thread.start()
|
|
44
|
+
return thread
|
|
45
|
+
|
|
46
|
+
yield _create_thread
|
|
47
|
+
|
|
48
|
+
# Cleanup: wait for all threads to complete
|
|
49
|
+
for thread in threads:
|
|
50
|
+
if thread.is_alive():
|
|
51
|
+
thread.join(timeout=1.0)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def thread_pool():
|
|
56
|
+
"""
|
|
57
|
+
Create a thread pool executor for testing.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
ThreadPoolExecutor instance with automatic cleanup.
|
|
61
|
+
"""
|
|
62
|
+
executor = ThreadPoolExecutor(max_workers=4)
|
|
63
|
+
yield executor
|
|
64
|
+
executor.shutdown(wait=True, cancel_futures=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def mock_thread():
|
|
69
|
+
"""
|
|
70
|
+
Create a mock thread for testing without actual threading.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Mock thread object.
|
|
74
|
+
"""
|
|
75
|
+
mock = Mock(spec=threading.Thread)
|
|
76
|
+
mock.is_alive.return_value = False
|
|
77
|
+
mock.daemon = False
|
|
78
|
+
mock.name = "MockThread"
|
|
79
|
+
mock.ident = 12345
|
|
80
|
+
mock.start = Mock()
|
|
81
|
+
mock.join = Mock()
|
|
82
|
+
mock.run = Mock()
|
|
83
|
+
|
|
84
|
+
return mock
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.fixture
|
|
88
|
+
def thread_local_storage():
|
|
89
|
+
"""
|
|
90
|
+
Create thread-local storage for testing.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Thread-local storage object.
|
|
94
|
+
"""
|
|
95
|
+
return threading.local()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"mock_thread",
|
|
100
|
+
"test_thread",
|
|
101
|
+
"thread_local_storage",
|
|
102
|
+
"thread_pool",
|
|
103
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thread-safe data structure test fixtures.
|
|
3
|
+
|
|
4
|
+
Fixtures for thread-safe lists, counters, and other data structures for testing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def thread_safe_list():
|
|
15
|
+
"""
|
|
16
|
+
Create a thread-safe list for collecting results.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Thread-safe list implementation.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
class ThreadSafeList:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self._list = []
|
|
25
|
+
self._lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
def append(self, item: Any):
|
|
28
|
+
"""Thread-safe append."""
|
|
29
|
+
with self._lock:
|
|
30
|
+
self._list.append(item)
|
|
31
|
+
|
|
32
|
+
def extend(self, items):
|
|
33
|
+
"""Thread-safe extend."""
|
|
34
|
+
with self._lock:
|
|
35
|
+
self._list.extend(items)
|
|
36
|
+
|
|
37
|
+
def get_all(self) -> list:
|
|
38
|
+
"""Get copy of all items."""
|
|
39
|
+
with self._lock:
|
|
40
|
+
return self._list.copy()
|
|
41
|
+
|
|
42
|
+
def clear(self):
|
|
43
|
+
"""Clear the list."""
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._list.clear()
|
|
46
|
+
|
|
47
|
+
def __len__(self) -> int:
|
|
48
|
+
with self._lock:
|
|
49
|
+
return len(self._list)
|
|
50
|
+
|
|
51
|
+
def __getitem__(self, index):
|
|
52
|
+
with self._lock:
|
|
53
|
+
return self._list[index]
|
|
54
|
+
|
|
55
|
+
return ThreadSafeList()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture
|
|
59
|
+
def thread_safe_counter():
|
|
60
|
+
"""
|
|
61
|
+
Create a thread-safe counter.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Thread-safe counter implementation.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
class ThreadSafeCounter:
|
|
68
|
+
def __init__(self, initial: int = 0):
|
|
69
|
+
self._value = initial
|
|
70
|
+
self._lock = threading.Lock()
|
|
71
|
+
|
|
72
|
+
def increment(self, amount: int = 1) -> int:
|
|
73
|
+
"""Thread-safe increment."""
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._value += amount
|
|
76
|
+
return self._value
|
|
77
|
+
|
|
78
|
+
def decrement(self, amount: int = 1) -> int:
|
|
79
|
+
"""Thread-safe decrement."""
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._value -= amount
|
|
82
|
+
return self._value
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def value(self) -> int:
|
|
86
|
+
"""Get current value."""
|
|
87
|
+
with self._lock:
|
|
88
|
+
return self._value
|
|
89
|
+
|
|
90
|
+
def reset(self, value: int = 0):
|
|
91
|
+
"""Reset counter."""
|
|
92
|
+
with self._lock:
|
|
93
|
+
self._value = value
|
|
94
|
+
|
|
95
|
+
return ThreadSafeCounter()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"thread_safe_counter",
|
|
100
|
+
"thread_safe_list",
|
|
101
|
+
]
|