py-wlcli 0.8.0__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.
Files changed (320) hide show
  1. py_wlcli-0.8.0.dist-info/METADATA +139 -0
  2. py_wlcli-0.8.0.dist-info/RECORD +320 -0
  3. py_wlcli-0.8.0.dist-info/WHEEL +4 -0
  4. py_wlcli-0.8.0.dist-info/entry_points.txt +2 -0
  5. py_wlcli-0.8.0.dist-info/licenses/LICENSE +21 -0
  6. wl_cli/__init__.py +27 -0
  7. wl_cli/__main__.py +130 -0
  8. wl_cli/capabilities.py +79 -0
  9. wl_cli/cc/__init__.py +5 -0
  10. wl_cli/cc/checks.py +1019 -0
  11. wl_cli/cc/cli.py +370 -0
  12. wl_cli/cc/mutate.py +1350 -0
  13. wl_cli/cli_click.py +156 -0
  14. wl_cli/commands/__init__.py +84 -0
  15. wl_cli/commands/base.py +80 -0
  16. wl_cli/commands/buildcommands/__init__.py +1 -0
  17. wl_cli/commands/buildcommands/build.py +71 -0
  18. wl_cli/commands/buildcommands/build_utils.py +47 -0
  19. wl_cli/commands/buildcommands/builders/__init__.py +18 -0
  20. wl_cli/commands/buildcommands/builders/cleanup.py +35 -0
  21. wl_cli/commands/buildcommands/builders/coordinator.py +122 -0
  22. wl_cli/commands/buildcommands/builders/environment.py +81 -0
  23. wl_cli/commands/buildcommands/builders/rust_builder.py +39 -0
  24. wl_cli/commands/buildcommands/builders/stubs.py +58 -0
  25. wl_cli/commands/buildcommands/dist.py +326 -0
  26. wl_cli/commands/buildcommands/test.py +89 -0
  27. wl_cli/commands/clean/__init__.py +1 -0
  28. wl_cli/commands/clean/build.py +151 -0
  29. wl_cli/commands/clean/clean.py +294 -0
  30. wl_cli/commands/clean/clean_utils.py +77 -0
  31. wl_cli/commands/clean/core.py +236 -0
  32. wl_cli/commands/clean/dead_code/__init__.py +0 -0
  33. wl_cli/commands/clean/dead_code/dead_code_command.py +70 -0
  34. wl_cli/commands/clean/dead_code/detector.py +161 -0
  35. wl_cli/commands/clean/dead_code/fixer.py +176 -0
  36. wl_cli/commands/clean/dead_code/reporter.py +295 -0
  37. wl_cli/commands/clean/dead_code/utils.py +0 -0
  38. wl_cli/commands/clean/lfs.py +215 -0
  39. wl_cli/commands/clean/python.py +81 -0
  40. wl_cli/commands/clean/rust.py +91 -0
  41. wl_cli/commands/clean/utils/__init__.py +34 -0
  42. wl_cli/commands/clean/venv.py +91 -0
  43. wl_cli/commands/cleanup.py +73 -0
  44. wl_cli/commands/click_base.py +79 -0
  45. wl_cli/commands/config/__init__.py +1 -0
  46. wl_cli/commands/config/config.py +291 -0
  47. wl_cli/commands/debug/__init__.py +6 -0
  48. wl_cli/commands/debug/debug.py +309 -0
  49. wl_cli/commands/debug/debug_cache.py +86 -0
  50. wl_cli/commands/debug/debug_subagent.py +156 -0
  51. wl_cli/commands/debug/output_formatter.py +165 -0
  52. wl_cli/commands/debug/source_hasher.py +92 -0
  53. wl_cli/commands/format/__init__.py +13 -0
  54. wl_cli/commands/format/format_cache_adapter.py +125 -0
  55. wl_cli/commands/format/format_command.py +197 -0
  56. wl_cli/commands/format/format_coordinator.py +191 -0
  57. wl_cli/commands/format/format_executor.py +470 -0
  58. wl_cli/commands/format/format_outcome.py +44 -0
  59. wl_cli/commands/format/format_path_handler.py +1016 -0
  60. wl_cli/commands/format/format_progress.py +62 -0
  61. wl_cli/commands/format/format_reporter.py +31 -0
  62. wl_cli/commands/format/python_formatter.py +506 -0
  63. wl_cli/commands/format/rust_formatter.py +106 -0
  64. wl_cli/commands/gate/__init__.py +1 -0
  65. wl_cli/commands/gate/gate_command.py +862 -0
  66. wl_cli/commands/gate/preflight.py +445 -0
  67. wl_cli/commands/git/__init__.py +1 -0
  68. wl_cli/commands/git/config.py +169 -0
  69. wl_cli/commands/git/git_facts.py +60 -0
  70. wl_cli/commands/git/git_flow.py +86 -0
  71. wl_cli/commands/git/hook.py +112 -0
  72. wl_cli/commands/git/policy.py +216 -0
  73. wl_cli/commands/git/push_update.py +82 -0
  74. wl_cli/commands/initenv/__init__.py +1 -0
  75. wl_cli/commands/initenv/common/__init__.py +23 -0
  76. wl_cli/commands/initenv/common/check_git_bash.py +78 -0
  77. wl_cli/commands/initenv/common/error_handler.py +68 -0
  78. wl_cli/commands/initenv/common/exceptions.py +52 -0
  79. wl_cli/commands/initenv/components/__init__.py +4 -0
  80. wl_cli/commands/initenv/components/cargo_sync.py +240 -0
  81. wl_cli/commands/initenv/components/config_manager.py +106 -0
  82. wl_cli/commands/initenv/components/directory_utils.py +71 -0
  83. wl_cli/commands/initenv/components/git/__init__.py +9 -0
  84. wl_cli/commands/initenv/components/git/branch_creator.py +143 -0
  85. wl_cli/commands/initenv/components/git/git_flow_interface.py +24 -0
  86. wl_cli/commands/initenv/components/git/git_flow_manager.py +159 -0
  87. wl_cli/commands/initenv/components/git/git_lfs_manager.py +272 -0
  88. wl_cli/commands/initenv/components/git/git_utility.py +254 -0
  89. wl_cli/commands/initenv/components/git/gitignore_manager.py +53 -0
  90. wl_cli/commands/initenv/components/git_initializer.py +212 -0
  91. wl_cli/commands/initenv/components/i18n_manager.py +104 -0
  92. wl_cli/commands/initenv/components/log_manager.py +241 -0
  93. wl_cli/commands/initenv/components/project_structure/__init__.py +23 -0
  94. wl_cli/commands/initenv/components/project_structure/config_generator.py +360 -0
  95. wl_cli/commands/initenv/components/project_structure/directory_creator.py +184 -0
  96. wl_cli/commands/initenv/components/project_structure/hook_source.py +110 -0
  97. wl_cli/commands/initenv/components/project_structure/hooks_manager.py +995 -0
  98. wl_cli/commands/initenv/components/project_structure/readme_handler.py +56 -0
  99. wl_cli/commands/initenv/components/project_structure/rust_files_handler.py +30 -0
  100. wl_cli/commands/initenv/components/project_structure/setup_handler.py +103 -0
  101. wl_cli/commands/initenv/components/project_structure/sop_generator.py +73 -0
  102. wl_cli/commands/initenv/components/project_structure/template_copier.py +104 -0
  103. wl_cli/commands/initenv/components/project_structure/template_manager.py +195 -0
  104. wl_cli/commands/initenv/components/pyproject_generator.py +194 -0
  105. wl_cli/commands/initenv/components/rust_initializer.py +145 -0
  106. wl_cli/commands/initenv/components/symlink_cleaner.py +99 -0
  107. wl_cli/commands/initenv/components/symlink_manager.py +52 -0
  108. wl_cli/commands/initenv/components/venv_manager.py +118 -0
  109. wl_cli/commands/initenv/initenv.py +169 -0
  110. wl_cli/commands/initenv/services/__init__.py +11 -0
  111. wl_cli/commands/initenv/services/environment_initializer.py +288 -0
  112. wl_cli/commands/initenv/services/git_lfs_initializer.py +26 -0
  113. wl_cli/commands/initenv/services/project_structure_initializer.py +52 -0
  114. wl_cli/commands/lfs/README.md +221 -0
  115. wl_cli/commands/lfs/__init__.py +5 -0
  116. wl_cli/commands/lfs/cli.py +106 -0
  117. wl_cli/commands/lfs/config.py +164 -0
  118. wl_cli/commands/lfs/hook.py +61 -0
  119. wl_cli/commands/lfs/initializer.py +531 -0
  120. wl_cli/commands/lfs/manager.py +95 -0
  121. wl_cli/commands/lfs/policy.py +165 -0
  122. wl_cli/commands/lfs/watcher.py +294 -0
  123. wl_cli/commands/lint/__init__.py +1 -0
  124. wl_cli/commands/lint/lint_command.py +297 -0
  125. wl_cli/commands/lint/lint_executor.py +409 -0
  126. wl_cli/commands/lint/lint_reporter.py +579 -0
  127. wl_cli/commands/lint/lint_run.py +258 -0
  128. wl_cli/commands/lint/mypy_config.py +90 -0
  129. wl_cli/commands/registry.py +103 -0
  130. wl_cli/commands/self/__init__.py +7 -0
  131. wl_cli/commands/self/common/__init__.py +8 -0
  132. wl_cli/commands/self/common/capabilities.py +30 -0
  133. wl_cli/commands/self/common/config.py +27 -0
  134. wl_cli/commands/self/common/version_cache.py +85 -0
  135. wl_cli/commands/self/handlers/__init__.py +19 -0
  136. wl_cli/commands/self/handlers/base_handler.py +28 -0
  137. wl_cli/commands/self/handlers/install_handler.py +85 -0
  138. wl_cli/commands/self/handlers/status_handler.py +66 -0
  139. wl_cli/commands/self/handlers/uninstall_handler.py +58 -0
  140. wl_cli/commands/self/handlers/update_handler.py +102 -0
  141. wl_cli/commands/self/handlers/version_handler.py +16 -0
  142. wl_cli/commands/self/self_command.py +237 -0
  143. wl_cli/commands/self/services/__init__.py +15 -0
  144. wl_cli/commands/self/services/installation_manager.py +187 -0
  145. wl_cli/commands/self/services/local_installer.py +165 -0
  146. wl_cli/commands/self/services/pypi_updater.py +278 -0
  147. wl_cli/commands/self/services/windows_update.py +174 -0
  148. wl_cli/commands/testcommands/__init__.py +8 -0
  149. wl_cli/commands/testcommands/base_dependency_analyzer.py +198 -0
  150. wl_cli/commands/testcommands/batch_processor.py +278 -0
  151. wl_cli/commands/testcommands/cache_manager.py +1156 -0
  152. wl_cli/commands/testcommands/coverage_analyzer.py +955 -0
  153. wl_cli/commands/testcommands/coverage_cache_manager.py +183 -0
  154. wl_cli/commands/testcommands/progress_manager.py +79 -0
  155. wl_cli/commands/testcommands/python_env_detector.py +207 -0
  156. wl_cli/commands/testcommands/report_generator.py +101 -0
  157. wl_cli/commands/testcommands/single_test_executor.py +250 -0
  158. wl_cli/commands/testcommands/speed_checker.py +118 -0
  159. wl_cli/commands/testcommands/test_command_builder.py +165 -0
  160. wl_cli/commands/testcommands/test_commands.py +786 -0
  161. wl_cli/commands/testcommands/test_reporter.py +181 -0
  162. wl_cli/commands/testcommands/test_result_handler.py +96 -0
  163. wl_cli/commands/testcommands/test_runner.py +1343 -0
  164. wl_cli/commands/testcommands/test_utils.py +197 -0
  165. wl_cli/commands/testcommands/utils/__init__.py +17 -0
  166. wl_cli/commands/testcommands/utils/coverage_parser.py +98 -0
  167. wl_cli/commands/testcommands/utils/env_info.py +36 -0
  168. wl_cli/commands/testcommands/utils/junit_parser.py +75 -0
  169. wl_cli/commands/testcommands/utils/markdown_utils.py +20 -0
  170. wl_cli/commands/testcommands/utils/project_root.py +61 -0
  171. wl_cli/commands/update/__init__.py +9 -0
  172. wl_cli/commands/update/services/__init__.py +21 -0
  173. wl_cli/commands/update/services/backup_manager.py +207 -0
  174. wl_cli/commands/update/services/component_manager.py +83 -0
  175. wl_cli/commands/update/services/conflict_resolver.py +189 -0
  176. wl_cli/commands/update/services/vendors_updater.py +85 -0
  177. wl_cli/commands/update/services/version_tracker.py +127 -0
  178. wl_cli/commands/update/update_command.py +270 -0
  179. wl_cli/commands/update/utils/__init__.py +3 -0
  180. wl_cli/commands/version/__init__.py +9 -0
  181. wl_cli/commands/version/version_command.py +155 -0
  182. wl_cli/commands/workflow.py +477 -0
  183. wl_cli/env/__init__.py +5 -0
  184. wl_cli/env/cli.py +167 -0
  185. wl_cli/env_status.py +157 -0
  186. wl_cli/envelope.py +91 -0
  187. wl_cli/exceptions.py +85 -0
  188. wl_cli/lib/__init__.py +3 -0
  189. wl_cli/lib/rust_utils.py +35 -0
  190. wl_cli/mcp/__init__.py +6 -0
  191. wl_cli/mcp/capabilities.py +19 -0
  192. wl_cli/mcp/cli.py +22 -0
  193. wl_cli/mcp/gitlab.py +553 -0
  194. wl_cli/mcp/k8s.py +344 -0
  195. wl_cli/mcp/protocol.py +159 -0
  196. wl_cli/platform.py +139 -0
  197. wl_cli/quality/__init__.py +18 -0
  198. wl_cli/utils/__init__.py +7 -0
  199. wl_cli/utils/cache.py +491 -0
  200. wl_cli/utils/cache_ledger.py +355 -0
  201. wl_cli/utils/cache_store.py +546 -0
  202. wl_cli/utils/config/__init__.py +27 -0
  203. wl_cli/utils/config/config_loader.py +163 -0
  204. wl_cli/utils/config/core.py +862 -0
  205. wl_cli/utils/config/defaults.py +142 -0
  206. wl_cli/utils/config/env_exporter.py +86 -0
  207. wl_cli/utils/config/env_parser.py +108 -0
  208. wl_cli/utils/config/validation.py +280 -0
  209. wl_cli/utils/error_handler.py +165 -0
  210. wl_cli/utils/file_copier.py +56 -0
  211. wl_cli/utils/file_operations.py +506 -0
  212. wl_cli/utils/language_utils.py +122 -0
  213. wl_cli/utils/lazy_import.py +240 -0
  214. wl_cli/utils/log_levels.py +35 -0
  215. wl_cli/utils/log_rotators.py +74 -0
  216. wl_cli/utils/logging.py +458 -0
  217. wl_cli/utils/output_context.py +142 -0
  218. wl_cli/utils/performance/__init__.py +1 -0
  219. wl_cli/utils/performance/decorators.py +111 -0
  220. wl_cli/utils/performance/exporter.py +38 -0
  221. wl_cli/utils/performance/models.py +16 -0
  222. wl_cli/utils/performance/monitor.py +161 -0
  223. wl_cli/utils/performance_monitor.py +32 -0
  224. wl_cli/utils/platform_adapter.py +131 -0
  225. wl_cli/utils/pre_commit.py +266 -0
  226. wl_cli/utils/pre_commit_config.py +72 -0
  227. wl_cli/utils/root.py +521 -0
  228. wl_cli/utils/root_fallback.py +256 -0
  229. wl_cli/utils/run_evidence.py +236 -0
  230. wl_cli/utils/structured_logger.py +18 -0
  231. wl_cli/utils/structured_logging/__init__.py +19 -0
  232. wl_cli/utils/structured_logging/config.py +337 -0
  233. wl_cli/utils/structured_logging/core.py +177 -0
  234. wl_cli/utils/structured_logging/env.py +56 -0
  235. wl_cli/utils/structured_logging/filtering.py +75 -0
  236. wl_cli/utils/structured_logging/filters.py +55 -0
  237. wl_cli/utils/structured_logging/formatters.py +251 -0
  238. wl_cli/utils/structured_logging/handlers.py +308 -0
  239. wl_cli/utils/structured_logging/logger.py +354 -0
  240. wl_cli/utils/subprocess.py +71 -0
  241. wl_cli/utils/subprocess_utils.py +260 -0
  242. wl_cli/utils/test_utils/__init__.py +5 -0
  243. wl_cli/utils/test_utils/test_filter.py +36 -0
  244. wl_cli/utils/uv_run.py +79 -0
  245. wl_cli/utils/uv_tool.py +117 -0
  246. wl_cli/utils/uv_tree.py +71 -0
  247. wl_cli/utils/uv_workspace.py +174 -0
  248. wl_cli/utils/version/__init__.py +15 -0
  249. wl_cli/utils/version/pypi_version_checker.py +95 -0
  250. wl_cli/utils/version/version_comparator.py +133 -0
  251. wl_cli/utils/version/version_detectors.py +312 -0
  252. wl_cli/utils/version/version_service.py +131 -0
  253. wl_cli/utils/version/version_updater.py +198 -0
  254. wl_cli/utils/wl_dir_updater.py +1440 -0
  255. wl_cli/utils/workspace_detector/__init__.py +28 -0
  256. wl_cli/utils/workspace_detector/core.py +161 -0
  257. wl_cli/utils/workspace_detector/detection_strategies.py +107 -0
  258. wl_cli/utils/workspace_detector/exceptions.py +5 -0
  259. wl_cli/utils/workspace_detector/types.py +17 -0
  260. wl_cli/utils/workspace_detector/venv_resolver.py +84 -0
  261. wl_cli/vendors/config/.codespellrc +2 -0
  262. wl_cli/vendors/config/dependencies.toml +10 -0
  263. wl_cli/vendors/config/mypy.ini +62 -0
  264. wl_cli/vendors/config/pyproject_defaults.toml +28 -0
  265. wl_cli/vendors/config/template_config.toml +24 -0
  266. wl_cli/vendors/config/tool_configs.toml +76 -0
  267. wl_cli/vendors/git/.gitignore +122 -0
  268. wl_cli/vendors/git/.pre-commit-config.yaml +37 -0
  269. wl_cli/vendors/hooks/applypatch-msg.sample +15 -0
  270. wl_cli/vendors/hooks/commit-msg.sample +24 -0
  271. wl_cli/vendors/hooks/fsmonitor-watchman.sample +174 -0
  272. wl_cli/vendors/hooks/post-update.sample +8 -0
  273. wl_cli/vendors/hooks/pre-applypatch.sample +14 -0
  274. wl_cli/vendors/hooks/pre-commit +45 -0
  275. wl_cli/vendors/hooks/pre-commit.legacy +31 -0
  276. wl_cli/vendors/hooks/pre-commit.sample +49 -0
  277. wl_cli/vendors/hooks/pre-merge-commit.sample +13 -0
  278. wl_cli/vendors/hooks/pre-push.sample +60 -0
  279. wl_cli/vendors/hooks/pre-rebase.sample +169 -0
  280. wl_cli/vendors/hooks/pre-receive.sample +24 -0
  281. wl_cli/vendors/hooks/prepare-commit-msg.sample +42 -0
  282. wl_cli/vendors/hooks/push-to-checkout.sample +78 -0
  283. wl_cli/vendors/hooks/sendemail-validate.sample +77 -0
  284. wl_cli/vendors/hooks/unix/applypatch-msg.sample +15 -0
  285. wl_cli/vendors/hooks/unix/check_lockfile.py +58 -0
  286. wl_cli/vendors/hooks/unix/check_lockfile.sh +36 -0
  287. wl_cli/vendors/hooks/unix/commit-msg.sample +24 -0
  288. wl_cli/vendors/hooks/unix/fsmonitor-watchman.sample +174 -0
  289. wl_cli/vendors/hooks/unix/post-update.sample +8 -0
  290. wl_cli/vendors/hooks/unix/pre-applypatch.sample +14 -0
  291. wl_cli/vendors/hooks/unix/pre-commit +190 -0
  292. wl_cli/vendors/hooks/unix/pre-commit.legacy +31 -0
  293. wl_cli/vendors/hooks/unix/pre-commit.py +81 -0
  294. wl_cli/vendors/hooks/unix/pre-commit.sample +49 -0
  295. wl_cli/vendors/hooks/unix/pre-merge-commit.sample +13 -0
  296. wl_cli/vendors/hooks/unix/pre-push +417 -0
  297. wl_cli/vendors/hooks/unix/pre-push.sample +53 -0
  298. wl_cli/vendors/hooks/unix/pre-rebase.sample +169 -0
  299. wl_cli/vendors/hooks/unix/pre-receive.sample +24 -0
  300. wl_cli/vendors/hooks/unix/prepare-commit-msg.sample +42 -0
  301. wl_cli/vendors/hooks/update.sample +128 -0
  302. wl_cli/vendors/hooks/win/work.bat +63 -0
  303. wl_cli/vendors/project/.gitignore +90 -0
  304. wl_cli/vendors/project/.python-version +1 -0
  305. wl_cli/vendors/project/LICENSE +21 -0
  306. wl_cli/vendors/project/README.md +0 -0
  307. wl_cli/vendors/project/pyproject.toml +27 -0
  308. wl_cli/vendors/project/pyproject_no_workspace.toml +16 -0
  309. wl_cli/vendors/project/pyproject_workspace.toml +14 -0
  310. wl_cli/vendors/readme/README.md +120 -0
  311. wl_cli/vendors/rust/Cargo.toml +14 -0
  312. wl_cli/vendors/rust/lib/__init__.py +3 -0
  313. wl_cli/vendors/rust/lib/rust_utils.py +31 -0
  314. wl_cli/vendors/rust/src/lib.rs +13 -0
  315. wl_cli/vendors/rust/src/main.rs +3 -0
  316. wl_cli/vendors/rust/tests/test_rust_fallback.py +40 -0
  317. wl_cli/vendors/sop_doc/git-dev-sop.md +220 -0
  318. wl_cli/vendors/sop_doc/industrial-standard-sop.md +145 -0
  319. wl_cli/vendors/sop_doc/python-sop.md +342 -0
  320. wl_cli/vendors/sop_doc/rust-readme.md +219 -0
@@ -0,0 +1,786 @@
1
+ """
2
+ Test command implementation for wl tool.
3
+ """
4
+
5
+ import fnmatch
6
+ import json
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import click
14
+
15
+ from ...utils.config import ConfigProvider
16
+ from ...utils.output_context import (
17
+ get_output_config,
18
+ is_concise_mode,
19
+ )
20
+ from ...utils.root import Root, RootKind
21
+ from ..base import BaseCommand
22
+ from .cache_manager import CacheManager
23
+ from .coverage_analyzer import (
24
+ CoverageAnalyzer,
25
+ CoverageStats,
26
+ collect_coverage_inputs,
27
+ )
28
+ from .coverage_cache_manager import CoverageCacheManager
29
+ from .progress_manager import ProgressManager
30
+
31
+ # Import modular components
32
+ from .python_env_detector import PythonEnvDetector
33
+ from .test_command_builder import TestCommandBuilder
34
+ from .test_result_handler import TestResultHandler
35
+ from .test_runner import TestRunner, _iter_python_files
36
+ from .test_utils import _ensure_pytest_available, _process_command_args
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ExistingCoverageResult:
41
+ coverage_data: dict[str, Any] | None = None
42
+ stale: bool = False
43
+
44
+
45
+ # pytest's default ``python_files`` patterns: what a directory selector expands
46
+ # to in step-by-step mode (step-by-step runs files, not node ids).
47
+ _PYTEST_FILE_PATTERNS = ("test_*.py", "*_test.py")
48
+
49
+
50
+ def _split_nodeid(selector: str) -> tuple[str, str]:
51
+ """Split ``tests/test_mod.py::TestFoo::test_bar`` into path and node part."""
52
+ path_part, _, node_part = selector.partition("::")
53
+ return path_part, node_part
54
+
55
+
56
+ def _expand_directory(directory: Path) -> list[Path]:
57
+ """Test modules below ``directory``, by pytest's default file patterns.
58
+
59
+ The walk is ``test_runner._iter_python_files`` -- the one definition #191
60
+ gave a directory selector, so ``.venv``/``venv``/``site-packages``/
61
+ ``node_modules`` and hidden directories never become step-by-step targets.
62
+ A plain ``rglob`` turned this repository's own ``.venv`` into 39 extra
63
+ "test files" (rv192 MINOR-1).
64
+ """
65
+ return sorted(
66
+ path
67
+ for path in _iter_python_files(directory)
68
+ if any(
69
+ fnmatch.fnmatchcase(path.name, pattern) for pattern in _PYTEST_FILE_PATTERNS
70
+ )
71
+ )
72
+
73
+
74
+ def _resolve_step_selectors(
75
+ selectors: list[str], project_root: str | Path
76
+ ) -> tuple[list[Path], list[str]]:
77
+ """Resolve ``wl test -s`` arguments to the test files they name.
78
+
79
+ Step-by-step mode runs one file at a time, so a selector may be a test
80
+ module, a directory of modules, or a pytest node id (``file::node``) --
81
+ the node id narrows the run to its file. Anything else (a stray option, a
82
+ missing path, a node id without a file) is returned as an error: silently
83
+ widening to the whole suite would run far more than the user asked for
84
+ (#192).
85
+
86
+ Returns:
87
+ ``(files, errors)`` -- files are de-duplicated in selector order.
88
+ """
89
+ files: list[Path] = []
90
+ errors: list[str] = []
91
+ root = Path(project_root)
92
+
93
+ def add(candidate: Path) -> None:
94
+ if candidate not in files:
95
+ files.append(candidate)
96
+
97
+ for selector in selectors:
98
+ if selector.startswith("-"):
99
+ errors.append(
100
+ f"unsupported option in step-by-step mode: {selector!r} "
101
+ "(pytest options are only forwarded in plain 'wl test')"
102
+ )
103
+ continue
104
+
105
+ path_part, node_part = _split_nodeid(selector)
106
+ if not path_part:
107
+ errors.append(f"node id has no file before '::': {selector!r}")
108
+ continue
109
+
110
+ candidate = Path(path_part)
111
+ if not candidate.exists():
112
+ candidate = root / path_part
113
+ if not candidate.exists():
114
+ errors.append(f"no such test file or directory: {path_part!r}")
115
+ continue
116
+
117
+ if candidate.is_dir():
118
+ expanded = _expand_directory(candidate)
119
+ if not expanded:
120
+ errors.append(f"no test modules under directory: {path_part!r}")
121
+ continue
122
+ for path in expanded:
123
+ add(path)
124
+ continue
125
+
126
+ if candidate.suffix != ".py":
127
+ errors.append(f"not a Python test file: {path_part!r}")
128
+ continue
129
+ if node_part:
130
+ print(f"[Step] Node id {selector!r} runs as its whole file: {candidate}")
131
+ add(candidate)
132
+
133
+ return files, errors
134
+
135
+
136
+ class TestCommand(BaseCommand):
137
+ """Command to run tests for the project."""
138
+
139
+ # Tell pytest not to collect this class as a test class
140
+ __test__ = False
141
+
142
+ def __init__(self):
143
+ """Initialize TestCommand with modular components."""
144
+ super().__init__()
145
+ self.python_detector = PythonEnvDetector()
146
+ self.command_builder = TestCommandBuilder(self.python_detector)
147
+ self.result_handler = TestResultHandler()
148
+ self.cache_manager = CacheManager()
149
+ self.test_runner = TestRunner(self.cache_manager)
150
+ self.progress_manager = ProgressManager()
151
+ self.config_provider = ConfigProvider()
152
+
153
+ @property
154
+ def name(self) -> str:
155
+ """Get the command name."""
156
+ return "test"
157
+
158
+ @property
159
+ def help(self) -> str:
160
+ """Get the command help text."""
161
+ return "Run project tests"
162
+
163
+ def execute(self, *_, **kwargs: Any) -> None:
164
+ """
165
+ Execute the test command.
166
+
167
+ Args:
168
+ **kwargs: Keyword arguments
169
+ """
170
+ try:
171
+ # 处理命令行参数获取quiet/verbose first for output config
172
+ quiet = kwargs.get("quiet", False)
173
+ verbose = kwargs.get("verbose", False)
174
+ output_config = get_output_config(
175
+ override_quiet=quiet, override_verbose=verbose
176
+ )
177
+
178
+ # 显示当前使用的Python环境路径 - only in verbose mode
179
+ python_executable = self.python_detector.get_python_executable()
180
+ if output_config.verbose:
181
+ print(f"Running tests using Python environment: {python_executable}")
182
+
183
+ # 处理命令行参数
184
+ (
185
+ report_mode,
186
+ debug_mode,
187
+ step_by_step,
188
+ no_cache,
189
+ clean_cache,
190
+ cache_info,
191
+ quiet,
192
+ verbose,
193
+ passed_args,
194
+ ) = _process_command_args(self, kwargs)
195
+
196
+ # 获取coverage选项
197
+ cover_mode = kwargs.get("cover", False)
198
+
199
+ # 命令行参数优先级高于配置文件
200
+ fail_under_cmd = kwargs.get("fail_under", None)
201
+ if fail_under_cmd is not None:
202
+ fail_under = float(fail_under_cmd)
203
+ else:
204
+ # 从配置读取覆盖率阈值
205
+ fail_under = self.config_provider.get_config(
206
+ "coverage.fail_under", 70.0
207
+ )
208
+
209
+ # 检查并安装pytest(如果需要)
210
+ python_executable = _ensure_pytest_available(python_executable)
211
+
212
+ # 处理缓存信息选项
213
+ if cache_info:
214
+ self.cache_manager.info()
215
+ return
216
+
217
+ # 处理清理缓存选项
218
+ if clean_cache:
219
+ self.cache_manager.purge_all()
220
+
221
+ # 获取coverage缓存和并行选项
222
+ no_cover_cache = kwargs.get("no_cover_cache", False)
223
+ cover_parallel_workers = kwargs.get("cover_parallel_workers", None)
224
+ no_skip = kwargs.get("no_skip", False)
225
+ evidence_json = kwargs.get("evidence_json")
226
+ evidence_path = Path(evidence_json) if evidence_json else None
227
+
228
+ # 如果是coverage模式,运行覆盖率分析
229
+ if cover_mode:
230
+ self._run_coverage_analysis(
231
+ python_executable,
232
+ step_by_step,
233
+ fail_under,
234
+ no_cover_cache,
235
+ cover_parallel_workers,
236
+ )
237
+ else:
238
+ # 运行测试
239
+ self._run_tests(
240
+ python_executable,
241
+ report_mode,
242
+ debug_mode,
243
+ step_by_step,
244
+ no_cache,
245
+ quiet,
246
+ verbose,
247
+ passed_args,
248
+ no_skip=no_skip,
249
+ evidence_path=evidence_path,
250
+ )
251
+ except SystemExit:
252
+ # Re-raise SystemExit to allow tests to capture it
253
+ raise
254
+ except FileNotFoundError:
255
+ print(
256
+ "Error: pytest not found. Please install it using 'pip install pytest'"
257
+ )
258
+ sys.exit(1)
259
+ except Exception as e:
260
+ print(f"Error running tests: {e}")
261
+ sys.exit(1)
262
+
263
+ def _run_tests(
264
+ self,
265
+ python_executable: str,
266
+ report_mode: bool,
267
+ debug_mode: bool,
268
+ step_by_step: bool,
269
+ no_cache: bool,
270
+ quiet: bool,
271
+ verbose: bool,
272
+ passed_args: list[str],
273
+ no_skip: bool = False,
274
+ evidence_path: Path | None = None,
275
+ ) -> None:
276
+ """Run tests with the specified configuration."""
277
+ # ADR 0006: 回退是调用方策略 —— 由 project_root() 显式兜底 CWD
278
+ project_root = self.progress_manager.project_root()
279
+ # Create todos directory in current working directory
280
+ todo_dir = Path("todos")
281
+ todo_dir.mkdir(exist_ok=True)
282
+ concise = is_concise_mode(override_quiet=quiet, override_verbose=verbose)
283
+ self.progress_manager._log_project_paths(project_root, todo_dir, concise)
284
+
285
+ env = self.progress_manager._build_env(project_root)
286
+
287
+ # Handle step-by-step mode
288
+ if step_by_step:
289
+ if passed_args:
290
+ # Resolve selectors (files, directories, node ids) explicitly.
291
+ # An unrecognised selector is an error, never a silent fallback
292
+ # to the whole suite (#192).
293
+ test_files, selector_errors = _resolve_step_selectors(
294
+ passed_args, project_root
295
+ )
296
+ # Both exits happen before any test run starts, so they write no
297
+ # `--evidence-json` record: there is no run for the file to
298
+ # describe (see docs/user/commands/test.md, "运行证据"). The
299
+ # contract covers terminating paths *after* a run begins.
300
+ if selector_errors:
301
+ for error in selector_errors:
302
+ print(f"Error: {error}")
303
+ print("No tests were run. Fix the selector(s) and retry.")
304
+ sys.exit(2)
305
+ if not test_files:
306
+ print("Error: no test files matched the given selectors.")
307
+ sys.exit(2)
308
+
309
+ # Run only specified test files
310
+ self.test_runner._run_specified_tests_step_by_step(
311
+ python_executable,
312
+ project_root,
313
+ env,
314
+ debug_mode,
315
+ no_cache,
316
+ quiet,
317
+ verbose,
318
+ test_files,
319
+ no_skip=no_skip,
320
+ evidence_path=evidence_path,
321
+ )
322
+ else:
323
+ # Run all test files
324
+ self.test_runner._run_tests_step_by_step(
325
+ python_executable,
326
+ project_root,
327
+ env,
328
+ debug_mode,
329
+ no_cache,
330
+ quiet,
331
+ verbose,
332
+ no_skip=no_skip,
333
+ evidence_path=evidence_path,
334
+ )
335
+ else:
336
+ self.test_runner._run_tests_normal(
337
+ python_executable,
338
+ project_root,
339
+ env,
340
+ report_mode,
341
+ passed_args,
342
+ debug_mode,
343
+ todo_dir,
344
+ no_cache,
345
+ quiet,
346
+ verbose,
347
+ evidence_path=evidence_path,
348
+ )
349
+
350
+ def _run_coverage_analysis(
351
+ self,
352
+ python_executable: str,
353
+ step_by_step: bool = False,
354
+ fail_under: float | None = None,
355
+ no_cover_cache: bool = False,
356
+ cover_parallel_workers: int | None = None,
357
+ ) -> None:
358
+ """Run coverage analysis and generate improvement suggestions.
359
+
360
+ Three-phase flow:
361
+ 1. Cache check - collect source and test files, check for cached coverage
362
+ 2. Run pytest - only if cache miss, generate coverage.json
363
+ 3. Foreground analysis - per-file check (step-by-step) or overall stats (normal)
364
+
365
+ Args:
366
+ python_executable: Path to Python executable
367
+ step_by_step: Whether to run in step-by-step mode (stop at first failure)
368
+ fail_under: If specified, fail if coverage is below this percentage
369
+ no_cover_cache: Whether to disable coverage caching
370
+ cover_parallel_workers: Number of parallel workers for coverage tests
371
+ """
372
+ # Root-anchored (#125): the test subsystem's own root policy, the same
373
+ # seam the non-coverage branch uses (`_run_tests` -> ``project_root()``),
374
+ # not the process CWD. The bare ``Path.cwd()`` this replaces fed both the
375
+ # analyzer and the ``.wl/coverage.json`` derivation below, so a run from
376
+ # a subdirectory read and wrote ``<subdir>/.wl``; and because
377
+ # ``CoverageAnalyzer.run_coverage`` mkdirs that parent, the stray ``.wl``
378
+ # *directory* then became a PROJECT marker and re-anchored the very sites
379
+ # this ticket root-anchored.
380
+ project_root = Path(self.progress_manager.project_root())
381
+ print("[Coverage] Running coverage analysis...")
382
+
383
+ analyzer = CoverageAnalyzer(project_root)
384
+
385
+ source_files = analyzer._find_source_files()
386
+ if not source_files:
387
+ print("[Coverage] No source files found to analyze")
388
+ return
389
+
390
+ # What the cached/stale coverage actually depended on: the whole test
391
+ # tree pytest executes plus conftest/pytest config -- not the at most
392
+ # one-to-one source->test mapping, which left every unmapped test file
393
+ # (e.g. tests/test_smoke.py) outside both the key and the freshness
394
+ # check (#192).
395
+ test_inputs = collect_coverage_inputs(project_root)
396
+
397
+ cache_mgr = CoverageCacheManager()
398
+ cache_enabled = (
399
+ self.config_provider.get_config("coverage.cache_enabled", True)
400
+ and not no_cover_cache
401
+ )
402
+ cache_key = cache_mgr.get_cache_key(source_files, test_inputs)
403
+
404
+ coverage_data = None
405
+ existing_result = ExistingCoverageResult()
406
+
407
+ if step_by_step:
408
+ existing_result = self._load_existing_coverage_json(
409
+ project_root,
410
+ source_files,
411
+ test_inputs,
412
+ )
413
+ coverage_data = existing_result.coverage_data
414
+
415
+ if not coverage_data and not existing_result.stale and cache_enabled:
416
+ coverage_data = cache_mgr.get_cached_coverage(cache_key)
417
+ if coverage_data:
418
+ print("[Cache] Using cached coverage data")
419
+
420
+ if not coverage_data:
421
+ if existing_result.stale:
422
+ print(
423
+ "[Coverage] Refreshing coverage data before step-by-step analysis..."
424
+ )
425
+ else:
426
+ print("[Coverage] Running pytest to generate coverage data...")
427
+ coverage_data = analyzer.run_coverage(
428
+ python_executable,
429
+ output_path=Root(project_root, RootKind.PROJECT).wl_dir()
430
+ / "coverage.json",
431
+ )
432
+
433
+ if coverage_data and cache_enabled:
434
+ # Only claim success when the entry was actually written. An
435
+ # unkeyed run (a file's stat failed) and a failed write both
436
+ # return False -- and the ledger already logs the write
437
+ # failure -- so a success line here would be a lie (#213
438
+ # review MEDIUM-1).
439
+ if cache_mgr.save_cached_coverage(cache_key, coverage_data):
440
+ print("[Cache] Coverage data saved to cache")
441
+
442
+ if not coverage_data:
443
+ print("[Coverage] Failed to collect coverage data")
444
+ return
445
+
446
+ self._analyze_coverage(
447
+ analyzer,
448
+ coverage_data,
449
+ source_files,
450
+ step_by_step,
451
+ fail_under,
452
+ project_root,
453
+ python_executable,
454
+ )
455
+
456
+ def _load_existing_coverage_json(
457
+ self,
458
+ project_root: Path,
459
+ source_files: list[Path],
460
+ test_inputs: list[Path],
461
+ ) -> ExistingCoverageResult:
462
+ coverage_json_path = (
463
+ Root(project_root, RootKind.PROJECT).wl_dir() / "coverage.json"
464
+ )
465
+ if not coverage_json_path.exists():
466
+ return ExistingCoverageResult()
467
+
468
+ try:
469
+ content = coverage_json_path.read_text()
470
+ if not content.strip():
471
+ print("[Coverage] Existing coverage data is empty, skipping")
472
+ return ExistingCoverageResult()
473
+ coverage_data = json.loads(content)
474
+ except (OSError, json.JSONDecodeError):
475
+ print("[Coverage] Existing coverage data is unreadable, skipping")
476
+ return ExistingCoverageResult()
477
+
478
+ files = coverage_data.get("files")
479
+ if not isinstance(files, dict):
480
+ print("[Coverage] Existing coverage data has invalid schema, skipping")
481
+ return ExistingCoverageResult()
482
+
483
+ source_keys = {str(source_file) for source_file in source_files}
484
+ source_keys.update(
485
+ str(source_file.relative_to(project_root)) for source_file in source_files
486
+ )
487
+ if not source_keys.intersection(files):
488
+ print(
489
+ "[Coverage] Existing coverage data does not match current sources, skipping"
490
+ )
491
+ return ExistingCoverageResult()
492
+
493
+ updated = coverage_json_path.stat().st_mtime
494
+ checked_files = [*source_files, *test_inputs]
495
+ if any(
496
+ path.exists() and path.stat().st_mtime > updated for path in checked_files
497
+ ):
498
+ print(
499
+ "[Coverage] Existing coverage data is stale; source, test, "
500
+ "conftest or pytest config files changed after .wl/coverage.json."
501
+ )
502
+ return ExistingCoverageResult(stale=True)
503
+
504
+ updated_text = datetime.fromtimestamp(updated).strftime("%Y-%m-%d %H:%M:%S")
505
+ print(
506
+ "[Coverage] Using existing coverage data: .wl/coverage.json "
507
+ f"(updated {updated_text})"
508
+ )
509
+ return ExistingCoverageResult(coverage_data=coverage_data)
510
+
511
+ def _analyze_coverage(
512
+ self,
513
+ analyzer: CoverageAnalyzer,
514
+ coverage_data: dict[str, Any],
515
+ source_files: list[Path],
516
+ step_by_step: bool,
517
+ fail_under: float | None,
518
+ project_root: Path | None = None,
519
+ python_executable: str | None = None,
520
+ ) -> None:
521
+ """Analyze coverage data and generate report.
522
+
523
+ Args:
524
+ analyzer: CoverageAnalyzer instance
525
+ coverage_data: Coverage data from pytest
526
+ source_files: List of source files to analyze
527
+ step_by_step: Whether to run in step-by-step mode
528
+ fail_under: Coverage threshold percentage
529
+ """
530
+ analyzer.set_coverage_data(coverage_data)
531
+
532
+ if not step_by_step:
533
+ overall_stats = analyzer.get_overall_stats()
534
+ overall_percent = float(overall_stats.get("overall_percent", 0.0))
535
+
536
+ print(f"\n[Coverage] Total coverage: {overall_percent:.1f}%")
537
+
538
+ if fail_under is not None:
539
+ if overall_percent < fail_under:
540
+ print(
541
+ f"❌ [Coverage] FAILED: Overall coverage ({overall_percent:.1f}%) "
542
+ f"is below threshold ({fail_under}%)"
543
+ )
544
+ sys.exit(1)
545
+ else:
546
+ print(
547
+ f"✓ [Coverage] PASS: Coverage meets threshold ({fail_under}%)"
548
+ )
549
+ return
550
+
551
+ print(f"\n[Coverage] Checking coverage for {len(source_files)} files...")
552
+ if fail_under is not None:
553
+ print(f"[Coverage] Threshold: {fail_under}%")
554
+ print("=" * 60)
555
+
556
+ stats_list: list[CoverageStats | None] = []
557
+ all_suggestions = []
558
+
559
+ display_root = project_root or Path.cwd()
560
+
561
+ for i, file_path in enumerate(source_files, 1):
562
+ file_rel = file_path.relative_to(display_root)
563
+
564
+ test_file = analyzer._find_test_file(file_path)
565
+ if not test_file:
566
+ stats_list.append(None)
567
+ continue
568
+
569
+ stats, suggestions = analyzer.analyze_file(file_path)
570
+ stats_list.append(stats)
571
+ all_suggestions.extend(suggestions)
572
+
573
+ coverage_percent = stats.coverage_percent
574
+
575
+ if coverage_percent >= (fail_under or 0):
576
+ print(
577
+ f"\r[{i}/{len(source_files)}] ✓ {file_rel.name}", end="", flush=True
578
+ )
579
+ else:
580
+ if self._handle_step_coverage_failure(
581
+ i,
582
+ len(source_files),
583
+ file_path,
584
+ file_rel,
585
+ test_file,
586
+ suggestions,
587
+ fail_under or 0,
588
+ python_executable,
589
+ analyzer,
590
+ ):
591
+ continue
592
+
593
+ print(f"\n\n[Coverage] All {len(source_files)} files passed ✓")
594
+
595
+ def _handle_step_coverage_failure(
596
+ self,
597
+ index: int,
598
+ total: int,
599
+ file_path: Path,
600
+ file_rel: Path,
601
+ test_file: Path,
602
+ suggestions: list[Any],
603
+ fail_under: float,
604
+ python_executable: str | None,
605
+ analyzer: CoverageAnalyzer | None,
606
+ ) -> bool:
607
+ print()
608
+ print(f"[Coverage] {file_rel} is below threshold in current coverage data.")
609
+ recheck_passed = False
610
+ if analyzer is not None and python_executable is not None:
611
+ print(f"[Coverage] Rechecking with {test_file} before failing...")
612
+ # The per-file recheck parses its own temporary report through
613
+ # ``_read_coverage_json``, which overwrites the analyzer's shared
614
+ # ``coverage_data`` with a one-file report. Snapshot it here so the
615
+ # aggregate report keeps driving every later file's threshold
616
+ # decision; otherwise each remaining file reports a bogus 0% and
617
+ # triggers its own recheck (#182).
618
+ saved_coverage_data = analyzer.coverage_data
619
+ try:
620
+ recheck_passed = analyzer.run_coverage_for_file(
621
+ python_executable, file_path, fail_under=fail_under
622
+ )
623
+ finally:
624
+ analyzer.coverage_data = saved_coverage_data
625
+
626
+ if recheck_passed:
627
+ print(f"[Coverage] Recheck passed for {file_rel}; continuing analysis.")
628
+ return True
629
+
630
+ print(f"[Coverage] Recheck confirmed {file_rel} is below threshold.")
631
+ print(f"[{index}/{total}] ❌ FAIL: {file_rel}")
632
+ print("\n提高此文件测试覆盖率")
633
+
634
+ if suggestions:
635
+ print("\nSuggestions for improvement:")
636
+ severity_order = {"high": 0, "medium": 1, "low": 2}
637
+ sorted_suggestions = sorted(
638
+ suggestions, key=lambda x: severity_order.get(x.severity, 2)
639
+ )
640
+ for suggestion in sorted_suggestions:
641
+ print(f" [{suggestion.severity.upper()}] {suggestion.suggestion}")
642
+
643
+ print("\n[Coverage] Stopping at first failure as requested.")
644
+ sys.exit(1)
645
+
646
+ def _run_single_test_file(
647
+ self,
648
+ python_executable,
649
+ test_file,
650
+ project_root,
651
+ env,
652
+ debug_mode,
653
+ no_cache=False,
654
+ step_by_step=False,
655
+ concise=False,
656
+ ):
657
+ """Run single test file."""
658
+ return self.test_runner.single_test_executor.execute(
659
+ python_executable,
660
+ test_file,
661
+ project_root,
662
+ env,
663
+ debug_mode,
664
+ no_cache,
665
+ step_by_step,
666
+ concise,
667
+ )
668
+
669
+ def _get_all_test_files(self, project_root):
670
+ """Get all test files."""
671
+ return self.test_runner._get_all_test_files(project_root)
672
+
673
+ def _ensure_pytest_available(self, python_executable):
674
+ """Ensure pytest is available."""
675
+ from .test_utils import _ensure_pytest_available
676
+
677
+ return _ensure_pytest_available(python_executable)
678
+
679
+ def _process_command_args(self, kwargs):
680
+ """Process command arguments."""
681
+ from .test_utils import _process_command_args
682
+
683
+ return _process_command_args(self, kwargs)
684
+
685
+ def _build_test_command(
686
+ self, python_executable, test_file, debug_mode, step_by_step=False
687
+ ):
688
+ """Build test command."""
689
+ return self.test_runner.single_test_executor._build_test_command(
690
+ python_executable, test_file, debug_mode, step_by_step
691
+ )
692
+
693
+
694
+ @click.command(
695
+ # Everything wl doesn't define itself must reach pytest untouched (#192):
696
+ # ``wl test -k ...`` used to die with NoSuchOption instead of forwarding.
697
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
698
+ )
699
+ @click.option("--report", is_flag=True, help="Show detailed test report")
700
+ @click.option("--debug", is_flag=True, help="Run tests in debug mode")
701
+ @click.option("-s", "--step-by-step", is_flag=True, help="Run one test file at a time")
702
+ @click.option("--no-cache", is_flag=True, help="Skip cache and run all tests")
703
+ @click.option(
704
+ "--use-cache",
705
+ is_flag=True,
706
+ help=(
707
+ "Reuse cached test results (default in step-by-step mode; "
708
+ "plain 'wl test' defaults to --no-cache)"
709
+ ),
710
+ )
711
+ @click.option("--clean-cache", is_flag=True, help="Clean all test cache before running")
712
+ @click.option("--cache-info", is_flag=True, help="Show cache information and exit")
713
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress verbose output")
714
+ @click.option("-v", "--verbose", is_flag=True, help="Force verbose output")
715
+ @click.option("--cover", is_flag=True, help="Run coverage analysis")
716
+ @click.option(
717
+ "--fail-under", type=float, default=None, help="Override coverage threshold"
718
+ )
719
+ @click.option("--no-cover-cache", is_flag=True, help="Disable coverage caching")
720
+ @click.option(
721
+ "--cover-parallel-workers",
722
+ type=int,
723
+ default=None,
724
+ help="Number of parallel workers for coverage tests",
725
+ )
726
+ @click.option(
727
+ "--no-skip", is_flag=True, help="Don't skip slow test checks for known patterns"
728
+ )
729
+ @click.option(
730
+ "--evidence-json",
731
+ "evidence_json",
732
+ type=click.Path(dir_okay=False, path_type=str),
733
+ default=None,
734
+ metavar="PATH",
735
+ help=(
736
+ "Write structured evidence of this test run (executed files, cache "
737
+ "hits, exit code) to PATH, atomically"
738
+ ),
739
+ )
740
+ @click.argument("args", nargs=-1, type=click.UNPROCESSED)
741
+ def test_command(
742
+ report: bool,
743
+ debug: bool,
744
+ step_by_step: bool,
745
+ no_cache: bool,
746
+ use_cache: bool,
747
+ clean_cache: bool,
748
+ cache_info: bool,
749
+ quiet: bool,
750
+ verbose: bool,
751
+ cover: bool,
752
+ fail_under: float | None,
753
+ no_cover_cache: bool,
754
+ cover_parallel_workers: int | None,
755
+ evidence_json: str | None,
756
+ args: tuple[str, ...],
757
+ no_skip: bool = False,
758
+ ) -> None:
759
+ """Run this project's tests with pytest.
760
+
761
+ Plain "wl test" runs the whole suite as one pytest batch with the cache
762
+ off; --use-cache opts back in. Unrecognised options and positional
763
+ selectors are forwarded to pytest untouched. With -s / --step-by-step, wl
764
+ resolves the selectors to test files, runs them one file at a time, reuses
765
+ cached results, and stops at the first failure. -s, -q and -v are wl's own
766
+ switches and are never passed through to pytest.
767
+ """
768
+ cmd = TestCommand()
769
+ cmd.execute(
770
+ report=report,
771
+ debug=debug,
772
+ step_by_step=step_by_step,
773
+ no_cache=no_cache,
774
+ use_cache=use_cache,
775
+ clean_cache=clean_cache,
776
+ cache_info=cache_info,
777
+ quiet=quiet,
778
+ verbose=verbose,
779
+ cover=cover,
780
+ fail_under=fail_under,
781
+ no_cover_cache=no_cover_cache,
782
+ cover_parallel_workers=cover_parallel_workers,
783
+ evidence_json=evidence_json,
784
+ no_skip=no_skip,
785
+ args=list(args),
786
+ )