collab-runtime 0.2.9__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.
- collab/__init__.py +77 -0
- collab/__main__.py +11 -0
- collab_runtime-0.2.9.dist-info/METADATA +218 -0
- collab_runtime-0.2.9.dist-info/RECORD +82 -0
- collab_runtime-0.2.9.dist-info/WHEEL +5 -0
- collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
- collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
- collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
- scripts/cleanup.py +395 -0
- scripts/collab_git_hook.py +190 -0
- scripts/format_code.py +594 -0
- scripts/generate_tests.py +560 -0
- scripts/validate_code.py +1397 -0
- src/__init__.py +4 -0
- src/dashboard/index.html +1131 -0
- src/live_locks_watcher.py +1982 -0
- src/lock_client.py +4268 -0
- src/logging_config.py +259 -0
- src/main.py +436 -0
- tests/backend/__init__.py +0 -0
- tests/backend/functional/__init__.py +0 -0
- tests/backend/functional/test_package_imports.py +43 -0
- tests/backend/integration/__init__.py +0 -0
- tests/backend/integration/test_cli_contract_parity.py +220 -0
- tests/backend/performance/__init__.py +0 -0
- tests/backend/reliability/__init__.py +0 -0
- tests/backend/security/__init__.py +0 -0
- tests/backend/unit/live_locks_watcher/__init__.py +5 -0
- tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
- tests/backend/unit/live_locks_watcher/conftest.py +18 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
- tests/backend/unit/lock_client/__init__.py +1 -0
- tests/backend/unit/lock_client/_helpers.py +132 -0
- tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
- tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
- tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
- tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
- tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
- tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
- tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
- tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
- tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
- tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
- tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
- tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
- tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
- tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
- tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
- tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
- tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
- tests/backend/unit/scripts/__init__.py +1 -0
- tests/backend/unit/scripts/_helpers.py +42 -0
- tests/backend/unit/scripts/test_cleanup.py +285 -0
- tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
- tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
- tests/backend/unit/scripts/test_format_code.py +368 -0
- tests/backend/unit/scripts/test_format_code_ported.py +177 -0
- tests/backend/unit/scripts/test_generate_tests.py +305 -0
- tests/backend/unit/scripts/test_hook_templates.py +357 -0
- tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
- tests/backend/unit/scripts/test_validate_code.py +867 -0
- tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
- tests/backend/unit/test_entrypoints_main_run.py +83 -0
- tests/backend/unit/test_logging_config.py +529 -0
- tests/backend/unit/test_main_watch_pid_file.py +278 -0
- tests/conftest.py +167 -0
- tests/frontend/__init__.py +0 -0
- tests/frontend/jest/__init__.py +0 -0
- tests/frontend/playwright/__init__.py +0 -0
- tests/packaging/test_smoke_install.py +76 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate test stubs for new Python code.
|
|
3
|
+
|
|
4
|
+
Analyzes source files using AST and generates pytest test templates that follow
|
|
5
|
+
repository standards. Generated tests include proper fixtures, docstrings, and
|
|
6
|
+
the Arrange-Act-Assert pattern.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python scripts/generate_tests.py src/services/new_module.py
|
|
10
|
+
python scripts/generate_tests.py src/lock_client.py
|
|
11
|
+
python scripts/generate_tests.py scripts/cleanup.py --dry-run
|
|
12
|
+
python scripts/generate_tests.py --scan
|
|
13
|
+
python scripts/generate_tests.py src/ --scan
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import ast
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Iterable, List, Optional, Set, Tuple
|
|
22
|
+
|
|
23
|
+
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
|
|
24
|
+
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_relative_to(path: Path, other: Path) -> bool:
|
|
31
|
+
"""Return True when *path* is located under *other*."""
|
|
32
|
+
try:
|
|
33
|
+
path.relative_to(other)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return False
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CodeAnalyzer:
|
|
40
|
+
"""Analyzes Python source files to extract testable entities."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, filepath: str):
|
|
43
|
+
self.filepath = filepath
|
|
44
|
+
self.module_name = Path(filepath).stem
|
|
45
|
+
self.entities: List[Tuple[str, str]] = [] # (name, type)
|
|
46
|
+
|
|
47
|
+
def analyze(self) -> List[Tuple[str, str]]:
|
|
48
|
+
"""Extract module-level public (non-private) classes and functions."""
|
|
49
|
+
with open(self.filepath, "r", encoding="utf-8-sig") as f:
|
|
50
|
+
content = f.read()
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
tree = ast.parse(content)
|
|
54
|
+
except SyntaxError as e:
|
|
55
|
+
print(f"ā ļø Syntax error in {self.filepath}: {e}")
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
# Only inspect top-level statements to avoid nested/helper functions.
|
|
59
|
+
for node in tree.body:
|
|
60
|
+
if isinstance(node, ast.ClassDef) and not node.name.startswith("_"):
|
|
61
|
+
self.entities.append((node.name, "class"))
|
|
62
|
+
elif isinstance(
|
|
63
|
+
node, (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
64
|
+
) and not node.name.startswith("_"):
|
|
65
|
+
self.entities.append((node.name, "function"))
|
|
66
|
+
|
|
67
|
+
return self.entities
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestGenerator:
|
|
71
|
+
"""Generates pytest test templates following repo standards."""
|
|
72
|
+
|
|
73
|
+
CATEGORY_PATTERNS = {
|
|
74
|
+
"api.py": "functional",
|
|
75
|
+
"routes.py": "functional",
|
|
76
|
+
"lock_client.py": "unit",
|
|
77
|
+
"utils.py": "unit",
|
|
78
|
+
"models.py": "unit",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
source_file: str,
|
|
84
|
+
category: Optional[str] = None,
|
|
85
|
+
repo_root: Optional[Path] = None,
|
|
86
|
+
):
|
|
87
|
+
self.repo_root = (repo_root or ROOT).resolve()
|
|
88
|
+
self.source_path = Path(source_file).resolve()
|
|
89
|
+
self.source_file = str(self.source_path)
|
|
90
|
+
self.module_name = self.source_path.stem
|
|
91
|
+
self.relative_source_path = self._get_relative_source_path()
|
|
92
|
+
self.category = category or self._detect_category()
|
|
93
|
+
|
|
94
|
+
def _get_relative_source_path(self) -> Optional[Path]:
|
|
95
|
+
"""Return the repository-relative source path when available."""
|
|
96
|
+
if _is_relative_to(self.source_path, self.repo_root):
|
|
97
|
+
return self.source_path.relative_to(self.repo_root)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def _detect_category(self) -> str:
|
|
101
|
+
"""Auto-detect test category based on file path."""
|
|
102
|
+
filepath_lower = self._display_source_path().lower()
|
|
103
|
+
if filepath_lower.startswith("scripts/") or filepath_lower.startswith(
|
|
104
|
+
".collab/"
|
|
105
|
+
):
|
|
106
|
+
return "unit"
|
|
107
|
+
for pattern, cat in self.CATEGORY_PATTERNS.items():
|
|
108
|
+
if pattern in filepath_lower:
|
|
109
|
+
return cat
|
|
110
|
+
return "unit"
|
|
111
|
+
|
|
112
|
+
def _display_source_path(self) -> str:
|
|
113
|
+
"""Return a readable source path for messages and generated docstrings."""
|
|
114
|
+
if self.relative_source_path is not None:
|
|
115
|
+
return self.relative_source_path.as_posix()
|
|
116
|
+
return self.source_path.as_posix()
|
|
117
|
+
|
|
118
|
+
def _get_direct_import_module(self) -> Optional[str]:
|
|
119
|
+
"""Return the direct import module path when the source is importable."""
|
|
120
|
+
rel = self.relative_source_path
|
|
121
|
+
if rel is None:
|
|
122
|
+
path = self.source_file.replace("\\", "/")
|
|
123
|
+
if "src/" in path:
|
|
124
|
+
return "src." + path.split("src/")[1].replace("/", ".").replace(
|
|
125
|
+
".py", ""
|
|
126
|
+
)
|
|
127
|
+
return self.module_name
|
|
128
|
+
|
|
129
|
+
parts = rel.with_suffix("").parts
|
|
130
|
+
if not parts:
|
|
131
|
+
return self.module_name
|
|
132
|
+
if parts[0] == "src":
|
|
133
|
+
return ".".join(parts)
|
|
134
|
+
if len(parts) == 1:
|
|
135
|
+
return parts[0]
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def get_test_dir(self, output_root: Optional[Path] = None) -> Path:
|
|
139
|
+
"""Return the target directory for the generated test file."""
|
|
140
|
+
if output_root is not None:
|
|
141
|
+
return Path(output_root) / self.category
|
|
142
|
+
|
|
143
|
+
rel = self.relative_source_path
|
|
144
|
+
if rel is not None and rel.parts:
|
|
145
|
+
# Preserve module structure, e.g., src/foo.py -> tests/backend/unit/foo
|
|
146
|
+
if len(rel.parts) > 1 and rel.parts[0] == "src":
|
|
147
|
+
sub_path = Path(*rel.parts[1:-1])
|
|
148
|
+
return self.repo_root / "tests" / "backend" / self.category / sub_path
|
|
149
|
+
|
|
150
|
+
return self.repo_root / "tests" / "backend" / self.category
|
|
151
|
+
|
|
152
|
+
def get_test_file(self, output_root: Optional[Path] = None) -> Path:
|
|
153
|
+
"""Return the full destination path for the generated test file."""
|
|
154
|
+
return self.get_test_dir(output_root=output_root) / (
|
|
155
|
+
f"test_{self.module_name}.py"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def generate(self, entities: List[Tuple[str, str]]) -> str:
|
|
159
|
+
"""Generate test file content."""
|
|
160
|
+
if not entities:
|
|
161
|
+
return ""
|
|
162
|
+
|
|
163
|
+
# Add imports
|
|
164
|
+
classes = [e[0] for e in entities if e[1] == "class"]
|
|
165
|
+
functions = [e[0] for e in entities if e[1] == "function"]
|
|
166
|
+
import_names = sorted(set(classes + functions))
|
|
167
|
+
|
|
168
|
+
lines = [f'"""Tests for `{self._display_source_path()}`."""', ""]
|
|
169
|
+
lines.extend(self._build_import_block(import_names))
|
|
170
|
+
|
|
171
|
+
if lines[-1] != "":
|
|
172
|
+
lines.append("")
|
|
173
|
+
|
|
174
|
+
# Generate test classes
|
|
175
|
+
for class_name, _ in entities:
|
|
176
|
+
if class_name in classes:
|
|
177
|
+
lines.extend(self._generate_class_tests(class_name))
|
|
178
|
+
lines.append("")
|
|
179
|
+
|
|
180
|
+
# Generate function tests
|
|
181
|
+
function_entities = [e[0] for e in entities if e[1] == "function"]
|
|
182
|
+
if function_entities:
|
|
183
|
+
lines.extend(self._generate_function_tests(function_entities))
|
|
184
|
+
|
|
185
|
+
return "\n".join(lines)
|
|
186
|
+
|
|
187
|
+
def _build_import_block(self, import_names: List[str]) -> List[str]:
|
|
188
|
+
"""Build the import or module-loading section for generated tests."""
|
|
189
|
+
direct_import = self._get_direct_import_module()
|
|
190
|
+
|
|
191
|
+
if direct_import:
|
|
192
|
+
lines = ["import pytest", ""]
|
|
193
|
+
lines.append(f"from {direct_import} import (")
|
|
194
|
+
for name in import_names:
|
|
195
|
+
lines.append(f" {name},")
|
|
196
|
+
lines[-1] = lines[-1].rstrip(",")
|
|
197
|
+
lines.append(")")
|
|
198
|
+
lines.append("")
|
|
199
|
+
return lines
|
|
200
|
+
|
|
201
|
+
return self._build_path_loader_block(import_names)
|
|
202
|
+
|
|
203
|
+
def _get_import_path(self) -> str:
|
|
204
|
+
"""Convert file path to import path."""
|
|
205
|
+
rel = self.relative_source_path
|
|
206
|
+
if rel is not None:
|
|
207
|
+
rel_without_suffix = rel.with_suffix("")
|
|
208
|
+
if rel.parts and rel.parts[0] == "src":
|
|
209
|
+
return ".".join(rel_without_suffix.parts[1:])
|
|
210
|
+
return ".".join(rel_without_suffix.parts)
|
|
211
|
+
|
|
212
|
+
path = self.source_file.replace("\\", "/")
|
|
213
|
+
if "src/" in path:
|
|
214
|
+
return path.split("src/")[1].replace("/", ".").replace(".py", "")
|
|
215
|
+
return self.module_name
|
|
216
|
+
|
|
217
|
+
def _build_path_loader_block(self, import_names: List[str]) -> List[str]:
|
|
218
|
+
"""Build a loader block for non-importable repository files."""
|
|
219
|
+
separator = "# " + "-" * 75
|
|
220
|
+
root_docstring = (
|
|
221
|
+
' """Locate the repository root from the generated test file."""'
|
|
222
|
+
)
|
|
223
|
+
root_marker_check = (
|
|
224
|
+
' if (candidate / "pyproject.toml").exists() '
|
|
225
|
+
'and (candidate / "AGENTS.md").exists():'
|
|
226
|
+
)
|
|
227
|
+
spec_line = (
|
|
228
|
+
" spec = importlib.util.spec_from_file_location("
|
|
229
|
+
f'"{self.module_name}_ut", module_path)'
|
|
230
|
+
)
|
|
231
|
+
lines = [
|
|
232
|
+
"import importlib.util",
|
|
233
|
+
"from pathlib import Path",
|
|
234
|
+
"",
|
|
235
|
+
"import pytest",
|
|
236
|
+
"",
|
|
237
|
+
separator,
|
|
238
|
+
"# Module loading",
|
|
239
|
+
separator,
|
|
240
|
+
"",
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
if self.relative_source_path is not None:
|
|
244
|
+
lines.extend(
|
|
245
|
+
[
|
|
246
|
+
"def _find_repo_root() -> Path:",
|
|
247
|
+
root_docstring,
|
|
248
|
+
" current = Path(__file__).resolve().parent",
|
|
249
|
+
" for candidate in (current, *current.parents):",
|
|
250
|
+
root_marker_check,
|
|
251
|
+
" return candidate",
|
|
252
|
+
' raise RuntimeError("Could not locate the repository root.")',
|
|
253
|
+
"",
|
|
254
|
+
"",
|
|
255
|
+
]
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
lines.extend(
|
|
259
|
+
[
|
|
260
|
+
"def _load_module():",
|
|
261
|
+
f' """Load `{self._display_source_path()}` as a testable module."""',
|
|
262
|
+
f" module_path = {self._build_module_path_expression()}",
|
|
263
|
+
spec_line,
|
|
264
|
+
" assert spec and spec.loader",
|
|
265
|
+
" mod = importlib.util.module_from_spec(spec)",
|
|
266
|
+
" spec.loader.exec_module(mod)",
|
|
267
|
+
" return mod",
|
|
268
|
+
"",
|
|
269
|
+
"",
|
|
270
|
+
"module_under_test = _load_module()",
|
|
271
|
+
]
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
for name in import_names:
|
|
275
|
+
lines.append(f"{name} = module_under_test.{name}")
|
|
276
|
+
|
|
277
|
+
lines.append("")
|
|
278
|
+
return lines
|
|
279
|
+
|
|
280
|
+
def _build_module_path_expression(self) -> str:
|
|
281
|
+
"""Render a portable Path expression for the source module."""
|
|
282
|
+
rel = self.relative_source_path
|
|
283
|
+
if rel is None:
|
|
284
|
+
return f"Path({self.source_path.as_posix()!r})"
|
|
285
|
+
|
|
286
|
+
expr = "_find_repo_root()"
|
|
287
|
+
for part in rel.parts:
|
|
288
|
+
expr += f" / {part!r}"
|
|
289
|
+
return expr
|
|
290
|
+
|
|
291
|
+
def _generate_class_tests(self, class_name: str) -> List[str]:
|
|
292
|
+
"""Generate test class for a source class."""
|
|
293
|
+
lines = [
|
|
294
|
+
f"class Test{class_name}:",
|
|
295
|
+
f' """Test suite for {class_name}."""',
|
|
296
|
+
"",
|
|
297
|
+
" # Add fixtures as needed (app, client, db, etc.)",
|
|
298
|
+
"",
|
|
299
|
+
" def test_class_is_importable(self):",
|
|
300
|
+
f' """Ensure {class_name} is importable and usable by tests."""',
|
|
301
|
+
f" assert {class_name} is not None",
|
|
302
|
+
"",
|
|
303
|
+
]
|
|
304
|
+
return lines
|
|
305
|
+
|
|
306
|
+
def _generate_function_tests(self, functions: List[str]) -> List[str]:
|
|
307
|
+
"""Generate tests for module-level functions."""
|
|
308
|
+
lines = [
|
|
309
|
+
"class TestModuleFunctions:",
|
|
310
|
+
' """Test suite for module functions."""',
|
|
311
|
+
"",
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
for func_name in sorted(functions):
|
|
315
|
+
lines.extend(
|
|
316
|
+
[
|
|
317
|
+
f" def test_{func_name}_is_callable(self):",
|
|
318
|
+
f' """Smoke test: {func_name} is callable."""',
|
|
319
|
+
f" assert callable({func_name})",
|
|
320
|
+
"",
|
|
321
|
+
f" def test_{func_name}(self):",
|
|
322
|
+
f' """TODO: implement behavior test for {func_name}."""',
|
|
323
|
+
" # Arrange",
|
|
324
|
+
" # (set up test data)",
|
|
325
|
+
"",
|
|
326
|
+
" # Act",
|
|
327
|
+
f" # result = {func_name}(...)",
|
|
328
|
+
"",
|
|
329
|
+
" # Assert",
|
|
330
|
+
(
|
|
331
|
+
' pytest.skip("TODO: add assertions '
|
|
332
|
+
'for this generated test")'
|
|
333
|
+
),
|
|
334
|
+
"",
|
|
335
|
+
]
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return lines
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class TestDiscovery:
|
|
342
|
+
"""Find modules without test coverage."""
|
|
343
|
+
|
|
344
|
+
EXCLUDED_DIRS = {
|
|
345
|
+
".git",
|
|
346
|
+
".pytest_cache",
|
|
347
|
+
".venv",
|
|
348
|
+
"__pycache__",
|
|
349
|
+
"build",
|
|
350
|
+
"dist",
|
|
351
|
+
"htmlcov",
|
|
352
|
+
"instance",
|
|
353
|
+
"logs",
|
|
354
|
+
"node_modules",
|
|
355
|
+
"playwright-report",
|
|
356
|
+
"test_data",
|
|
357
|
+
"vscode-live-locks",
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
def __init__(self, repo_root: Optional[Path] = None):
|
|
361
|
+
self.repo_root = (repo_root or ROOT).resolve()
|
|
362
|
+
self.test_dir = str(self.repo_root / "tests")
|
|
363
|
+
|
|
364
|
+
def find_untested(self, src_dir: Optional[str] = None) -> List[str]:
|
|
365
|
+
"""Find Python modules without corresponding tests."""
|
|
366
|
+
scan_path = Path(src_dir).resolve() if src_dir else self.repo_root
|
|
367
|
+
|
|
368
|
+
if not scan_path.exists():
|
|
369
|
+
return []
|
|
370
|
+
|
|
371
|
+
if not _is_relative_to(scan_path, self.repo_root):
|
|
372
|
+
return self._find_untested_external(scan_path)
|
|
373
|
+
|
|
374
|
+
untested = []
|
|
375
|
+
for source_path in self._iter_repo_source_files(scan_path):
|
|
376
|
+
generator = TestGenerator(str(source_path), repo_root=self.repo_root)
|
|
377
|
+
if not generator.get_test_file().exists():
|
|
378
|
+
untested.append(source_path.relative_to(self.repo_root).as_posix())
|
|
379
|
+
|
|
380
|
+
return sorted(untested)
|
|
381
|
+
|
|
382
|
+
def _find_untested_external(self, src_dir: Path) -> List[str]:
|
|
383
|
+
"""Fallback scan mode for paths outside the repository root."""
|
|
384
|
+
tested = self._get_tested_modules()
|
|
385
|
+
untested = []
|
|
386
|
+
|
|
387
|
+
for source_path in self._iter_python_files(src_dir):
|
|
388
|
+
module = source_path.stem
|
|
389
|
+
if module not in tested:
|
|
390
|
+
untested.append(str(source_path))
|
|
391
|
+
|
|
392
|
+
return sorted(untested)
|
|
393
|
+
|
|
394
|
+
def _iter_repo_source_files(self, scan_path: Path) -> Iterable[Path]:
|
|
395
|
+
"""Yield repository Python files that should have tests."""
|
|
396
|
+
if scan_path == self.repo_root:
|
|
397
|
+
for child in sorted(self.repo_root.glob("*.py")):
|
|
398
|
+
if self._is_candidate_source(child):
|
|
399
|
+
yield child
|
|
400
|
+
|
|
401
|
+
for dirname in ("src", "scripts"):
|
|
402
|
+
target = self.repo_root / dirname
|
|
403
|
+
if target.exists():
|
|
404
|
+
yield from self._iter_python_files(target)
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
yield from self._iter_python_files(scan_path)
|
|
408
|
+
|
|
409
|
+
def _iter_python_files(self, scan_path: Path) -> Iterable[Path]:
|
|
410
|
+
"""Yield candidate Python files under *scan_path*."""
|
|
411
|
+
if scan_path.is_file():
|
|
412
|
+
if self._is_candidate_source(scan_path):
|
|
413
|
+
yield scan_path
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
for root, dirs, files in os.walk(scan_path):
|
|
417
|
+
dirs[:] = [d for d in dirs if not self._should_skip_dir(Path(root) / d)]
|
|
418
|
+
for filename in sorted(files):
|
|
419
|
+
candidate = Path(root) / filename
|
|
420
|
+
if self._is_candidate_source(candidate):
|
|
421
|
+
yield candidate.resolve()
|
|
422
|
+
|
|
423
|
+
def _should_skip_dir(self, path: Path) -> bool:
|
|
424
|
+
"""Return True when a directory should be excluded from scanning."""
|
|
425
|
+
name = path.name
|
|
426
|
+
if name in self.EXCLUDED_DIRS or name.endswith(".egg-info"):
|
|
427
|
+
return True
|
|
428
|
+
if name.startswith(".") and name not in {".collab"}:
|
|
429
|
+
return True
|
|
430
|
+
return False
|
|
431
|
+
|
|
432
|
+
def _is_candidate_source(self, path: Path) -> bool:
|
|
433
|
+
"""Return True when a Python file should be considered for test generation."""
|
|
434
|
+
if path.suffix != ".py":
|
|
435
|
+
return False
|
|
436
|
+
if path.name == "__init__.py" or path.name.startswith(("test_", "_")):
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
normalized_parts = set(path.parts)
|
|
440
|
+
if "tests" in normalized_parts:
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
return True
|
|
444
|
+
|
|
445
|
+
def _get_tested_modules(self) -> Set[str]:
|
|
446
|
+
"""Get set of modules that have tests."""
|
|
447
|
+
tested = set()
|
|
448
|
+
for root, dirs, files in os.walk(self.test_dir):
|
|
449
|
+
for file in files:
|
|
450
|
+
if file.startswith("test_") and file.endswith(".py"):
|
|
451
|
+
module = file.replace("test_", "").replace(".py", "")
|
|
452
|
+
tested.add(module)
|
|
453
|
+
return tested
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def main():
|
|
457
|
+
"""Main entry point."""
|
|
458
|
+
parser = argparse.ArgumentParser(
|
|
459
|
+
description="Generate pytest test stubs for Python code"
|
|
460
|
+
)
|
|
461
|
+
parser.add_argument("source_file", nargs="?", help="Path to source Python file")
|
|
462
|
+
parser.add_argument(
|
|
463
|
+
"--category",
|
|
464
|
+
choices=["unit", "functional", "integration"],
|
|
465
|
+
help="Test category (auto-detected if not specified)",
|
|
466
|
+
)
|
|
467
|
+
parser.add_argument(
|
|
468
|
+
"--dry-run", action="store_true", help="Print output without creating file"
|
|
469
|
+
)
|
|
470
|
+
parser.add_argument(
|
|
471
|
+
"--output-root",
|
|
472
|
+
help="Root directory for generated tests (default: tests/<category>)",
|
|
473
|
+
)
|
|
474
|
+
parser.add_argument(
|
|
475
|
+
"--scan",
|
|
476
|
+
action="store_true",
|
|
477
|
+
help="Scan the repository (or a provided directory) for untested modules",
|
|
478
|
+
)
|
|
479
|
+
parser.add_argument(
|
|
480
|
+
"--force", action="store_true", help="Overwrite existing test files"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
args = parser.parse_args()
|
|
484
|
+
|
|
485
|
+
# Scan mode
|
|
486
|
+
if args.scan:
|
|
487
|
+
scan_target = args.source_file
|
|
488
|
+
scope_label = scan_target or "repository"
|
|
489
|
+
print(f"\nš Untested modules in {scope_label}:\n")
|
|
490
|
+
discovery = TestDiscovery()
|
|
491
|
+
untested = discovery.find_untested(scan_target)
|
|
492
|
+
|
|
493
|
+
if untested:
|
|
494
|
+
for module in untested:
|
|
495
|
+
print(f" ⢠{module}")
|
|
496
|
+
print("\nRun: python scripts/generate_tests.py <module_path>\n")
|
|
497
|
+
else:
|
|
498
|
+
print("ā
All modules have tests!\n")
|
|
499
|
+
return
|
|
500
|
+
|
|
501
|
+
# Generate tests for specific file
|
|
502
|
+
if not args.source_file:
|
|
503
|
+
parser.print_help()
|
|
504
|
+
sys.exit(1)
|
|
505
|
+
|
|
506
|
+
if not os.path.exists(args.source_file):
|
|
507
|
+
print(f"ā File not found: {args.source_file}\n")
|
|
508
|
+
sys.exit(1)
|
|
509
|
+
|
|
510
|
+
source_path = Path(args.source_file).resolve()
|
|
511
|
+
if source_path.is_dir():
|
|
512
|
+
print("ā Source path is a directory. Use --scan to inspect directories.\n")
|
|
513
|
+
sys.exit(1)
|
|
514
|
+
|
|
515
|
+
# Analyze
|
|
516
|
+
analyzer = CodeAnalyzer(str(source_path))
|
|
517
|
+
entities = analyzer.analyze()
|
|
518
|
+
|
|
519
|
+
if not entities:
|
|
520
|
+
print(f"ā ļø No testable entities found in {source_path}\n")
|
|
521
|
+
return
|
|
522
|
+
|
|
523
|
+
print(f"š Found {len(entities)} testable entities\n")
|
|
524
|
+
|
|
525
|
+
# Generate
|
|
526
|
+
generator = TestGenerator(str(source_path), category=args.category)
|
|
527
|
+
test_code = generator.generate(entities)
|
|
528
|
+
|
|
529
|
+
# Determine output path (support custom output root)
|
|
530
|
+
test_file = generator.get_test_file(
|
|
531
|
+
output_root=Path(args.output_root).resolve() if args.output_root else None
|
|
532
|
+
)
|
|
533
|
+
test_dir = test_file.parent
|
|
534
|
+
|
|
535
|
+
# Handle existing files
|
|
536
|
+
if test_file.exists() and not args.force and not args.dry_run:
|
|
537
|
+
print(f"ā ļø File exists: {test_file}")
|
|
538
|
+
print(" Use --force to overwrite or --dry-run to preview\n")
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
# Dry-run mode
|
|
542
|
+
if args.dry_run:
|
|
543
|
+
print("š Generated test template:\n")
|
|
544
|
+
print("=" * 80)
|
|
545
|
+
print(test_code)
|
|
546
|
+
print("=" * 80)
|
|
547
|
+
print("\nā
Preview mode - no files created\n")
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
# Create file
|
|
551
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
552
|
+
with open(test_file, "w", encoding="utf-8") as f:
|
|
553
|
+
f.write(test_code)
|
|
554
|
+
|
|
555
|
+
print(f"ā
Created: {test_file}")
|
|
556
|
+
print(f"š Next: Fill in test logic and run: pytest {test_file}\n")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
if __name__ == "__main__":
|
|
560
|
+
main()
|