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,1156 @@
1
+ """Cache management module for test commands (the test-result cache adapter).
2
+
3
+ This module is one of the three adapters over the single cache seam
4
+ :class:`~wl_cli.utils.cache_ledger.CacheLedger` (#213, ADR-0011). It owns what
5
+ this cache decides for itself -- the key inputs, the user-level location and the
6
+ 24-hour TTL -- and delegates key spelling, storage, freshness and the error
7
+ policy to the ledger.
8
+
9
+ Classes in this module:
10
+ - CacheStats: Cache statistics data class
11
+ - CacheMaintenance: Cache purge and statistics over a KeyedStore
12
+ - DependencyAnalyzer: Concrete dependency analyzer with public API
13
+ - CacheKeyGenerator: Generates the cache inputs (and their key) from file
14
+ content, dependencies and the environment of the verdict
15
+ - CacheManager: Facade that delegates to the specialized components above
16
+ """
17
+
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import random
22
+ import subprocess
23
+ import sys
24
+ import threading
25
+ import time
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from ...utils.cache_ledger import CacheInputs, CacheLedger, format_key
31
+ from ...utils.cache_store import KeyedStore, is_fresh
32
+ from ...utils.config import get_config
33
+ from .utils.project_root import project_root_for
34
+
35
+
36
+ class CacheKeyUnavailableError(RuntimeError):
37
+ """The cache key cannot be derived truthfully, so nothing may be cached.
38
+
39
+ Raised instead of falling back to a weaker key: an entry written under a
40
+ key that ignored the pytest configuration (or the interpreter) would be
41
+ served to a later run that is *not* the same run, which is exactly the
42
+ false green #191 removes. Callers treat it as "skip the cache".
43
+ """
44
+
45
+
46
+ def _log_safe_key(key: str) -> str:
47
+ """Render a key for a log line, escaping characters the stream cannot encode.
48
+
49
+ A lone surrogate is a legal ``str`` but cannot be written to a UTF-8 stdout,
50
+ so interpolating one raw into a ``print`` raises ``UnicodeEncodeError`` --
51
+ which in :meth:`CacheManager.delete` would escape as a crash. Every other
52
+ key, including the empty string and control characters, is returned
53
+ unchanged, so existing log lines are byte-for-byte unaffected.
54
+ """
55
+ return key.encode("utf-8", "backslashreplace").decode("utf-8")
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Cache location
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ def default_test_cache_dir() -> Path:
64
+ """Resolve the directory the test-result cache lives in.
65
+
66
+ User-level on purpose (``test_cache.cache_dir`` from configuration, falling
67
+ back to ``~/.cache/wl``, plus ``test-cache``): worktrees and the main
68
+ checkout share it, and the key carries the file path, so the shared store
69
+ does not collide. The location is this adapter's policy, not a technical
70
+ debt -- see ADR-0011.
71
+
72
+ Returns:
73
+ Path to the test-cache directory.
74
+ """
75
+ cache_root = get_config("test_cache.cache_dir", None)
76
+ if cache_root and isinstance(cache_root, str):
77
+ return Path(cache_root).expanduser() / "test-cache"
78
+
79
+ return Path.home() / ".cache" / "wl" / "test-cache"
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # CacheStats & CacheMaintenance
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class CacheStats:
89
+ """Cache statistics for display.
90
+
91
+ Attributes:
92
+ total_entries: Number of cached test results
93
+ total_size_bytes: Total size of all cache files in bytes
94
+ """
95
+
96
+ total_entries: int
97
+ total_size_bytes: int
98
+
99
+
100
+ class CacheMaintenance:
101
+ """Cache purge and statistics operations over a :class:`KeyedStore`.
102
+
103
+ Expiry is judged from each entry's own ``timestamp`` field through
104
+ :func:`is_fresh` -- never from file mtime -- so the test-cache stack has a
105
+ single freshness clock.
106
+ """
107
+
108
+ def __init__(self, store: KeyedStore):
109
+ """Initialize with the store to maintain.
110
+
111
+ Args:
112
+ store: KeyedStore holding the test-cache entries.
113
+ """
114
+ self._store = store
115
+
116
+ def purge_expired(self, max_age_seconds: float) -> int:
117
+ """Remove entries whose payload timestamp is older than the limit.
118
+
119
+ An entry with no timestamp (or a non-numeric one) counts as expired, and
120
+ an entry that cannot be read or is not a JSON object is removed as
121
+ well: those files can never become hits, so a purge reclaims them.
122
+ Leftover temporary files from interrupted writes are swept too.
123
+
124
+ A file whose name is not a legal key (``.json`` yields the empty key,
125
+ ``a<TAB>b.json`` a control-character key) is reported by
126
+ ``iter_entry_files`` but refused by ``invalidate``, so the deletion
127
+ falls back to ``invalidate_file`` -- otherwise statistics counted a
128
+ file the purge could never reclaim (#172 wave 9).
129
+
130
+ Args:
131
+ max_age_seconds: Maximum age of a fresh entry, in seconds.
132
+
133
+ Returns:
134
+ Number of deleted cache files, temporary files included
135
+ """
136
+ deleted = 0
137
+ for key, entry in self._store.iter_entry_files():
138
+ if entry is not None and is_fresh(entry, max_age_seconds):
139
+ continue
140
+ if self._store.invalidate(key) or self._store.invalidate_file(key):
141
+ deleted += 1
142
+ return deleted + self._store.remove_temp_files()
143
+
144
+ def clear_all(self) -> int:
145
+ """Clear all cache entries.
146
+
147
+ Returns:
148
+ Number of deleted cache files
149
+ """
150
+ return self._store.clear()
151
+
152
+ def get_stats(self) -> CacheStats:
153
+ """Get current cache statistics.
154
+
155
+ ``total_entries`` and ``total_size_bytes`` cover the same set -- every
156
+ entry file in the store, unusable ones included -- so the two numbers
157
+ can be reconciled. A purge is what reclaims those unusable files.
158
+
159
+ Returns:
160
+ CacheStats object with current statistics
161
+ """
162
+ return CacheStats(
163
+ total_entries=sum(1 for _ in self._store.iter_entry_files()),
164
+ total_size_bytes=self._store.total_size_bytes(),
165
+ )
166
+
167
+ def print_stats(self, output: bool = True) -> str:
168
+ """Format cache statistics as a human-readable string.
169
+
170
+ Args:
171
+ output: Whether to print directly to stdout
172
+
173
+ Returns:
174
+ Formatted string with statistics
175
+ """
176
+ stats = self.get_stats()
177
+
178
+ def format_size(bytes_value: int) -> str:
179
+ """Convert bytes to human-readable format."""
180
+ if bytes_value < 1024:
181
+ return f"{bytes_value} B"
182
+ elif bytes_value < 1024 * 1024:
183
+ return f"{bytes_value / 1024:.1f} KB"
184
+ elif bytes_value < 1024 * 1024 * 1024:
185
+ return f"{bytes_value / (1024 * 1024):.1f} MB"
186
+ else:
187
+ return f"{bytes_value / (1024 * 1024 * 1024):.1f} GB"
188
+
189
+ lines = [
190
+ "Cache Statistics:",
191
+ f" * Entries: {stats.total_entries}",
192
+ f" * Total Size: {format_size(stats.total_size_bytes)}",
193
+ ]
194
+ result = "\n".join(lines)
195
+
196
+ if output:
197
+ print(result)
198
+
199
+ return result
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # DependencyAnalyzer (thin wrapper around BaseDependencyAnalyzer)
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ from .base_dependency_analyzer import BaseDependencyAnalyzer # noqa: E402
208
+
209
+
210
+ class DependencyAnalyzer(BaseDependencyAnalyzer):
211
+ """Analyze Python file dependencies using AST parsing.
212
+
213
+ This class provides a public API for analyzing all dependencies
214
+ of a test file, including indirect dependencies through imports.
215
+ """
216
+
217
+ def __init__(
218
+ self, module_file_cache: dict[tuple[str, str], Path | None] | None = None
219
+ ):
220
+ """Initialize with optional module file cache.
221
+
222
+ Args:
223
+ module_file_cache: Optional cache for found module files
224
+ Caches (module_name, src_dir_str) -> Path found result
225
+ """
226
+ super().__init__(module_file_cache)
227
+
228
+ def analyze(self, test_file: Path) -> list[Path]:
229
+ """Analyze a test file and recursively find all dependencies.
230
+
231
+ Args:
232
+ test_file: Path to the test file to analyze
233
+
234
+ Returns:
235
+ List of all found dependency file paths
236
+ """
237
+ return self._get_test_dependencies(test_file)
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # CacheKeyGenerator
242
+ # ---------------------------------------------------------------------------
243
+
244
+
245
+ #: pytest config files, in the order pytest itself tries them in a directory.
246
+ _PYTEST_CONFIG_FILENAMES = ("pytest.ini", "pyproject.toml", "tox.ini", "setup.cfg")
247
+
248
+ #: Environment variables that change what the child pytest process does.
249
+ #:
250
+ #: The rule for membership is "a different value can change the verdict", not
251
+ #: "the value looks important": a variable that only changes how an identical
252
+ #: run is *reported* stays out, because keying it would turn a legitimate hit
253
+ #: into a re-run.
254
+ _KEY_ENVIRONMENT_VARIABLES = (
255
+ "PYTHONPATH",
256
+ "WL_ENVIRONMENT",
257
+ "CI",
258
+ "PYTHON_VERSION",
259
+ # `PYTEST_ADDOPTS` is appended to every pytest command line: it can select,
260
+ # filter and reconfigure the run, so it belongs in the key (#191).
261
+ "PYTEST_ADDOPTS",
262
+ "PYTEST_PLUGINS",
263
+ "PYTEST_DISABLE_PLUGIN_AUTOLOAD",
264
+ # `PYTHONWARNINGS` is the child interpreter's warning filter, and `error`
265
+ # turns a warning into a raised exception -- a different verdict, not a
266
+ # differently-formatted report. wl appends its own `-W` switches to every
267
+ # run, so this variable is the only per-run warning input the key could not
268
+ # see; wl's `-W ignore::UserWarning` is why the reproducing test has to
269
+ # emit a `FutureWarning` (#209).
270
+ "PYTHONWARNINGS",
271
+ # `PYTHONOPTIMIZE` (`-O`/`-OO`) strips `assert` statements before the test
272
+ # runs, so a test whose only check is an assert can pass under it and fail
273
+ # without it. Verdict-level, and previously unseen (#209 audit).
274
+ "PYTHONOPTIMIZE",
275
+ # `PYTHONDEVMODE` (`-X dev`) widens the startup warning filters and turns
276
+ # asyncio debug mode on, which emits warnings a test can observe (#209
277
+ # audit).
278
+ "PYTHONDEVMODE",
279
+ # `PYTHONHASHSEED` pins str/bytes hashing. Unset -- and, equivalently, the
280
+ # explicit value `random` -- means a fresh random seed per process, so no
281
+ # cache can be sound against *that*; an integer seed can order a set
282
+ # differently from the run that wrote the entry, so a set value must move
283
+ # the key (#209 audit).
284
+ "PYTHONHASHSEED",
285
+ # `PYTHONUTF8` / `PYTHONIOENCODING` change the child's default text encoding
286
+ # and stdio encoding, which tests that read, write or compare text observe
287
+ # (#209 audit).
288
+ "PYTHONUTF8",
289
+ "PYTHONIOENCODING",
290
+ # `PYTEST_TIMEOUT` (pytest-timeout) fails a test that overruns it: a pass
291
+ # recorded without a limit is not a pass under one (#209 audit).
292
+ "PYTEST_TIMEOUT",
293
+ )
294
+
295
+ #: Runs inside the *test* interpreter, never in the wl process: the fingerprint
296
+ #: must describe the interpreter that will run pytest -- wl itself may live in
297
+ #: a different environment (uv tool env vs project .venv).
298
+ _FINGERPRINT_CODE = """
299
+ import json
300
+ import sys
301
+ import importlib.metadata as md
302
+
303
+ plugins = {}
304
+ try:
305
+ entry_points = md.entry_points(group="pytest11")
306
+ except Exception:
307
+ entry_points = ()
308
+ for entry_point in entry_points:
309
+ dist = getattr(entry_point, "dist", None)
310
+ try:
311
+ if dist is None:
312
+ plugins[entry_point.name] = "unknown"
313
+ else:
314
+ plugins[dist.metadata["Name"] or entry_point.name] = dist.version
315
+ except Exception:
316
+ plugins[entry_point.name] = "unknown"
317
+ try:
318
+ pytest_version = md.version("pytest")
319
+ except Exception:
320
+ pytest_version = None
321
+ print(
322
+ json.dumps(
323
+ {
324
+ "implementation": sys.implementation.name,
325
+ "version": sys.version,
326
+ "executable": sys.executable,
327
+ "prefix": sys.prefix,
328
+ "base_prefix": getattr(sys, "base_prefix", sys.prefix),
329
+ "pytest": pytest_version,
330
+ "plugins": plugins,
331
+ },
332
+ sort_keys=True,
333
+ )
334
+ )
335
+ """
336
+
337
+
338
+ def _interpreter_fingerprint(python_executable: str) -> str:
339
+ """SHA-256 of what the pytest-running interpreter *is*.
340
+
341
+ Covers the interpreter identity (implementation, version, executable,
342
+ prefix/base prefix) and the pytest/plugin versions importable **by that
343
+ interpreter**. The old key recorded the pytest version of the wl process,
344
+ which says nothing when the tests run in a project virtualenv.
345
+
346
+ Deliberately unmemoised: the probe costs a subprocess, and callers memoise
347
+ per run (see :class:`CacheKeyGenerator`). Keeping the memo out of module
348
+ state means one mis-answered probe cannot leak into an unrelated run.
349
+
350
+ Raises:
351
+ CacheKeyUnavailableError: the interpreter cannot be fingerprinted (missing
352
+ binary, nonzero exit, unparsable output, no pytest importable).
353
+ A key without this fingerprint would let a cache entry cross
354
+ interpreters, so the caller must not cache at all.
355
+ """
356
+ try:
357
+ completed = subprocess.run(
358
+ [python_executable, "-c", _FINGERPRINT_CODE],
359
+ capture_output=True,
360
+ text=True,
361
+ timeout=30,
362
+ check=False,
363
+ )
364
+ except (OSError, subprocess.SubprocessError) as error:
365
+ raise CacheKeyUnavailableError(
366
+ f"cannot fingerprint interpreter {python_executable!r}: {error}"
367
+ ) from error
368
+
369
+ if completed.returncode != 0:
370
+ raise CacheKeyUnavailableError(
371
+ f"cannot fingerprint interpreter {python_executable!r}: "
372
+ f"exit {completed.returncode}: {completed.stderr.strip()[:200]}"
373
+ )
374
+ try:
375
+ payload = json.loads(completed.stdout)
376
+ except ValueError as error:
377
+ raise CacheKeyUnavailableError(
378
+ f"cannot fingerprint interpreter {python_executable!r}: "
379
+ f"unparsable output: {error}"
380
+ ) from error
381
+ if not isinstance(payload, dict) or not payload.get("pytest"):
382
+ raise CacheKeyUnavailableError(
383
+ f"cannot fingerprint interpreter {python_executable!r}: "
384
+ "pytest is not importable by that interpreter"
385
+ )
386
+
387
+ return hashlib.sha256(
388
+ json.dumps(payload, sort_keys=True).encode("utf-8")
389
+ ).hexdigest()
390
+
391
+
392
+ def _nearest_pytest_config_root(start: Path) -> Path | None:
393
+ """Nearest ancestor of *start* (inclusive) holding a pytest config file.
394
+
395
+ This is pytest's own rootdir rule in miniature (nearest config wins), and
396
+ it is deliberately independent of the process working directory: the cache
397
+ key must describe *the file's* environment, not wherever wl was launched.
398
+ """
399
+ for directory in (start, *start.parents):
400
+ for filename in _PYTEST_CONFIG_FILENAMES:
401
+ if (directory / filename).is_file():
402
+ return directory
403
+ return None
404
+
405
+
406
+ def _pytest_context_files(test_file: Path) -> list[Path]:
407
+ """conftest.py files and pytest config files that shape *test_file*'s run.
408
+
409
+ pytest builds a verdict out of more than the file and its imports: every
410
+ ``conftest.py`` between the rootdir and the test's directory is executed
411
+ before the test, and one config file supplies ``addopts``, ``testpaths``
412
+ and the plugin set. The AST import graph used to miss all of it, so editing
413
+ a conftest or ``pytest.ini`` left the cache key byte-for-byte unchanged
414
+ (#191).
415
+
416
+ The walk starts at the test file's own directory and stops at the config
417
+ root (or, when no config file exists, at the subsystem's project root when
418
+ that is an ancestor, else the filesystem root) -- the same boundary pytest
419
+ uses when it decides which conftest files apply.
420
+ """
421
+ directory = test_file.parent
422
+ root = _nearest_pytest_config_root(directory)
423
+ if root is None:
424
+ fallback = Path(project_root_for())
425
+ try:
426
+ fallback = fallback.resolve()
427
+ except OSError:
428
+ pass
429
+ if fallback in (directory, *directory.parents):
430
+ root = fallback
431
+
432
+ chain: list[Path] = []
433
+ current = directory
434
+ while True:
435
+ chain.append(current)
436
+ if current == root or current.parent == current:
437
+ break
438
+ current = current.parent
439
+ chain.reverse()
440
+
441
+ files: list[Path] = []
442
+ for item in chain:
443
+ conftest = item / "conftest.py"
444
+ if conftest.is_file():
445
+ files.append(conftest)
446
+ for filename in _PYTEST_CONFIG_FILENAMES:
447
+ candidate = item / filename
448
+ if candidate.is_file():
449
+ files.append(candidate)
450
+ return files
451
+
452
+
453
+ try:
454
+ import pytest # noqa: E402
455
+
456
+ HAS_PYTEST = True
457
+ except ImportError:
458
+ HAS_PYTEST = False
459
+
460
+
461
+ class CacheKeyGenerator(BaseDependencyAnalyzer):
462
+ """Derive the test-result cache inputs for a test file.
463
+
464
+ This generator is the test adapter's answer to "what enters the key"
465
+ (#213, ADR-0011): an ordered list of normalized fragments that the ledger
466
+ folds into ``test_<sha256>``. The fragments are:
467
+
468
+ - Test file path and modification/change times
469
+ - Test file content
470
+ - All dependency file paths, times, and content
471
+ - Every ``conftest.py`` and pytest config file that applies to the file
472
+ (rootdir down to the file's directory) (#191)
473
+ - Relevant environment variables: the pytest switches (``PYTEST_ADDOPTS``,
474
+ ``PYTEST_PLUGINS``, ``PYTEST_DISABLE_PLUGIN_AUTOLOAD``, and
475
+ ``PYTEST_TIMEOUT``, which the pytest-timeout plugin reads rather than the
476
+ interpreter startup), the child interpreter's startup inputs
477
+ (``PYTHONWARNINGS``, ``PYTHONOPTIMIZE``, ``PYTHONDEVMODE``,
478
+ ``PYTHONHASHSEED``, ``PYTHONUTF8``, ``PYTHONIOENCODING``), and the
479
+ ambient identity variables (``PYTHONPATH``, ``PYTHON_VERSION``, ``CI``,
480
+ ``WL_ENVIRONMENT``) (#191, #209)
481
+ - The wl process's pytest version (if available)
482
+ - A fingerprint of the interpreter that will run pytest (version, prefix,
483
+ and the pytest/plugin versions it can import) (#191)
484
+
485
+ When a part cannot be read the generator raises
486
+ :class:`CacheKeyUnavailableError` instead of returning a weaker input set.
487
+ """
488
+
489
+ #: The cache this adapter feeds. Becomes the key's prefix.
490
+ NAMESPACE = "test"
491
+
492
+ def __init__(
493
+ self, dependency_cache: dict[str, tuple[list[Path], float]] | None = None
494
+ ):
495
+ """Initialize with optional dependency cache.
496
+
497
+ Args:
498
+ dependency_cache: Optional shared cache for dependency lookups
499
+ Stores (dependencies list, mtime) keyed by file path string
500
+ """
501
+ # Initialize base class with empty module file cache
502
+ super().__init__()
503
+ self._dependency_cache = (
504
+ dependency_cache if dependency_cache is not None else {}
505
+ )
506
+ self._content_read_warnings: list[str] = []
507
+ # Interpreter fingerprints, memoised for this generator (= one test
508
+ # run): a probe costs a subprocess and the key is asked for once per
509
+ # test file. Failures are memoised too, so an interpreter that cannot
510
+ # answer is not re-probed for every file in the suite.
511
+ self._fingerprints: dict[str, str] = {}
512
+ self._fingerprint_failures: dict[str, str] = {}
513
+
514
+ def generate(self, test_file: Path, python_executable: str | None = None) -> str:
515
+ """Generate the cache key for a test file from its cache inputs.
516
+
517
+ Args:
518
+ test_file: Path to the test file
519
+ python_executable: Interpreter that will run pytest for this file.
520
+ Defaults to the interpreter running wl, which is only correct
521
+ when the two are the same; every production caller passes the
522
+ real one.
523
+
524
+ Returns:
525
+ The ledger's key: ``test_`` followed by the full SHA-256 digest of
526
+ the normalized parts.
527
+
528
+ Raises:
529
+ CacheKeyUnavailableError: a part of the key could not be derived
530
+ truthfully (an unreadable conftest/config file, or an
531
+ interpreter that cannot be fingerprinted). The caller must then
532
+ treat the file as uncacheable.
533
+ """
534
+ return format_key(self.cache_inputs(test_file, python_executable))
535
+
536
+ def cache_inputs(
537
+ self, test_file: Path, python_executable: str | None = None
538
+ ) -> CacheInputs:
539
+ """Build the ordered, normalized fragments that decide this file's key.
540
+
541
+ Args:
542
+ test_file: Path to the test file
543
+ python_executable: Interpreter that will run pytest for this file.
544
+
545
+ Returns:
546
+ The test adapter's :class:`CacheInputs`.
547
+
548
+ Raises:
549
+ CacheKeyUnavailableError: a part of the key could not be derived
550
+ truthfully (an unreadable conftest/config file, or an
551
+ interpreter that cannot be fingerprinted).
552
+ """
553
+ parts: list[str] = []
554
+
555
+ # Test file path and modification/change times. A path with normalized
556
+ # separators, so the same file spells the same key on every platform.
557
+ file_path_str = str(test_file).replace("\\", "/")
558
+ try:
559
+ mtime = os.path.getmtime(test_file)
560
+ ctime = os.path.getctime(test_file)
561
+ except OSError:
562
+ mtime = 0.0
563
+ ctime = 0.0
564
+ parts.extend((file_path_str, str(mtime), str(ctime)))
565
+
566
+ # Test file content. An unreadable file contributes a per-run random
567
+ # value instead, so its entry can never be mistaken for a real one.
568
+ try:
569
+ content = test_file.read_text(encoding="utf-8")
570
+ if not isinstance(content, str):
571
+ content = str(content)
572
+ parts.append(content)
573
+ except (OSError, UnicodeDecodeError):
574
+ parts.append(f"{random.random()}-{time.time()}")
575
+ if file_path_str not in self._content_read_warnings:
576
+ self._content_read_warnings.append(file_path_str)
577
+
578
+ # Dependency files, in sorted path order.
579
+ dependencies = self._get_test_dependencies(test_file)
580
+ sorted_dependencies = sorted(dependencies, key=lambda x: str(x))
581
+ for dep_file in sorted_dependencies:
582
+ dep_path_str = str(dep_file).replace("\\", "/")
583
+ parts.append(dep_path_str)
584
+ try:
585
+ parts.append(str(os.path.getmtime(dep_file)))
586
+ parts.append(str(os.path.getctime(dep_file)))
587
+ parts.append(dep_file.read_text(encoding="utf-8"))
588
+ except (OSError, UnicodeDecodeError):
589
+ parts.append(str(time.time()))
590
+
591
+ # conftest.py chain + pytest configuration: what pytest loads *around*
592
+ # the file, which the import graph above cannot see (#191).
593
+ for context_file in _pytest_context_files(test_file):
594
+ try:
595
+ context_stat = context_file.stat()
596
+ context_content = context_file.read_bytes()
597
+ except OSError as error:
598
+ raise CacheKeyUnavailableError(
599
+ f"cannot read pytest context file {context_file}: {error}"
600
+ ) from error
601
+ parts.extend(
602
+ (
603
+ f"context={context_file}",
604
+ str(context_stat.st_mtime_ns),
605
+ # A part must be a str; hex keeps the exact bytes without
606
+ # asking the digest to carry binary data.
607
+ context_content.hex(),
608
+ )
609
+ )
610
+
611
+ # Add key environment variables to cache key. NUL separates name from
612
+ # value so no pair can alias another: with a bare `=`, a value that
613
+ # happens to embed the next `NAME=` prefix (`PYTHONPATH=/xPYTHONOPTIMIZE=1`)
614
+ # hashes to the same bytes as that variable set separately.
615
+ for env_var in _KEY_ENVIRONMENT_VARIABLES:
616
+ if env_var in os.environ:
617
+ parts.append(f"{env_var}\0{os.environ[env_var]}\0")
618
+
619
+ # Add pytest version to cache key
620
+ if HAS_PYTEST:
621
+ pytest_version = pytest.__version__
622
+ parts.append(f"pytest_version={pytest_version}")
623
+
624
+ # The interpreter that will actually run pytest, fingerprinted in a
625
+ # child process: version/prefix *and* the pytest/plugin versions it can
626
+ # import. Raises CacheKeyUnavailableError rather than keying on a guess.
627
+ executable = python_executable or sys.executable
628
+ parts.append(f"interpreter={self._fingerprint(executable)}")
629
+
630
+ return CacheInputs(self.NAMESPACE, tuple(parts))
631
+
632
+ def _fingerprint(self, python_executable: str) -> str:
633
+ """Memoised :func:`_interpreter_fingerprint` for this generator."""
634
+ cached = self._fingerprints.get(python_executable)
635
+ if cached is not None:
636
+ return cached
637
+ failure = self._fingerprint_failures.get(python_executable)
638
+ if failure is not None:
639
+ raise CacheKeyUnavailableError(failure)
640
+ try:
641
+ digest = _interpreter_fingerprint(python_executable)
642
+ except CacheKeyUnavailableError as error:
643
+ self._fingerprint_failures[python_executable] = str(error)
644
+ raise
645
+ self._fingerprints[python_executable] = digest
646
+ return digest
647
+
648
+
649
+ # ---------------------------------------------------------------------------
650
+ # CacheManager (facade)
651
+ # ---------------------------------------------------------------------------
652
+
653
+
654
+ class CacheManager:
655
+ """Cache manager for test results (the test-result cache adapter).
656
+
657
+ Narrow public cache interface: hit/record/purge/report operations composed
658
+ over the ledger/CacheKeyGenerator/DependencyAnalyzer/CacheMaintenance
659
+ behind a thread-safe lock, with read-only per-run hit tracking. The ledger
660
+ owns the mechanism -- key spelling, atomic writes, freshness and the write
661
+ error policy; this class keeps only *this cache's* policy: the inputs, the
662
+ user-level location and the 24-hour TTL (ADR-0011 keeps those per adapter).
663
+ """
664
+
665
+ CACHE_EXPIRY_SECONDS = 86400 # 24 hours
666
+
667
+ # Annotated at class level, not only in ``__init__``: mypy does not check
668
+ # the body of an untyped function, so an attribute assigned only there
669
+ # would read as ``Any`` at every typed use site.
670
+ _dependency_analyzer: DependencyAnalyzer
671
+ _key_generator: CacheKeyGenerator
672
+ _ledger: CacheLedger
673
+ _maintenance: CacheMaintenance
674
+
675
+ def __init__(self):
676
+ """Initialize CacheManager."""
677
+ # Cache statistics
678
+ self.cache_stats = {
679
+ "hits": 0,
680
+ "misses": 0,
681
+ "expired": 0,
682
+ "invalid": 0,
683
+ "total": 0,
684
+ }
685
+ # Runtime-set cache hit tracking
686
+ self._cache_hits: set[Path] = set()
687
+ # Shared caches passed to components for efficiency
688
+ self._dependency_cache: dict[str, tuple[list[Path], float]] = {}
689
+ self._module_file_cache: dict[tuple[str, str], Path | None] = {}
690
+
691
+ # Initialize specialized components
692
+ self._dependency_analyzer = DependencyAnalyzer(self._module_file_cache)
693
+ self._key_generator = CacheKeyGenerator(self._dependency_cache)
694
+ # All entry I/O goes through the ledger, rooted at the user-level
695
+ # test-cache directory and judged against this adapter's 24 hours. The
696
+ # per-run statistics below still own the "expired"/"invalid" lines, so
697
+ # they read raw entries and ask the ledger to judge them.
698
+ self._ledger = CacheLedger(default_test_cache_dir(), self.CACHE_EXPIRY_SECONDS)
699
+ self._maintenance = CacheMaintenance(self._ledger.store)
700
+
701
+ # Thread safety
702
+ self._ensure_cache_lock()
703
+
704
+ def _ensure_cache_lock(self):
705
+ """Ensure cache lock exists for thread-safe operations with enhanced initialization."""
706
+ if not hasattr(self, "_cache_lock"):
707
+ with threading.Lock():
708
+ if not hasattr(self, "_cache_lock"):
709
+ self._cache_lock = threading.RLock()
710
+
711
+ @property
712
+ def cache_dir(self) -> Path:
713
+ """Directory where cache files are stored."""
714
+ return self._ledger.store_dir
715
+
716
+ @property
717
+ def cache_hit_files(self) -> frozenset[Path]:
718
+ """Read-only view of files that hit the cache during the current run."""
719
+ return frozenset(self._cache_hits)
720
+
721
+ def start_run(self) -> None:
722
+ """Reset per-run hit tracking and counters before a fresh test run."""
723
+ self._cache_hits = set()
724
+ self.cache_stats = {
725
+ "hits": 0,
726
+ "misses": 0,
727
+ "expired": 0,
728
+ "invalid": 0,
729
+ "total": 0,
730
+ }
731
+
732
+ def get_cache_key(
733
+ self, test_file: Path, python_executable: str | None = None
734
+ ) -> str:
735
+ """Generate cache key based on file content, dependencies and environment.
736
+
737
+ Raises:
738
+ CacheKeyUnavailableError: the key cannot be derived truthfully, so the
739
+ file must not be cached (see :class:`CacheKeyUnavailableError`).
740
+ """
741
+ return self._key_generator.generate(test_file, python_executable)
742
+
743
+ def invalidate_file(
744
+ self,
745
+ test_file: Path,
746
+ step_by_step: bool = False,
747
+ concise: bool = False,
748
+ python_executable: str | None = None,
749
+ ) -> None:
750
+ """Drop *test_file*'s entry.
751
+
752
+ A key that cannot be derived names no entry to delete, so an unavailable
753
+ key is a no-op here rather than an error: the caller's intent (leave no
754
+ passing entry behind) is already satisfied.
755
+ """
756
+ try:
757
+ cache_key = self.get_cache_key(test_file, python_executable)
758
+ except CacheKeyUnavailableError:
759
+ return
760
+ self.delete(cache_key, step_by_step, concise)
761
+
762
+ def get_test_dependencies(self, test_file: Path) -> list[Path]:
763
+ """Get test file dependencies by analyzing imports, including indirect dependencies."""
764
+ return self._dependency_analyzer.analyze(test_file)
765
+
766
+ def _validate_cache_timestamp(
767
+ self, data: dict[str, Any], step_by_step: bool = False, concise: bool = False
768
+ ) -> bool:
769
+ """Validate cache timestamp.
770
+
771
+ The freshness decision itself is :func:`is_fresh` (the stack's single
772
+ freshness judgement); the checks around it only distinguish the log
773
+ lines and keep the cache statistics.
774
+ """
775
+ try:
776
+ timestamp = data.get("timestamp")
777
+ if not timestamp:
778
+ if not concise and not step_by_step:
779
+ print("[Cache] Missing timestamp in cache")
780
+ return False
781
+
782
+ current_time = time.time()
783
+ # Computed before the freshness check so a non-numeric timestamp
784
+ # still takes the error path it always has (the ``except`` below).
785
+ time_diff = current_time - timestamp
786
+ if not self._ledger.fresh(data, now=current_time):
787
+ if not concise and not step_by_step:
788
+ print(
789
+ f"[Cache] Cache expired: current={current_time}, timestamp={timestamp}, diff={time_diff}"
790
+ )
791
+ self.cache_stats["expired"] += 1
792
+ self.cache_stats["total"] += 1
793
+ return False
794
+ else:
795
+ if not concise and not step_by_step:
796
+ print(
797
+ f"[Cache] Cache valid: current={current_time}, timestamp={timestamp}, diff={time_diff}"
798
+ )
799
+
800
+ return True
801
+ except Exception as e:
802
+ if not concise and not step_by_step:
803
+ print(f"[Cache] Error validating timestamp: {e}")
804
+ return False
805
+
806
+ def _validate_cache_result(
807
+ self, data: dict[str, Any], step_by_step: bool = False
808
+ ) -> tuple[bool, bool | None]:
809
+ """Validate cache result format."""
810
+ result = data.get("result", False)
811
+ if not isinstance(result, bool):
812
+ if not step_by_step:
813
+ print("[Cache] Invalid result format in cache file")
814
+ self.cache_stats["invalid"] += 1
815
+ self.cache_stats["total"] += 1
816
+ return False, None
817
+ return True, result
818
+
819
+ def _check_cache_consistency(
820
+ self,
821
+ test_file: Path | None,
822
+ cache_key: str,
823
+ step_by_step: bool = False,
824
+ python_executable: str | None = None,
825
+ ) -> bool:
826
+ """Check cache consistency."""
827
+ if test_file:
828
+ current_cache_key = self.get_cache_key(test_file, python_executable)
829
+ if current_cache_key != cache_key:
830
+ if not step_by_step:
831
+ print(
832
+ f"[Cache] Cache inconsistent for {test_file.name}, key mismatch"
833
+ )
834
+ self.cache_stats["invalid"] += 1
835
+ self.cache_stats["total"] += 1
836
+ return False
837
+ return True
838
+
839
+ def load(
840
+ self,
841
+ cache_key: str,
842
+ test_file: Path | None = None,
843
+ step_by_step: bool = False,
844
+ concise: bool = False,
845
+ python_executable: str | None = None,
846
+ ) -> bool | None:
847
+ """Load cached test result with enhanced error handling and consistency check.
848
+
849
+ Returns ``None`` -- a miss -- whenever the entry cannot be used: an
850
+ illegal key (see ``KeyedStore``, which degrades instead of raising), a
851
+ missing entry, one whose file cannot be read, one that exists but holds
852
+ corrupt JSON or something other than a JSON object, and one that fails
853
+ the timestamp/result/consistency checks. Every miss is counted in
854
+ ``cache_stats``.
855
+
856
+ Counting a corrupt or non-object entry as a miss narrows the behaviour
857
+ from before this class moved onto ``KeyedStore`` (#132), which returned
858
+ ``None`` silently without touching the counters: the entry is unusable,
859
+ so it *is* a miss, and counting it makes the stats more honest. The
860
+ change is intentional and pinned by ``test_cache_manager``. The only
861
+ reachable route to such an entry is external tampering -- every write
862
+ here is atomic and goes through :meth:`save`.
863
+ """
864
+ try:
865
+ self._ensure_cache_lock()
866
+
867
+ with self._cache_lock:
868
+ data = self._ledger.read(cache_key)
869
+ if data is None:
870
+ if not concise and not step_by_step:
871
+ print(
872
+ f"[Cache] Cache file not found: "
873
+ f"{_log_safe_key(cache_key)}.json"
874
+ )
875
+ self.cache_stats["misses"] += 1
876
+ self.cache_stats["total"] += 1
877
+ return None
878
+
879
+ if not self._validate_cache_timestamp(data, step_by_step, concise):
880
+ return None
881
+
882
+ valid, result = self._validate_cache_result(data, step_by_step)
883
+ if not valid:
884
+ return None
885
+
886
+ if not self._check_cache_consistency(
887
+ test_file, cache_key, step_by_step, python_executable
888
+ ):
889
+ return None
890
+
891
+ if not step_by_step:
892
+ print(
893
+ f"[Cache] Cache loaded successfully for key: {cache_key[:8]}..."
894
+ )
895
+ self.cache_stats["hits"] += 1
896
+ self.cache_stats["total"] += 1
897
+ return result
898
+ except OSError as e:
899
+ if not step_by_step:
900
+ print(f"[Cache] File access error: {e}")
901
+ self.cache_stats["misses"] += 1
902
+ self.cache_stats["total"] += 1
903
+ return None
904
+ except Exception as e:
905
+ if not step_by_step:
906
+ print(f"[Cache] Unexpected error loading cache: {e}")
907
+ self.cache_stats["misses"] += 1
908
+ self.cache_stats["total"] += 1
909
+ return None
910
+
911
+ def save(
912
+ self,
913
+ cache_key: str,
914
+ result: bool,
915
+ step_by_step: bool = False,
916
+ concise: bool = False,
917
+ ) -> None:
918
+ """Save test result to cache with enhanced error handling.
919
+
920
+ Reports failure with the ``[Cache] Failed to save`` line instead of a
921
+ false success: the ledger returns ``False`` for an illegal key (see
922
+ ``KeyedStore``) or a failed write, and that result is passed on rather
923
+ than ignored. The entry's ``timestamp`` is the ledger's.
924
+ """
925
+ data = {
926
+ "result": result,
927
+ "version": "1.0",
928
+ }
929
+
930
+ try:
931
+ self._ensure_cache_lock()
932
+
933
+ with self._cache_lock:
934
+ if not self._ledger.put(cache_key, data):
935
+ if not concise and not step_by_step:
936
+ print(
937
+ f"[Cache] Failed to save cache for key: "
938
+ f"{_log_safe_key(cache_key)}"
939
+ )
940
+ return
941
+
942
+ if not concise and not step_by_step:
943
+ print(
944
+ f"[Cache] Cache saved successfully for key: {cache_key[:8]}..."
945
+ )
946
+ except OSError as e:
947
+ if not concise and not step_by_step:
948
+ print(f"[Cache] File access error saving cache: {e}")
949
+ except Exception as e:
950
+ if not concise and not step_by_step:
951
+ print(f"[Cache] Unexpected error saving cache: {e}")
952
+
953
+ def delete(
954
+ self, cache_key: str, step_by_step: bool = False, concise: bool = False
955
+ ) -> None:
956
+ """Delete cache for the specified key with enhanced error handling.
957
+
958
+ Reports failure with the ``[Cache] Failed to delete`` line instead of
959
+ raising: an illegal key (see ``KeyedStore`` -- a non-``str``, the empty
960
+ string, a path separator, a directory reference, a control character, or
961
+ a character unencodable as a file name) takes the same path as a missing
962
+ entry, because an illegal key names no entry to delete. The key is
963
+ rendered log-safe, so even an unencodable one cannot crash the line.
964
+ """
965
+ success = self._ledger.invalidate(cache_key)
966
+ if not success and not concise and not step_by_step:
967
+ print(f"[Cache] Failed to delete cache for key: {_log_safe_key(cache_key)}")
968
+
969
+ def purge_expired(self) -> None:
970
+ """Remove cache entries older than 24 hours, judged by payload timestamp."""
971
+ deleted = self._maintenance.purge_expired(self.CACHE_EXPIRY_SECONDS)
972
+ if deleted > 0:
973
+ print(f"[Cache] Cleaned up {deleted} expired cache files")
974
+
975
+ def purge_all(self) -> None:
976
+ """Clean all test cache."""
977
+ deleted = self._maintenance.clear_all()
978
+ print(f"\nCleanup completed. Removed {deleted} cache files.")
979
+
980
+ def info(self) -> None:
981
+ """Show cache information."""
982
+ print(self._maintenance.print_stats(output=False))
983
+
984
+ print("\n=== Cache Usage Tips ===")
985
+ print("- Use --no-cache to skip cache for a single run")
986
+ print("- Use --clean-cache to remove all cache files")
987
+ print("- Cache automatically expires after 24 hours")
988
+
989
+ def report_summary(self, test_files: list[Path], concise: bool = False) -> None:
990
+ """Print per-run cache-usage summary from hit tracking."""
991
+ total_files = len(test_files)
992
+ cache_hit_count = len(self._cache_hits)
993
+
994
+ if cache_hit_count > 0 and not concise:
995
+ cache_hit_percent = (cache_hit_count / total_files) * 100
996
+ print(
997
+ f"Cache usage: {cache_hit_count}/{total_files} files ({cache_hit_percent:.1f}%) used cached results"
998
+ )
999
+ elif not concise:
1000
+ print("Cache usage: No cached results used")
1001
+
1002
+ def report_detail(self) -> None:
1003
+ """Print detailed cache statistics and reset the run counters."""
1004
+ print("\n=== Detailed Cache Statistics ===")
1005
+ print(f"Total cache operations: {self.cache_stats['total']}")
1006
+ print(f"Cache hits: {self.cache_stats['hits']}")
1007
+ print(f"Cache misses: {self.cache_stats['misses']}")
1008
+ print(f"Expired caches: {self.cache_stats['expired']}")
1009
+ print(f"Invalid caches: {self.cache_stats['invalid']}")
1010
+
1011
+ if self.cache_stats["total"] > 0:
1012
+ hit_rate = (self.cache_stats["hits"] / self.cache_stats["total"]) * 100
1013
+ miss_rate = (self.cache_stats["misses"] / self.cache_stats["total"]) * 100
1014
+ invalid_rate = (
1015
+ (self.cache_stats["expired"] + self.cache_stats["invalid"])
1016
+ / self.cache_stats["total"]
1017
+ ) * 100
1018
+ print(f"\nCache hit rate: {hit_rate:.1f}%")
1019
+ print(f"Cache miss rate: {miss_rate:.1f}%")
1020
+ print(f"Cache invalidation rate: {invalid_rate:.1f}%")
1021
+
1022
+ self.cache_stats = {
1023
+ "hits": 0,
1024
+ "misses": 0,
1025
+ "expired": 0,
1026
+ "invalid": 0,
1027
+ "total": 0,
1028
+ }
1029
+
1030
+ def all_cached(
1031
+ self,
1032
+ test_files: list[Path],
1033
+ concise: bool = False,
1034
+ python_executable: str | None = None,
1035
+ ) -> bool:
1036
+ """Check if all test files have valid cached results.
1037
+
1038
+ An unavailable key counts as "not cached": the run must happen rather
1039
+ than be skipped on the strength of a key that could not be derived.
1040
+ """
1041
+ if not test_files:
1042
+ return False
1043
+
1044
+ if not concise:
1045
+ print("[Cache] Checking cache for all test files...")
1046
+ for test_file in test_files:
1047
+ try:
1048
+ cache_key = self.get_cache_key(test_file, python_executable)
1049
+ if not concise:
1050
+ print(
1051
+ f"[Cache] Checking cache for {test_file.name} (key: {cache_key[:8]}...)"
1052
+ )
1053
+ cache_result = self.load(
1054
+ cache_key,
1055
+ test_file,
1056
+ concise=concise,
1057
+ python_executable=python_executable,
1058
+ )
1059
+
1060
+ if cache_result is None:
1061
+ if not concise:
1062
+ print(f"[Cache] Cache miss for {test_file.name}")
1063
+ return False
1064
+ elif not cache_result:
1065
+ if not concise:
1066
+ print(
1067
+ f"[Cache] Cached failure for {test_file.name}, need to re-run"
1068
+ )
1069
+ return False
1070
+ else:
1071
+ if not concise:
1072
+ print(f"[Cache] Cache hit for {test_file.name}")
1073
+ except Exception as e:
1074
+ if not concise:
1075
+ print(f"[Cache] Error checking cache for {test_file.name}: {e}")
1076
+ return False
1077
+ return True
1078
+
1079
+ def record_passed_batch(
1080
+ self,
1081
+ test_files: list[Path],
1082
+ concise: bool = False,
1083
+ python_executable: str | None = None,
1084
+ ) -> None:
1085
+ """Record a successful run for exactly the given test files.
1086
+
1087
+ The caller decides *which* files these are -- the set the run actually
1088
+ executed (#191). Only the success path lives here. Attributing an
1089
+ individual failing file (re-running pytest per file) is the runner's
1090
+ job, which then calls :meth:`record_passed_single` / :meth:`delete` per
1091
+ outcome.
1092
+ """
1093
+ print("[Cache] All tests passed, saving cache for all files...")
1094
+ for test_file in test_files:
1095
+ try:
1096
+ cache_key = self.get_cache_key(test_file, python_executable)
1097
+ except CacheKeyUnavailableError as error:
1098
+ print(f"[Cache] Not caching {test_file.name}: {error}")
1099
+ continue
1100
+ try:
1101
+ print(
1102
+ f"[Cache] Saving cache for {test_file.name} (key: {cache_key[:8]}...)"
1103
+ )
1104
+ self.save(cache_key, True, concise=concise)
1105
+ except Exception as e:
1106
+ print(f"[Cache] Error saving cache for {test_file.name}: {e}")
1107
+
1108
+ def cached_result(
1109
+ self,
1110
+ test_file: Path,
1111
+ step_by_step: bool = False,
1112
+ concise: bool = False,
1113
+ python_executable: str | None = None,
1114
+ ) -> tuple[bool, str, bool, None]:
1115
+ """Check cache for a single test file.
1116
+
1117
+ An unavailable key is reported as a miss, never as a hit and never as
1118
+ an error the caller has to know about.
1119
+ """
1120
+ try:
1121
+ cache_key = self.get_cache_key(test_file, python_executable)
1122
+ cache_result = self.load(
1123
+ cache_key,
1124
+ test_file,
1125
+ step_by_step,
1126
+ concise,
1127
+ python_executable,
1128
+ )
1129
+
1130
+ if cache_result is not None:
1131
+ if not hasattr(self, "_cache_hits"):
1132
+ self._cache_hits = set()
1133
+ self._cache_hits.add(test_file)
1134
+ return True, "Cache hit", cache_result, None
1135
+ else:
1136
+ return False, "Cache miss", False, None
1137
+ except Exception as e:
1138
+ return False, f"Cache check error: {e}", False, None
1139
+
1140
+ def record_passed_single(
1141
+ self,
1142
+ test_file: Path,
1143
+ step_by_step: bool = False,
1144
+ concise: bool = False,
1145
+ python_executable: str | None = None,
1146
+ ) -> None:
1147
+ """Record a passing single-file result in the cache.
1148
+
1149
+ Silent by design: an unavailable key simply means the file is not
1150
+ cached, which is the same outcome as a save failure.
1151
+ """
1152
+ try:
1153
+ cache_key = self.get_cache_key(test_file, python_executable)
1154
+ self.save(cache_key, True, step_by_step, concise)
1155
+ except Exception:
1156
+ pass