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,1343 @@
1
+ """Test runner module for test commands."""
2
+
3
+ import os
4
+ import shlex
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import threading
9
+ import time
10
+ from collections.abc import Callable, Iterator
11
+ from pathlib import Path
12
+ from xml.etree import ElementTree
13
+
14
+ from ...utils.logging import log_info_pair
15
+ from ...utils.output_context import is_concise_mode
16
+ from ...utils.run_evidence import TestRunEvidence
17
+ from .batch_processor import BatchProcessor
18
+ from .cache_manager import CacheManager
19
+ from .report_generator import ReportGenerator
20
+ from .single_test_executor import SingleTestExecutor
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Per-file attribution: which user passthrough args travel along (#183)
24
+ #
25
+ # The batch run produced its verdict *under these args*, so a per-file re-run
26
+ # that drops them can reach a different verdict for a file the user's selection
27
+ # had effectively passed: a failing test the user excluded with `-k` would
28
+ # still mark its file failed and drop it from the cache. Selection args
29
+ # therefore have to travel. Only the subset below does -- an allowlist, not
30
+ # "everything dash-shaped", because a half-forwarded option is worse than a
31
+ # dropped one: `-k` without its expression swallows the test path, pytest then
32
+ # collects nothing, exits 5, and `_process_test_result` reads that as success,
33
+ # caching a file that never ran.
34
+ #
35
+ # Two families are deliberately *out* of the allowlist (rv183):
36
+ # * warning filters (`-W` / `--pythonwarnings`): a single-file run does not
37
+ # reproduce the batch run's warning stack, so `-W error` here can fail a
38
+ # file the batch run passed -- attribution would drop a good file.
39
+ # * stateful selection (`--lf` / `--ff` / `--nf` / `--sw` and long forms):
40
+ # they read *and rewrite* `.pytest_cache` in the project root, so file N+1
41
+ # sees state file N left behind -- not the batch selection at all. It also
42
+ # cannot be forwarded completely (`--lfnf` is the missing half).
43
+ # The companion guard for the `rc == 5` case lives in
44
+ # `SingleTestExecutor.execute`: "no tests ran under a forwarded selection" is
45
+ # not evidence of a pass, so the file is left unattributed instead of cached.
46
+ # ---------------------------------------------------------------------------
47
+
48
+ #: Option spelling -> whether it consumes the following token as its value.
49
+ _ATTRIBUTION_SAFE_OPTIONS: dict[str, bool] = {
50
+ # Selection: the filter the batch verdict was produced under.
51
+ "-k": True,
52
+ "--keyword": True,
53
+ "-m": True,
54
+ "--markexpr": True,
55
+ "--deselect": True,
56
+ "--ignore": True,
57
+ "--ignore-glob": True,
58
+ # Failure / output switches. None of them can change a single file's
59
+ # pass/fail verdict: `-x` only stops earlier *within* a file that has
60
+ # already failed.
61
+ "-x": False,
62
+ "--exitfirst": False,
63
+ "--maxfail": True,
64
+ "-q": False,
65
+ "--quiet": False,
66
+ "-v": False,
67
+ "--verbose": False,
68
+ "-r": True,
69
+ "--tb": True,
70
+ "-l": False,
71
+ "--showlocals": False,
72
+ "--strict-markers": False,
73
+ "--strict-config": False,
74
+ # Configuration the batch run honoured.
75
+ "-o": True,
76
+ "--override-ini": True,
77
+ "-p": True,
78
+ "--plugin": True,
79
+ "-c": True,
80
+ "--config-file": True,
81
+ }
82
+
83
+
84
+ def _attribution_args(passed_args: list[str]) -> list[str]:
85
+ """Return the user passthrough args a per-file attribution run may reuse.
86
+
87
+ Forward only :data:`_ATTRIBUTION_SAFE_OPTIONS` and the values of the ones
88
+ that take a value. Deliberately *not* forwarded:
89
+
90
+ * positional selectors (``tests/subdir``, ``tests/x.py::TestA``): the
91
+ subprocess is already pinned to exactly one file, and re-adding a path
92
+ would run other files too -- their failures would be attributed to this
93
+ one.
94
+ * coverage flags (``--cov*``, ``--no-cov``): this pass exists to attribute
95
+ cache, not to collect coverage; the single-file executor already decides
96
+ ``--no-cov`` for the interpreter that runs it.
97
+ * report artifacts (``--junitxml``, ``--html``, ``--cov-report``,
98
+ ``--pastebin``): every file would overwrite the same artifact.
99
+ * warning filters (``-W``, ``--pythonwarnings``): the single-file command
100
+ does not reproduce the batch warning stack, so forwarding can fail a file
101
+ the batch run passed (rv183 M2).
102
+ * stateful selection (``--lf``, ``--ff``, ``--nf``, ``--sw``): their state
103
+ lives in ``.pytest_cache``, which each per-file run rewrites; the series
104
+ is also incomplete without ``--lfnf`` (rv183 M3).
105
+ * anything unrecognised: dropped whole, rather than half-forwarded.
106
+ """
107
+ forwarded: list[str] = []
108
+ index = 0
109
+ while index < len(passed_args):
110
+ arg = passed_args[index]
111
+ index += 1
112
+
113
+ if not arg.startswith("-"):
114
+ continue # positional selector, see above
115
+
116
+ name, separator, _value = arg.partition("=")
117
+ takes_value = _ATTRIBUTION_SAFE_OPTIONS.get(name)
118
+ if takes_value is None:
119
+ # `-kexpr` / `-Werror`: a short option carrying its value inline.
120
+ if len(arg) > 2 and not arg.startswith("--"):
121
+ takes_value = _ATTRIBUTION_SAFE_OPTIONS.get(arg[:2])
122
+ if takes_value:
123
+ forwarded.append(arg)
124
+ continue
125
+
126
+ if not takes_value or separator:
127
+ forwarded.append(arg)
128
+ continue
129
+
130
+ # Never emit a value-taking option without its value: a dangling `-k`
131
+ # would consume the test path and leave nothing to run.
132
+ if index < len(passed_args) and not passed_args[index].startswith("-"):
133
+ forwarded.append(arg)
134
+ forwarded.append(passed_args[index])
135
+ index += 1
136
+ return forwarded
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Executed-file derivation (#191)
141
+ #
142
+ # A pytest process that exits 0 proves something about *the tests it ran*, and
143
+ # nothing about the rest of the tree. The cache used to record every file under
144
+ # tests/ after such a run, so `wl test --use-cache tests/test_a.py` marked a red
145
+ # tests/test_b.py as passing and the next `wl test -s` reported the suite green
146
+ # from the cache. The write-back set is now derived here -- and when it cannot
147
+ # be derived, nothing is written (fail-closed).
148
+ # ---------------------------------------------------------------------------
149
+
150
+ #: Directories a selector walk never descends into. A directory selector like
151
+ #: `.` must not turn a virtualenv's vendored `test_*.py` files into "tests this
152
+ #: run executed" (rv191 F3), and walking them is pure cost.
153
+ _SKIP_DIRECTORY_NAMES: frozenset[str] = frozenset(
154
+ {"__pycache__", "site-packages", "node_modules", "venv", "build", "dist"}
155
+ )
156
+
157
+ #: Passthrough options a run may carry *and still write cache* -- an allowlist,
158
+ #: not a blocklist. Two rounds of blocklists taught the lesson (rv191: first
159
+ #: `--collect-only`, then `--setup-only`): every enumeration missed the next
160
+ #: pytest flag that changes what a green exit code means. Here the default is
161
+ #: "denied", so a new or unknown switch can only cost a cache write, never
162
+ #: produce a false green.
163
+ #:
164
+ #: Everything listed leaves a green exit code meaning what it says: the tests
165
+ #: the run selected did pass. Output/verbosity, fail-fast switches (a green `-x`
166
+ #: run still ran every selected test, and any failure would be non-green),
167
+ #: collection strictness (which can only turn a pass into an error), coverage
168
+ #: instrumentation, and parallelism (`-n`/`--dist` move where a test runs, not
169
+ #: whether it runs).
170
+ #: Deliberately absent: warning filters (`-W`/`--pythonwarnings`, see below),
171
+ #: selection filters (`-k`, `-m`, `--deselect`, `--ignore*`,
172
+ #: `--lf`/`--ff`/`--nf`/`--sw`), loading-chain switches (`--noconftest`, `-p`,
173
+ #: `-c`, `-o`, `--rootdir`, `--confcutdir`, `--import-mode`), non-executing
174
+ #: modes (`--collect-only`, `--setup-only`, `--setup-plan`, `--version`,
175
+ #: `--fixtures`, `--markers`, `--help`, `--trace-config`), report destinations
176
+ #: (`--junitxml`/`--junit-xml`, `--html`, `--pastebin`) and seed/order flags
177
+ #: (`--randomly-seed`).
178
+ #:
179
+ #: Warning filters are denied in both directions (#199 rv199). A filter can
180
+ #: *weaken* the verdict -- with `filterwarnings = error` in the project config,
181
+ #: `-W ignore` turns a red file green -- and the cache key cannot see a
182
+ #: command-line switch, so storing that green under the plain key let the next
183
+ #: unfiltered run answer "using cache" over a suite that is red. Storing only
184
+ #: under "stricter" filters was the earlier idea and it does not hold: only a
185
+ #: bare `error` is stricter than the project's own setting, and a filtered run
186
+ #: can never read cache anyway, so the write bought one hit for a plainer run --
187
+ #: exactly the unsound direction. `-W` stays in
188
+ #: :data:`_VALUE_TAKING_OPTIONS` so its value is not mistaken for a selector.
189
+ #:
190
+ #: Short-option spellings matter here too (#199): `-rA`, `-vv` and `-n4` are
191
+ #: the same switches as `-r A`, `-v -v` and `-n 4`, so
192
+ #: :func:`_short_option_name` reduces them to their two-character name before
193
+ #: this set is consulted. Keeping the inline spellings denied only meant that
194
+ #: `wl test -- -rA` and `wl test -- -vv` (ordinary habits) silently stopped
195
+ #: caching.
196
+ _CACHE_SAFE_OPTIONS: frozenset[str] = frozenset(
197
+ {
198
+ # Output and verbosity.
199
+ "-q",
200
+ "--quiet",
201
+ "-v",
202
+ "--verbose",
203
+ "--no-header",
204
+ "--no-summary",
205
+ "--disable-warnings",
206
+ "-r",
207
+ "--tb",
208
+ "-l",
209
+ "--showlocals",
210
+ "--color",
211
+ "--code-highlight",
212
+ "--durations",
213
+ "--durations-min",
214
+ "--log-level",
215
+ "--log-file",
216
+ "--log-format",
217
+ "--log-date-format",
218
+ "-s",
219
+ "--capture",
220
+ # Fail-fast: green still means every selected test ran and passed.
221
+ "-x",
222
+ "--exitfirst",
223
+ "--maxfail",
224
+ # Collection strictness: can only turn a pass into an error.
225
+ "--strict-markers",
226
+ "--strict-config",
227
+ # Parallelism: changes where a test runs, not whether it runs.
228
+ "-n",
229
+ "--numprocesses",
230
+ "--dist",
231
+ }
232
+ )
233
+
234
+ #: Prefix allowlist for the coverage family (`--cov`, `--cov-report=...`,
235
+ #: `--cov-fail-under=...`, `--no-cov`, `--no-cov-on-fail`). Coverage changes
236
+ #: instrumentation and exit status, not a test's verdict.
237
+ _CACHE_SAFE_OPTION_PREFIXES: tuple[str, ...] = ("--cov", "--no-cov")
238
+
239
+ #: Spellings of pytest's junit report destination. Both must be recognised: the
240
+ #: runner adds a report of its own to observe the run, and writing that over a
241
+ #: user-supplied path would silently discard their file (rv191 F2).
242
+ _JUNIT_REPORT_OPTIONS: frozenset[str] = frozenset({"--junitxml", "--junit-xml"})
243
+
244
+ #: Options that consume the following token as their value. Used only to keep
245
+ #: an option's value from being mistaken for a path selector; deliberately a
246
+ #: superset of the attribution allowlist, because an unrecognised value option
247
+ #: would otherwise turn its value into a selector that does not exist.
248
+ _VALUE_TAKING_OPTIONS: frozenset[str] = frozenset(
249
+ {
250
+ *(
251
+ name
252
+ for name, takes_value in _ATTRIBUTION_SAFE_OPTIONS.items()
253
+ if takes_value
254
+ ),
255
+ "-W",
256
+ "--pythonwarnings",
257
+ "--durations",
258
+ "--durations-min",
259
+ "--rootdir",
260
+ "--confcutdir",
261
+ "--junitxml",
262
+ "--junit-xml",
263
+ "--junit-prefix",
264
+ "--color",
265
+ "--capture",
266
+ "-n",
267
+ "--numprocesses",
268
+ "--dist",
269
+ "--timeout",
270
+ "--basetemp",
271
+ "--import-mode",
272
+ "--log-level",
273
+ "--log-file",
274
+ "--log-format",
275
+ "--log-date-format",
276
+ "-a",
277
+ "--alluredir",
278
+ "--randomly-seed",
279
+ }
280
+ )
281
+
282
+
283
+ def _short_option_name(arg: str) -> str | None:
284
+ """The two-character name of an inline short option, or ``None``.
285
+
286
+ ``-rA``, ``-Werror`` and ``-n4`` carry their value in the same token;
287
+ ``-vv``/``-qq`` repeat a flag. In every case pytest reads the option as
288
+ ``arg[:2]`` plus a remainder that is a value or a repetition, so the
289
+ allowlist must be asked about the two-character name -- asking about the
290
+ raw token denied ordinary habits like ``-rA`` and ``-vv`` (#199). The
291
+ remainder never changes whether the run executes tests: for a value-taking
292
+ option it *is* the value, and for a flag it is the flag repeated.
293
+ """
294
+ if len(arg) <= 2 or arg.startswith("--"):
295
+ return None
296
+ return arg[:2]
297
+
298
+
299
+ def _split_passthrough_args(passed_args: list[str]) -> tuple[list[str], list[str]]:
300
+ """Split the user's passthrough args into (option names, path selectors).
301
+
302
+ Option values are skipped using :data:`_VALUE_TAKING_OPTIONS`; an unknown
303
+ option's would-be value stays a selector, which then fails to resolve and
304
+ makes the whole selection "unknown" -- the safe direction.
305
+
306
+ Inline short options are reported by their canonical two-character name
307
+ (:func:`_short_option_name`), so ``-rA`` is vetted as ``-r``.
308
+ """
309
+ option_names: list[str] = []
310
+ selectors: list[str] = []
311
+ index = 0
312
+ while index < len(passed_args):
313
+ arg = passed_args[index]
314
+ index += 1
315
+ if not arg.startswith("-") or arg == "-":
316
+ selectors.append(arg)
317
+ continue
318
+
319
+ name, separator, _value = arg.partition("=")
320
+ if separator:
321
+ option_names.append(name)
322
+ continue
323
+ if name in _VALUE_TAKING_OPTIONS:
324
+ option_names.append(name)
325
+ if index < len(passed_args):
326
+ index += 1 # this token is the option's value, not a selector
327
+ continue
328
+ short = _short_option_name(arg)
329
+ option_names.append(short if short is not None else name)
330
+ return option_names, selectors
331
+
332
+
333
+ def _selector_spelling(path: Path, project_root: str) -> str:
334
+ """A path as the user would spell it on the command line."""
335
+ try:
336
+ relative = path.relative_to(project_root)
337
+ except ValueError:
338
+ relative = path
339
+ return str(relative).replace("\\", "/")
340
+
341
+
342
+ def _selector_path(selector: str, project_root: str) -> Path:
343
+ """Resolve a selector the way pytest does: relative to the project root."""
344
+ path = Path(selector)
345
+ if not path.is_absolute():
346
+ path = Path(project_root) / path
347
+ return path
348
+
349
+
350
+ def _files_for_selector(
351
+ token: str, known: dict[str, Path], project_root: str
352
+ ) -> list[Path] | None:
353
+ """Resolve one already-normalised path selector to the files it names.
354
+
355
+ Returns ``None`` when the selector names nothing this run can vouch for;
356
+ the caller turns that into "the file set is unknown".
357
+ """
358
+ match = known.get(token)
359
+ if match is not None:
360
+ return [match]
361
+
362
+ if any(spelling == token or spelling.startswith(f"{token}/") for spelling in known):
363
+ # A directory selector: every known file underneath it.
364
+ return [
365
+ test_file
366
+ for spelling, test_file in known.items()
367
+ if spelling.startswith(f"{token}/")
368
+ ]
369
+
370
+ resolved = _selector_path(token, project_root)
371
+ if resolved.is_dir():
372
+ # Only files the project's own test tree knows about: a directory
373
+ # selector covers `.`, and `.` is where a virtualenv keeps thousands of
374
+ # vendored `test_*.py` files that were never this project's tests
375
+ # (rv191 F3).
376
+ chosen = [
377
+ known[spelling]
378
+ for discovered in _iter_python_files(resolved)
379
+ for spelling in (_selector_spelling(discovered, project_root),)
380
+ if spelling in known
381
+ ]
382
+ return chosen or None
383
+ if resolved.is_file():
384
+ return [resolved]
385
+ return None
386
+
387
+
388
+ def _iter_python_files(directory: Path) -> Iterator[Path]:
389
+ """Yield ``*.py`` files under *directory*, skipping vendored trees.
390
+
391
+ Hidden directories (``.venv``, ``.git``, ...) and the usual dependency
392
+ directories are pruned rather than filtered afterwards, so a selector like
393
+ `.` costs one walk of the project's own sources.
394
+ """
395
+ for current, dirnames, filenames in os.walk(directory):
396
+ dirnames[:] = sorted(
397
+ name
398
+ for name in dirnames
399
+ if not name.startswith(".") and name not in _SKIP_DIRECTORY_NAMES
400
+ )
401
+ for name in sorted(filenames):
402
+ if name.endswith(".py") and name != "__init__.py":
403
+ yield Path(current) / name
404
+
405
+
406
+ def _executed_test_files(
407
+ passed_args: list[str], project_root: str, all_test_files: list[Path]
408
+ ) -> list[Path] | None:
409
+ """The test files this run executed, or ``None`` when that is unknown.
410
+
411
+ ``None`` means "cannot be determined" and the caller must not write cache:
412
+ a green exit code from a run whose file set is unknown is not evidence
413
+ about any particular file. Concretely this covers a filtered run
414
+ (``-k``/``-m``/``--deselect``/... -- the verdict is about a subset of each
415
+ file's tests), a run whose loading chain or execution mode was switched
416
+ (``--noconftest``, ``-p``, ``--collect-only``, ...), a nodeid selector
417
+ (``tests/test_a.py::TestA`` -- a slice of a file), and a selector that does
418
+ not resolve to anything.
419
+
420
+ With no positional selector the default selection is the whole ``tests/``
421
+ tree, which is exactly ``all_test_files``.
422
+
423
+ ``PYTEST_ADDOPTS`` is not consulted for the selection: an addopts that
424
+ could narrow it (a filter or a positional selector) makes the whole run
425
+ uncacheable in :func:`_addopts_cache_blocker` (#199), and an addopts that
426
+ survives that gate contains only switches that cannot select.
427
+ """
428
+ option_names, selectors = _split_passthrough_args(passed_args)
429
+ if _unsafe_option(option_names) is not None:
430
+ return None
431
+ if not selectors:
432
+ return list(all_test_files)
433
+
434
+ known = {
435
+ _selector_spelling(test_file, project_root): test_file
436
+ for test_file in all_test_files
437
+ }
438
+
439
+ executed: list[Path] = []
440
+ seen: set[Path] = set()
441
+ for selector in selectors:
442
+ if "::" in selector:
443
+ # A nodeid ran part of the file; a whole-file pass was never proven.
444
+ return None
445
+ token = selector.replace("\\", "/")
446
+ while token.startswith("./"):
447
+ token = token[2:]
448
+ token = token.rstrip("/")
449
+
450
+ chosen = _files_for_selector(token, known, project_root)
451
+ if chosen is None:
452
+ return None
453
+ for path in chosen:
454
+ if path not in seen:
455
+ seen.add(path)
456
+ executed.append(path)
457
+ return executed
458
+
459
+
460
+ def _unsafe_option(option_names: list[str]) -> str | None:
461
+ """The first passthrough option that is not on the cache-safe allowlist.
462
+
463
+ ``None`` means every option in the run is vetted. Anything else -- a
464
+ selection filter, a loading-chain switch, a collection-only mode, or a flag
465
+ nobody has thought about yet -- makes the run uncacheable.
466
+ """
467
+ for name in option_names:
468
+ if name in _CACHE_SAFE_OPTIONS:
469
+ continue
470
+ if name.startswith(_CACHE_SAFE_OPTION_PREFIXES):
471
+ continue
472
+ return name
473
+ return None
474
+
475
+
476
+ def _cache_write_blocker(option_names: list[str]) -> str | None:
477
+ """Why this run may not write cache at all, or ``None`` when it may."""
478
+ unsafe = _unsafe_option(option_names)
479
+ if unsafe is None:
480
+ return None
481
+ return (
482
+ f"the run used {unsafe}, which is not on the cache-safe allowlist of "
483
+ "pytest switches"
484
+ )
485
+
486
+
487
+ def _addopts_cache_blocker(env: dict[str, str] | None) -> str | None:
488
+ """Why ``PYTEST_ADDOPTS`` makes this run uncacheable, or ``None``.
489
+
490
+ pytest prepends ``PYTEST_ADDOPTS`` to *every* command line, so it is a
491
+ second door through which a switch reaches the run -- and until #199 it was
492
+ the unwatched one: the cache key included the variable's value, but nothing
493
+ checked what the value *meant*. ``PYTEST_ADDOPTS=--collect-only`` therefore
494
+ exited 0 without executing a test while the allowlist, which only ever saw
495
+ the command line, raised no objection.
496
+
497
+ The value is split exactly as pytest splits it (``shlex.split``) and vetted
498
+ against the same allowlist. A positional token counts as unsafe here even
499
+ though the command line treats it as a selector: addopts selectors would
500
+ narrow the run behind the runner's back, so its file set is unknowable.
501
+ An unparsable value is denied outright.
502
+ """
503
+ raw = (env or {}).get("PYTEST_ADDOPTS", "")
504
+ if not raw.strip():
505
+ return None
506
+ try:
507
+ tokens = shlex.split(raw)
508
+ except ValueError:
509
+ return (
510
+ "PYTEST_ADDOPTS cannot be parsed, so the run it produces cannot be vetted"
511
+ )
512
+ option_names, selectors = _split_passthrough_args(tokens)
513
+ unsafe = _unsafe_option(option_names)
514
+ if unsafe is not None:
515
+ return (
516
+ f"PYTEST_ADDOPTS injects {unsafe}, which is not on the cache-safe "
517
+ "allowlist of pytest switches"
518
+ )
519
+ if selectors:
520
+ return (
521
+ "PYTEST_ADDOPTS injects a positional selector, so this run's test "
522
+ "files cannot be determined"
523
+ )
524
+ return None
525
+
526
+
527
+ def _execution_report(
528
+ passed_args: list[str], report_mode: bool
529
+ ) -> tuple[Path | None, bool]:
530
+ """Where to read this run's junit report, and whether we own the file.
531
+
532
+ ``(None, False)`` means the run cannot be observed: the user asked for
533
+ their own junit report (``--junitxml`` or its ``--junit-xml`` alias), and
534
+ writing ours over their path would silently discard their file -- losing
535
+ the cache for one run is the cheaper mistake. In report mode the command
536
+ builder already writes ``todos/junit.xml``; otherwise the runner points
537
+ pytest at a fresh temporary file, so a leftover report can never be read as
538
+ this run's.
539
+
540
+ The report is the *positive signal* the cache write needs (rv191 F1): pytest
541
+ writes one test case per executed test, and writes none for
542
+ ``--collect-only``, ``--version`` or ``--fixtures``.
543
+ """
544
+ option_names, _selectors = _split_passthrough_args(passed_args)
545
+ if _JUNIT_REPORT_OPTIONS.intersection(option_names):
546
+ return None, False
547
+ if report_mode:
548
+ # TestCommandBuilder's report-mode target, relative to the process CWD.
549
+ return Path("todos") / "junit.xml", False
550
+ handle, name = tempfile.mkstemp(prefix="wl-test-report-", suffix=".xml")
551
+ os.close(handle)
552
+ return Path(name), True
553
+
554
+
555
+ def _case_spelling(case: ElementTree.Element, project_root: str) -> str | None:
556
+ """The ``file`` attribute of a junit ``<testcase>``, normalised."""
557
+ file_attr = case.get("file")
558
+ if not file_attr:
559
+ return None
560
+ token = file_attr.replace("\\", "/")
561
+ while token.startswith("./"):
562
+ token = token[2:]
563
+ if Path(token).is_absolute():
564
+ return _selector_spelling(Path(token), project_root)
565
+ return token
566
+
567
+
568
+ def _match_classname(classname: str, by_module: dict[str, Path]) -> Path | None:
569
+ """Match a junit ``classname`` to a known test file by module prefix.
570
+
571
+ pytest's junit report often omits ``file``; the ``classname`` is the module
572
+ path (``tests.test_a``), optionally suffixed by the test class. Longest
573
+ prefix wins, so ``pkg.tests.test_a`` is not shadowed by ``pkg.tests``.
574
+ """
575
+ for prefix, test_file in by_module.items():
576
+ if classname == prefix or classname.startswith(f"{prefix}."):
577
+ return test_file
578
+ return None
579
+
580
+
581
+ def _report_outcome_counts_agree(report_root: ElementTree.Element) -> bool:
582
+ """Whether every ``<testcase>`` in the report carries a counted outcome.
583
+
584
+ pytest stamps each ``<testsuite>`` with ``tests``: the number of outcomes
585
+ the session recorded (passed + failed + skipped + error). It emits one
586
+ ``<testcase>`` element per item it *touched*. For a run that actually
587
+ executes its tests the two numbers agree -- verified against ``-x``, xdist,
588
+ teardown errors, xfail and parametrisation -- but ``--setup-only`` touches
589
+ every item while recording an outcome only for the items it skips, so its
590
+ ``tests`` count falls short of the testcase count.
591
+
592
+ That shortfall is the report's *own* proof that the run did not execute
593
+ what it lists. Checking it here makes the evidence defence independent of
594
+ the switch allowlist (#199): a ``--setup-only`` run is already denied
595
+ before it starts, but if a future whitelist change or a `PYTEST_ADDOPTS`
596
+ path ever let it through, this gate still refuses to read its report as
597
+ proof of execution. A report with no ``<testsuite>`` at all proves nothing.
598
+ """
599
+ suites = list(report_root.iter("testsuite"))
600
+ if not suites:
601
+ return False
602
+ for suite in suites:
603
+ declared = suite.get("tests")
604
+ if declared is None:
605
+ # pytest always writes it; a report without it cannot be verified.
606
+ return False
607
+ try:
608
+ declared_count = int(declared)
609
+ except ValueError:
610
+ return False
611
+ if declared_count != len(suite.findall("testcase")):
612
+ return False
613
+ return True
614
+
615
+
616
+ def _executed_cases(report_root: ElementTree.Element) -> Iterator[ElementTree.Element]:
617
+ """The report's testcases that carry a verdict.
618
+
619
+ Skipped cases are dropped here: pytest records a skip when it evaluated a
620
+ skip marker or a missing dependency, so the body never ran and the file was
621
+ never proven to pass (#199). The junit XML cannot distinguish a skip raised
622
+ inside the body from one raised at setup, so the conservative reading is
623
+ the only safe one -- a skip is a decision, not a verdict.
624
+ """
625
+ return (
626
+ case for case in report_root.iter("testcase") if case.find("skipped") is None
627
+ )
628
+
629
+
630
+ def _executed_files_from_report(
631
+ report_path: Path | None,
632
+ project_root: str,
633
+ known: dict[str, Path],
634
+ started_at: float,
635
+ ) -> list[Path]:
636
+ """The known test files that pytest's own report proves it executed.
637
+
638
+ Returns ``[]`` -- "no proof" -- for a missing, stale, empty or unparsable
639
+ report, for a report whose ``tests`` counts do not match its testcase
640
+ elements (:func:`_report_outcome_counts_agree`), and for a report with zero
641
+ test cases. A file named in the report but outside the project's test tree
642
+ is ignored: only files this project knows as tests may ever be cached
643
+ (rv191 F3).
644
+
645
+ A skipped testcase is not evidence either (#199): pytest records a skip
646
+ when it evaluated a skip marker or a missing dependency -- the test body
647
+ never ran -- so an all-skipped file would otherwise be cached as passing,
648
+ and would keep reading "passed" after the skip condition changed. The
649
+ junit XML does not distinguish a skip raised inside the body from one
650
+ raised at setup, so the conservative reading is the only safe one: a skip
651
+ is a decision, not a verdict.
652
+ """
653
+ if report_path is None:
654
+ return []
655
+ try:
656
+ stat = report_path.stat()
657
+ except OSError:
658
+ return []
659
+ if stat.st_size == 0 or stat.st_mtime < started_at - 1.0:
660
+ return []
661
+ try:
662
+ report_root = ElementTree.parse(report_path).getroot()
663
+ except (ElementTree.ParseError, OSError):
664
+ return []
665
+ if not _report_outcome_counts_agree(report_root):
666
+ return []
667
+
668
+ by_resolved: dict[Path, Path] = {}
669
+ for test_file in known.values():
670
+ try:
671
+ by_resolved[test_file.resolve()] = test_file
672
+ except OSError: # pragma: no cover - unresolvable path, never matched
673
+ continue
674
+
675
+ by_module = {
676
+ spelling[: -len(".py")].replace("/", "."): test_file
677
+ for spelling, test_file in sorted(
678
+ known.items(), key=lambda item: len(item[0]), reverse=True
679
+ )
680
+ if spelling.endswith(".py")
681
+ }
682
+
683
+ executed: list[Path] = []
684
+ seen: set[Path] = set()
685
+ for case in _executed_cases(report_root):
686
+ spelling = _case_spelling(case, project_root)
687
+ matched: Path | None = None
688
+ if spelling is not None:
689
+ matched = known.get(spelling)
690
+ if matched is None:
691
+ try:
692
+ matched = by_resolved.get(
693
+ _selector_path(spelling, project_root).resolve()
694
+ )
695
+ except OSError: # pragma: no cover - unresolvable path
696
+ matched = None
697
+ if matched is None:
698
+ matched = _match_classname(case.get("classname") or "", by_module)
699
+ if matched is None or matched in seen:
700
+ continue
701
+ seen.add(matched)
702
+ executed.append(matched)
703
+ return executed
704
+
705
+
706
+ def _write_run_evidence(
707
+ path: Path | None,
708
+ *,
709
+ project_root: str,
710
+ started_at: float,
711
+ executed_files: list[Path],
712
+ cache_hits: int,
713
+ cache_total: int,
714
+ no_cache_requested: bool,
715
+ cacheable: bool,
716
+ cache_blocked_reason: str | None,
717
+ exit_code: int,
718
+ ) -> None:
719
+ """Write this run's structured evidence without disturbing the run.
720
+
721
+ ``--evidence-json`` is an observation channel, not part of ``wl test``'s
722
+ exit-code contract: a write failure is reported on stderr and otherwise
723
+ ignored, exactly as a human-readable line would be. The gate reads the
724
+ absence as red -- the fail-closed direction, since a run nobody could
725
+ observe is not a run the gate can vouch for (ADR-0010).
726
+
727
+ ``executed_files`` are rendered relative to *project_root* (the same
728
+ spelling the user sees), and ``cache_total`` is the denominator of the
729
+ "cache usage" fact: the files this run considered for cache reuse.
730
+ """
731
+ if path is None:
732
+ return
733
+ evidence = TestRunEvidence(
734
+ executed_files=tuple(
735
+ _selector_spelling(test_file, project_root) for test_file in executed_files
736
+ ),
737
+ cache_hits=cache_hits,
738
+ cache_total=cache_total,
739
+ no_cache_requested=no_cache_requested,
740
+ cacheable=cacheable,
741
+ cache_blocked_reason=cache_blocked_reason,
742
+ exit_code=exit_code,
743
+ started_at=started_at,
744
+ finished_at=time.time(),
745
+ )
746
+ try:
747
+ evidence.write(path)
748
+ except OSError as error:
749
+ print(
750
+ f"[Evidence] Failed to write run evidence to {path}: {error}",
751
+ file=sys.stderr,
752
+ )
753
+
754
+
755
+ class TestRunner:
756
+ """Test runner for executing tests."""
757
+
758
+ def __init__(self, cache_manager: CacheManager | None = None):
759
+ """Initialize TestRunner with dependencies.
760
+
761
+ Args:
762
+ cache_manager: Optional shared CacheManager. If omitted, creates a new one.
763
+ """
764
+ self.cache_manager = cache_manager or CacheManager()
765
+ self.batch_processor = BatchProcessor(self.cache_manager)
766
+ self.single_test_executor = SingleTestExecutor(self.cache_manager)
767
+ self.report_generator = ReportGenerator()
768
+
769
+ def _run_all_tests(
770
+ self,
771
+ python_executable: str,
772
+ project_root: str,
773
+ env: dict[str, str],
774
+ report_mode: bool,
775
+ passed_args: list[str],
776
+ debug_mode: bool,
777
+ cache_report: Path | None = None,
778
+ ) -> subprocess.CompletedProcess:
779
+ """Run all tests and return the result.
780
+
781
+ ``cache_report`` is the path pytest should write its junit XML to so the
782
+ runner can tell *which tests executed* -- the positive signal a cache
783
+ write requires (rv191). It is ``None`` in report mode (the command
784
+ builder already writes ``todos/junit.xml``) and when the user owns the
785
+ ``--junitxml`` destination.
786
+ """
787
+ from .python_env_detector import PythonEnvDetector
788
+ from .test_command_builder import TestCommandBuilder
789
+
790
+ python_detector = PythonEnvDetector()
791
+ command_builder = TestCommandBuilder(python_detector)
792
+
793
+ cmd_args = command_builder.build_command_args(
794
+ report_mode,
795
+ passed_args,
796
+ debug_mode,
797
+ python_executable=python_executable,
798
+ env=env,
799
+ # 复用调用方已解析的 root,而不是让 builder 再解析一次(rv192b NIT):
800
+ # 同一个值既是 pytest 的 cwd,也是 `--cov` 的目标,两者不可能指向
801
+ # 不同的项目。
802
+ project_root=Path(project_root),
803
+ )
804
+
805
+ # 解释器与执行用 env 在 build_command_args 内部就已确定:pytest-cov 探针
806
+ # 问的是同一个 `python_executable`、拿的是同一份 `env`,此处不再事后改写
807
+ # `cmd_args[0]`——那正是「判定者与执行者不是同一个解释器」的来路。
808
+
809
+ if cache_report is not None:
810
+ # 核心 pytest 自带的 junit XML:每个真正执行的用例写一条
811
+ # <testcase>,`--collect-only` / `--version` / `--fixtures` 则一条
812
+ # 也不写。缓存写回只认这份「确有执行」的正向信号(rv191 F1)。
813
+ cmd_args.append(f"--junitxml={cache_report}")
814
+
815
+ # In all modes, pass through stdout/stderr to preserve colors and formatting
816
+ result = subprocess.run(
817
+ cmd_args,
818
+ cwd=project_root,
819
+ check=False,
820
+ env=env,
821
+ )
822
+
823
+ return result
824
+
825
+ def _cache_write_scope(
826
+ self,
827
+ blocker: str | None,
828
+ selected_files: list[Path] | None,
829
+ executed: list[Path],
830
+ ) -> list[Path] | None:
831
+ """The files this run may record as passed, or ``None`` for "record none".
832
+
833
+ Three independent gates, any of which closes the write:
834
+
835
+ 1. the run must be cacheable at all -- no filter, no loading-chain
836
+ switch, no collection-only mode, nothing injected via
837
+ ``PYTEST_ADDOPTS``;
838
+ 2. its selected file set must be derivable from the passthrough args;
839
+ 3. pytest's own report must be internally consistent (outcome count vs
840
+ testcase count) and name at least one executed, non-skipped test --
841
+ *executed* is the caller's already-derived reading of that report.
842
+
843
+ The result is the *intersection* of the selection and the evidence, so
844
+ neither a stale report nor an over-broad selector (`.` reaching into
845
+ `.venv`) can smuggle a file into the cache (rv191 F1/F3).
846
+ """
847
+ if blocker is not None:
848
+ print(f"[Cache] Not saving results: {blocker}.")
849
+ return None
850
+ if selected_files is None:
851
+ print(
852
+ "[Cache] Not saving results: this run's test files cannot be "
853
+ "determined."
854
+ )
855
+ return None
856
+ if not executed:
857
+ print(
858
+ "[Cache] Not saving results: the pytest report shows no executed test."
859
+ )
860
+ return None
861
+ executed_set = set(executed)
862
+ scope = [test_file for test_file in selected_files if test_file in executed_set]
863
+ if not scope:
864
+ print(
865
+ "[Cache] Not saving results: no selected file appears in the "
866
+ "executed set."
867
+ )
868
+ return None
869
+ return scope
870
+
871
+ def _get_all_test_files(self, project_root: str) -> list[Path]:
872
+ """Get all test files in the project."""
873
+ test_files: list[Path] = []
874
+ tests_dir = Path(project_root) / "tests"
875
+
876
+ if tests_dir.exists() and tests_dir.is_dir():
877
+ # Recursively find all Python files in tests directory
878
+ for file_path in tests_dir.rglob("*.py"):
879
+ # Skip __init__.py files
880
+ if file_path.name != "__init__.py":
881
+ test_files.append(file_path)
882
+
883
+ return test_files
884
+
885
+ @staticmethod
886
+ def _display_test_path(test_file: Path, project_root: str) -> str:
887
+ """Path as shown on an attribution progress line.
888
+
889
+ Relative to the project root when the file lives under it (the usual
890
+ case), so ``[3/87] tests/test_foo.py`` names the file the way the user
891
+ does rather than echoing an absolute temporary path.
892
+ """
893
+ try:
894
+ return str(test_file.relative_to(project_root))
895
+ except ValueError:
896
+ return str(test_file)
897
+
898
+ @staticmethod
899
+ def _step_cache_disabled(no_cache: bool, env: dict[str, str] | None) -> bool:
900
+ """Whether this step-by-step run must bypass the cache entirely.
901
+
902
+ Step mode walks the files itself and runs each one through
903
+ :class:`SingleTestExecutor`, so it inherited none of the normal path's
904
+ vetting: a ``PYTEST_ADDOPTS=--collect-only`` run executed nothing,
905
+ exited 0, and still recorded every file as passed (#199 rv199). The
906
+ same :func:`_addopts_cache_blocker` gate now covers this mode, and a
907
+ blocked value closes *both* directions of the cache -- no read, no
908
+ write -- exactly as it does in :meth:`_run_tests_normal`.
909
+ """
910
+ return no_cache or _addopts_cache_blocker(env) is not None
911
+
912
+ def _run_specified_tests_step_by_step(
913
+ self,
914
+ python_executable: str,
915
+ project_root: str,
916
+ env: dict[str, str],
917
+ debug_mode: bool,
918
+ no_cache: bool,
919
+ quiet: bool,
920
+ verbose: bool,
921
+ test_files: list[Path],
922
+ no_skip: bool = False,
923
+ evidence_path: Path | None = None,
924
+ ):
925
+ """Run specified test files in step-by-step mode."""
926
+ cache_disabled = self._step_cache_disabled(no_cache, env)
927
+ concise = is_concise_mode(override_quiet=quiet, override_verbose=verbose)
928
+
929
+ def run_one(test_file: Path) -> tuple[bool, str]:
930
+ return self.single_test_executor.execute(
931
+ python_executable,
932
+ test_file,
933
+ project_root,
934
+ env,
935
+ debug_mode,
936
+ cache_disabled,
937
+ step_by_step=True,
938
+ concise=concise,
939
+ no_skip=no_skip,
940
+ )
941
+
942
+ def run_batch(execute_single: Callable[[Path], tuple[bool, str]]) -> None:
943
+ self.batch_processor.process_specified_files(
944
+ quiet,
945
+ verbose,
946
+ test_files,
947
+ python_executable,
948
+ project_root,
949
+ env,
950
+ debug_mode,
951
+ cache_disabled,
952
+ execute_single,
953
+ )
954
+
955
+ self._run_step_batch_with_evidence(
956
+ run_one,
957
+ run_batch,
958
+ test_files=test_files,
959
+ project_root=project_root,
960
+ env=env,
961
+ no_cache=no_cache,
962
+ evidence_path=evidence_path,
963
+ )
964
+
965
+ def _run_tests_step_by_step(
966
+ self,
967
+ python_executable: str,
968
+ project_root: str,
969
+ env: dict[str, str],
970
+ debug_mode: bool,
971
+ no_cache: bool,
972
+ quiet: bool,
973
+ verbose: bool,
974
+ no_skip: bool = False,
975
+ evidence_path: Path | None = None,
976
+ ):
977
+ """Run tests in step-by-step mode, stopping at first error."""
978
+ test_files = self._get_all_test_files(project_root)
979
+ if not test_files:
980
+ # Fail closed (#191): "nothing to run" is not "everything passed".
981
+ # The quality gate drives this path; returning normally here made a
982
+ # project whose tests were never discovered exit 0 and look green.
983
+ print("No test files found.")
984
+ # The evidence still records what happened: no file ran, and the
985
+ # run terminates non-zero. A gate that requested evidence must not
986
+ # have to guess why the file is absent (#212).
987
+ blocker = _addopts_cache_blocker(env)
988
+ _write_run_evidence(
989
+ evidence_path,
990
+ project_root=project_root,
991
+ started_at=time.time(),
992
+ executed_files=[],
993
+ cache_hits=0,
994
+ cache_total=0,
995
+ no_cache_requested=no_cache,
996
+ cacheable=blocker is None,
997
+ cache_blocked_reason=blocker,
998
+ exit_code=1,
999
+ )
1000
+ sys.exit(1)
1001
+
1002
+ cache_disabled = self._step_cache_disabled(no_cache, env)
1003
+ concise = is_concise_mode(override_quiet=quiet, override_verbose=verbose)
1004
+
1005
+ def run_one(test_file: Path) -> tuple[bool, str]:
1006
+ return self.single_test_executor.execute(
1007
+ python_executable,
1008
+ test_file,
1009
+ project_root,
1010
+ env,
1011
+ debug_mode,
1012
+ cache_disabled,
1013
+ step_by_step=True,
1014
+ concise=concise,
1015
+ no_skip=no_skip,
1016
+ )
1017
+
1018
+ def run_batch(execute_single: Callable[[Path], tuple[bool, str]]) -> None:
1019
+ self.batch_processor.process_all_files(
1020
+ quiet,
1021
+ verbose,
1022
+ test_files,
1023
+ python_executable,
1024
+ project_root,
1025
+ env,
1026
+ debug_mode,
1027
+ cache_disabled,
1028
+ execute_single,
1029
+ )
1030
+
1031
+ self._run_step_batch_with_evidence(
1032
+ run_one,
1033
+ run_batch,
1034
+ test_files=test_files,
1035
+ project_root=project_root,
1036
+ env=env,
1037
+ no_cache=no_cache,
1038
+ evidence_path=evidence_path,
1039
+ )
1040
+
1041
+ def _run_step_batch_with_evidence(
1042
+ self,
1043
+ run_one: Callable[[Path], tuple[bool, str]],
1044
+ run_batch: Callable[[Callable[[Path], tuple[bool, str]]], None],
1045
+ *,
1046
+ test_files: list[Path],
1047
+ project_root: str,
1048
+ env: dict[str, str],
1049
+ no_cache: bool,
1050
+ evidence_path: Path | None,
1051
+ ) -> None:
1052
+ """Run one step-mode batch and write its single aggregated evidence.
1053
+
1054
+ Step mode calls ``sys.exit(1)`` on the first failing file, so a
1055
+ terminating path arrives here as ``SystemExit``. The exit code is
1056
+ captured and the record written in ``finally``: one file per
1057
+ invocation, aggregating every file the batch processed. Files served
1058
+ from the cache are *not* "executed" -- the record lists exactly the
1059
+ files the executor actually ran, derived from the run's own hit set.
1060
+ """
1061
+ started_at = time.time()
1062
+ attempted: list[Path] = []
1063
+ attempted_lock = threading.Lock()
1064
+
1065
+ def execute(test_file: Path) -> tuple[bool, str]:
1066
+ # Recording before the call: the file was handed to the executor,
1067
+ # so it is at worst a near-miss, and a cache hit is filtered out
1068
+ # below from the run's own hit set.
1069
+ with attempted_lock:
1070
+ attempted.append(test_file)
1071
+ return run_one(test_file)
1072
+
1073
+ blocker = _addopts_cache_blocker(env)
1074
+ exit_code = 0
1075
+ try:
1076
+ run_batch(execute)
1077
+ except SystemExit as error:
1078
+ exit_code = error.code if isinstance(error.code, int) else 1
1079
+ raise
1080
+ except BaseException:
1081
+ exit_code = 1
1082
+ raise
1083
+ finally:
1084
+ cache_hits = self.cache_manager.cache_hit_files
1085
+ _write_run_evidence(
1086
+ evidence_path,
1087
+ project_root=project_root,
1088
+ started_at=started_at,
1089
+ executed_files=[
1090
+ test_file for test_file in attempted if test_file not in cache_hits
1091
+ ],
1092
+ cache_hits=len(cache_hits),
1093
+ cache_total=len(test_files),
1094
+ no_cache_requested=no_cache,
1095
+ cacheable=blocker is None,
1096
+ cache_blocked_reason=blocker,
1097
+ exit_code=exit_code,
1098
+ )
1099
+
1100
+ def _record_cache_for_result(
1101
+ self,
1102
+ result: subprocess.CompletedProcess,
1103
+ executed_files: list[Path] | None,
1104
+ *,
1105
+ passed_args: list[str],
1106
+ python_executable: str,
1107
+ project_root: str,
1108
+ env: dict[str, str],
1109
+ concise: bool,
1110
+ ) -> None:
1111
+ """Update cache from a batch result, for the files execution was proven.
1112
+
1113
+ A fully-passing suite records those files in bulk; a failing suite is
1114
+ attributed per file (each file re-run through the single-file executor
1115
+ so passing files get cached and failing ones are dropped). A run with
1116
+ no proof records nothing at all (fail-closed, #191).
1117
+ """
1118
+ if result.returncode == 0:
1119
+ if not executed_files:
1120
+ print(
1121
+ "[Cache] Not saving results: no executed test was proven "
1122
+ "for this run."
1123
+ )
1124
+ else:
1125
+ self.cache_manager.record_passed_batch(
1126
+ executed_files,
1127
+ concise,
1128
+ python_executable=python_executable,
1129
+ )
1130
+ else:
1131
+ print("Tests failed, updating cache for individual test files...")
1132
+ # The same selection/behavior switches the batch run used, so a
1133
+ # file's verdict is attributed under the filter it was produced
1134
+ # under (a `-k`-excluded failing test must not drop this file).
1135
+ extra_args = _attribution_args(passed_args)
1136
+ scope = executed_files or []
1137
+ if not scope:
1138
+ print(
1139
+ "[Cache] Skipping per-file attribution: no executed test "
1140
+ "was proven for this run."
1141
+ )
1142
+ total = len(scope)
1143
+ for index, test_file in enumerate(scope, start=1):
1144
+ # One line per file: this pass is serial and can dwarf the
1145
+ # batch run on a cold cache, so silence is not an option.
1146
+ progress = (
1147
+ f"[{index}/{total}] "
1148
+ f"{self._display_test_path(test_file, project_root)}"
1149
+ )
1150
+ log_info_pair(progress, progress)
1151
+ self.single_test_executor.execute(
1152
+ python_executable,
1153
+ test_file,
1154
+ project_root,
1155
+ env,
1156
+ debug_mode=False,
1157
+ no_cache=False,
1158
+ step_by_step=False,
1159
+ concise=concise,
1160
+ extra_args=extra_args,
1161
+ )
1162
+ # Show cache statistics after test run
1163
+ if not concise:
1164
+ self.cache_manager.report_detail()
1165
+ # Cleanup expired cache files (runs in background)
1166
+ try:
1167
+ self.cache_manager.purge_expired()
1168
+ except Exception:
1169
+ # Ignore cleanup errors
1170
+ pass
1171
+
1172
+ def _run_tests_normal(
1173
+ self,
1174
+ python_executable: str,
1175
+ project_root: str,
1176
+ env: dict[str, str],
1177
+ report_mode: bool,
1178
+ passed_args: list[str],
1179
+ debug_mode: bool,
1180
+ todo_dir: Path,
1181
+ no_cache: bool = False,
1182
+ quiet: bool = False,
1183
+ verbose: bool = False,
1184
+ evidence_path: Path | None = None,
1185
+ ) -> None:
1186
+ """Run tests in normal mode with caching.
1187
+
1188
+ When *evidence_path* is given, one structured record is written on
1189
+ **every** terminating path -- the all-cached short circuit above pytest,
1190
+ a passing or failing suite, and an exception -- so a consumer never has
1191
+ to infer "did this run execute anything?" from stdout (#212).
1192
+ """
1193
+ from ...utils.output_context import is_concise_mode
1194
+
1195
+ evidence_started_at = time.time()
1196
+
1197
+ # Start the manager's per-run hit tracking here, as step mode does, so
1198
+ # the evidence's ``cache_hits`` can only ever describe this run (#212
1199
+ # review L1). Normal mode runs one pytest batch and never consults the
1200
+ # cache per file, so this set stays empty today; the record still reads
1201
+ # it instead of writing a literal 0, so a future partial-reuse path
1202
+ # cannot silently report "0 hits" over reused results.
1203
+ self.cache_manager.start_run()
1204
+
1205
+ # Known test files, the subset this run selects, and -- because a green
1206
+ # exit code alone proves nothing (rv191 F1) -- the evidence pytest
1207
+ # writes about what it actually executed.
1208
+ test_files = self._get_all_test_files(project_root)
1209
+ known = {
1210
+ _selector_spelling(test_file, project_root): test_file
1211
+ for test_file in test_files
1212
+ }
1213
+ option_names, _selectors = _split_passthrough_args(passed_args)
1214
+ # Two doors carry pytest switches into the run: the command line and
1215
+ # `PYTEST_ADDOPTS`, which pytest prepends to every command line. Both
1216
+ # are vetted against the same allowlist (#199).
1217
+ cache_blocker = _cache_write_blocker(option_names) or _addopts_cache_blocker(
1218
+ env
1219
+ )
1220
+ selected_files = _executed_test_files(passed_args, project_root, test_files)
1221
+ # The denominator of the "cache usage" fact: the files this run would
1222
+ # consider. An unknown selection still has a known suite size, so the
1223
+ # evidence says "0 of N", not "0 of 0" (#212).
1224
+ cache_total = (
1225
+ len(selected_files) if selected_files is not None else len(test_files)
1226
+ )
1227
+
1228
+ concise = is_concise_mode(override_quiet=quiet, override_verbose=verbose)
1229
+
1230
+ # Check if we can use cached results for every file this run would run.
1231
+ # With an unknown selection (or a run the cache cannot speak about) the
1232
+ # only safe answer is "run, don't cache".
1233
+ all_cached = (
1234
+ not no_cache
1235
+ and cache_blocker is None
1236
+ and selected_files is not None
1237
+ and self.cache_manager.all_cached(
1238
+ selected_files, concise, python_executable=python_executable
1239
+ )
1240
+ )
1241
+
1242
+ if all_cached:
1243
+ # All tests passed in cache, no need to run
1244
+ print("All tests passed successfully! (using cache)")
1245
+ # Show cache statistics
1246
+ self.cache_manager.report_detail()
1247
+ # The short circuit is the very state the gate must judge red, so
1248
+ # its evidence is written explicitly: nothing executed, every file
1249
+ # served from the cache (#212).
1250
+ _write_run_evidence(
1251
+ evidence_path,
1252
+ project_root=project_root,
1253
+ started_at=evidence_started_at,
1254
+ executed_files=[],
1255
+ cache_hits=cache_total,
1256
+ cache_total=cache_total,
1257
+ no_cache_requested=no_cache,
1258
+ cacheable=cache_blocker is None,
1259
+ cache_blocked_reason=cache_blocker,
1260
+ exit_code=0,
1261
+ )
1262
+ return
1263
+
1264
+ # Need to run tests
1265
+ report_path, owns_report = _execution_report(passed_args, report_mode)
1266
+ started_at = time.time()
1267
+ exit_code = 1
1268
+ executed_files: list[Path] | None = None
1269
+ executed_from_report: list[Path] = []
1270
+ try:
1271
+ result = self._run_all_tests(
1272
+ python_executable,
1273
+ project_root,
1274
+ env,
1275
+ report_mode,
1276
+ passed_args,
1277
+ debug_mode,
1278
+ cache_report=report_path,
1279
+ )
1280
+ # pytest's own report is the positive signal behind both the cache
1281
+ # write and the evidence's ``executed_files`` -- derived once, so
1282
+ # the record and the cache scope can never disagree.
1283
+ executed_from_report = _executed_files_from_report(
1284
+ report_path, project_root, known, started_at
1285
+ )
1286
+ executed_files = (
1287
+ None
1288
+ if no_cache
1289
+ else self._cache_write_scope(
1290
+ cache_blocker, selected_files, executed_from_report
1291
+ )
1292
+ )
1293
+
1294
+ from .test_result_handler import TestResultHandler
1295
+
1296
+ result_handler = TestResultHandler()
1297
+ exit_code = result_handler.handle_test_result(result, report_mode)
1298
+
1299
+ if report_mode:
1300
+ self.report_generator.generate(todo_dir, project_root, verbose)
1301
+
1302
+ if not no_cache:
1303
+ self._record_cache_for_result(
1304
+ result,
1305
+ executed_files,
1306
+ passed_args=passed_args,
1307
+ python_executable=python_executable,
1308
+ project_root=project_root,
1309
+ env=env,
1310
+ concise=concise,
1311
+ )
1312
+ except SystemExit as error:
1313
+ exit_code = error.code if isinstance(error.code, int) else 1
1314
+ raise
1315
+ except BaseException:
1316
+ if exit_code == 0: # pragma: no cover - defensive, exit_code starts 1
1317
+ exit_code = 1
1318
+ raise
1319
+ finally:
1320
+ if owns_report and report_path is not None:
1321
+ try:
1322
+ report_path.unlink()
1323
+ except OSError:
1324
+ pass
1325
+ _write_run_evidence(
1326
+ evidence_path,
1327
+ project_root=project_root,
1328
+ started_at=evidence_started_at,
1329
+ executed_files=executed_from_report,
1330
+ # The same fact step mode reads, from the manager's own per-run
1331
+ # hit set -- never a literal 0 (#212 review L1).
1332
+ cache_hits=len(self.cache_manager.cache_hit_files),
1333
+ cache_total=cache_total,
1334
+ no_cache_requested=no_cache,
1335
+ cacheable=cache_blocker is None,
1336
+ cache_blocked_reason=cache_blocker,
1337
+ exit_code=exit_code,
1338
+ )
1339
+
1340
+ # Exit only after report generation and cache attribution have run,
1341
+ # so a failing suite still produces its reports and cache state.
1342
+ if exit_code:
1343
+ sys.exit(exit_code)