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,355 @@
1
+ """The single cache seam: cache inputs -> key, plus freshness and error policy.
2
+
3
+ ADR-0011 keeps three caches -- test results, coverage, format results -- and
4
+ gives them one mechanism. Each cache is an *adapter*: it answers what enters its
5
+ key (a :class:`CacheInputs`), how long an entry stays fresh (``ttl_seconds``)
6
+ and where the entries live (``store_dir``). This module answers everything else,
7
+ once:
8
+
9
+ - **Key spelling** -- ``{namespace}_{sha256(parts)}``, the *full* digest. The
10
+ coverage cache used to truncate the digest to 16 characters; that truncation
11
+ is gone, so a key is as unique as the hash under it.
12
+ - **Storage** -- atomic writes delegated to
13
+ :class:`~wl_cli.utils.cache_store.KeyedStore`.
14
+ - **Freshness** -- :func:`~wl_cli.utils.cache_store.is_fresh` applied to the
15
+ entry's own ``timestamp`` field with the adapter's TTL. Nothing expires by
16
+ file mtime, and the TTL values are the adapters' policy, not a debt to unify:
17
+ test results are fresh for 24 hours, coverage and format results for 7 days.
18
+ - **Error policy** -- a failed *read* is a cache miss (silent: not caching is
19
+ always safe), a failed *write* never interrupts the run but is logged with a
20
+ ``log_*_pair``, so the user can see why the next run is slow.
21
+
22
+ The parts of a :class:`CacheInputs` are ordered and normalized by the adapter.
23
+ Each part is length-prefixed before hashing, so ``("ab", "c")`` and
24
+ ``("a", "bc")`` cannot collide, and no part can smuggle in a delimiter that
25
+ makes two different input sets hash alike.
26
+ """
27
+
28
+ import hashlib
29
+ import time
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from .cache_store import KeyedStore, is_fresh
35
+ from .logging import log_warning_pair
36
+
37
+ #: Characters that would let a namespace escape the store directory (keys become
38
+ #: file names, see :mod:`wl_cli.utils.cache_store`).
39
+ _NAMESPACE_PATH_SEPARATORS = ("/", "\\")
40
+
41
+ #: C0 control characters (including NUL) and DEL are rejected on every platform,
42
+ #: matching the entry-key rule in :mod:`wl_cli.utils.cache_store`.
43
+ _FIRST_PRINTABLE_CODEPOINT = 0x20
44
+ _DELETE_CODEPOINT = 0x7F
45
+
46
+ #: How much of a key a write-failure log line shows. A preview only -- the key
47
+ #: itself is never truncated.
48
+ _KEY_PREVIEW_LENGTH = 16
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class CacheInputs:
53
+ """What an adapter says decides its cache key.
54
+
55
+ A namespace (which cache this is) plus the ordered, already-normalized
56
+ fragments that must move the key when they change. The key is a *function*
57
+ of these inputs -- :func:`format_key` -- not a synonym for them.
58
+
59
+ Attributes:
60
+ namespace: Cache identity, e.g. ``"test"``, ``"coverage"``,
61
+ ``"format"``. Becomes the key's prefix, so it must be usable inside
62
+ a file name: non-empty, no path separator, no control character.
63
+ parts: The fragments, in the adapter's order. Each is hashed verbatim;
64
+ the adapter is responsible for normalizing it (path separators,
65
+ number formatting) so two processes agree.
66
+ """
67
+
68
+ namespace: str
69
+ parts: tuple[str, ...]
70
+
71
+ def __post_init__(self) -> None:
72
+ """Reject an unusable namespace or part at construction, once.
73
+
74
+ Validating here means a key can never be spelled from inputs that would
75
+ produce an illegal file name, so an adapter's mistake surfaces at the
76
+ call rather than as a silent cache miss later.
77
+ """
78
+ if not isinstance(self.namespace, str) or not self.namespace:
79
+ raise ValueError("Cache namespace must be a non-empty str")
80
+ if any(separator in self.namespace for separator in _NAMESPACE_PATH_SEPARATORS):
81
+ raise ValueError(
82
+ f"Cache namespace must not contain a path separator: {self.namespace!r}"
83
+ )
84
+ if any(
85
+ ord(character) < _FIRST_PRINTABLE_CODEPOINT
86
+ or ord(character) == _DELETE_CODEPOINT
87
+ for character in self.namespace
88
+ ):
89
+ raise ValueError(
90
+ f"Cache namespace must not contain control characters: "
91
+ f"{self.namespace!r}"
92
+ )
93
+
94
+ # The parts are typed ``tuple[str, ...]`` and every adapter spells them
95
+ # as strings; a wrong type is a programming error at the call site, not
96
+ # a runtime condition this value object should paper over.
97
+
98
+
99
+ def _digest(parts: tuple[str, ...]) -> str:
100
+ """SHA-256 over the length-prefixed parts, as hexadecimal.
101
+
102
+ Length-prefixing is what makes the parts unambiguous: without it the
103
+ concatenation of ``("ab", "c")`` and ``("a", "bc")`` is the same bytes, and
104
+ two different input sets would share a key.
105
+ """
106
+ hasher = hashlib.sha256()
107
+ for part in parts:
108
+ encoded = part.encode("utf-8")
109
+ hasher.update(f"{len(encoded)}:".encode("ascii"))
110
+ hasher.update(encoded)
111
+ return hasher.hexdigest()
112
+
113
+
114
+ def format_key(inputs: CacheInputs) -> str:
115
+ """Spell the key for *inputs*: ``{namespace}_{sha256(parts)}``.
116
+
117
+ A pure function of its input -- no clock, no environment, no file system --
118
+ so two processes that agree on the inputs agree on the key.
119
+
120
+ Args:
121
+ inputs: The adapter's cache inputs.
122
+
123
+ Returns:
124
+ The entry key: the full digest, never a truncated one.
125
+ """
126
+ return f"{inputs.namespace}_{_digest(inputs.parts)}"
127
+
128
+
129
+ def _preview(key: object) -> str:
130
+ """Render a key for a log line: safe to print, shortened for reading."""
131
+ text = key if isinstance(key, str) else repr(key)
132
+ safe = text.encode("utf-8", "backslashreplace").decode("utf-8")
133
+ if len(safe) <= _KEY_PREVIEW_LENGTH:
134
+ return safe
135
+ return f"{safe[:_KEY_PREVIEW_LENGTH]}..."
136
+
137
+
138
+ class CacheLedger:
139
+ """The cache mechanism every adapter shares.
140
+
141
+ Args:
142
+ store_dir: Directory holding the entries. The adapter decides it --
143
+ user-level for test and coverage results, project-level for format.
144
+ ttl_seconds: How long an entry stays fresh, for this adapter only.
145
+ """
146
+
147
+ def __init__(self, store_dir: Path, ttl_seconds: float) -> None:
148
+ self._store = KeyedStore(store_dir)
149
+ self._ttl_seconds = ttl_seconds
150
+
151
+ @property
152
+ def store_dir(self) -> Path:
153
+ """Directory holding the entries."""
154
+ return self._store.store_dir
155
+
156
+ @property
157
+ def store(self) -> KeyedStore:
158
+ """The underlying store, for maintenance and statistics.
159
+
160
+ Enumerating and measuring entries is a different concern from deciding
161
+ a key or judging freshness (``CacheMaintenance`` owns the test cache's
162
+ CLI statistics and purge; :meth:`purge_stale` is the per-adapter
163
+ housekeeping step). Key spelling and the error policy stay here --
164
+ callers must not write entries around the ledger.
165
+ """
166
+ return self._store
167
+
168
+ @property
169
+ def ttl_seconds(self) -> float:
170
+ """How long an entry stays fresh, in seconds."""
171
+ return self._ttl_seconds
172
+
173
+ def key(self, inputs: CacheInputs) -> str:
174
+ """Return the entry key for *inputs* (see :func:`format_key`)."""
175
+ return format_key(inputs)
176
+
177
+ def read(self, key: str) -> dict[str, Any] | None:
178
+ """Read the entry under *key* without judging freshness.
179
+
180
+ A read failure of any kind is a miss: the store already degrades an
181
+ unreadable, corrupt or non-object entry to ``None``, and an ``OSError``
182
+ escaping it (a permission change between the existence check and the
183
+ open) means the same thing -- there is no cache here.
184
+
185
+ Args:
186
+ key: Entry key.
187
+
188
+ Returns:
189
+ The stored dict, or ``None`` on any miss or read failure.
190
+ """
191
+ try:
192
+ return self._store.get(key)
193
+ except OSError:
194
+ return None
195
+
196
+ def get(self, key: str) -> dict[str, Any] | None:
197
+ """Read the entry under *key* if it is still fresh.
198
+
199
+ Args:
200
+ key: Entry key.
201
+
202
+ Returns:
203
+ The stored dict when it exists and its ``timestamp`` is within this
204
+ ledger's TTL; ``None`` otherwise.
205
+ """
206
+ entry = self.read(key)
207
+ if entry is None or not self.fresh(entry):
208
+ return None
209
+ return entry
210
+
211
+ def fresh(self, entry: Any, *, now: float | None = None) -> bool:
212
+ """Judge *entry* against this ledger's TTL.
213
+
214
+ Args:
215
+ entry: A stored entry, as returned by :meth:`read`.
216
+ now: Current epoch time; ``time.time()`` when omitted. Injectable
217
+ so a TTL boundary can be tested exactly.
218
+
219
+ Returns:
220
+ True when the entry's payload timestamp is younger than this
221
+ ledger's TTL.
222
+ """
223
+ return is_fresh(entry, self._ttl_seconds, now=now)
224
+
225
+ def put(self, key: str, payload: dict[str, Any]) -> bool:
226
+ """Write *payload* under *key*, stamped with the current time.
227
+
228
+ The entry's ``timestamp`` is the ledger's, always: freshness is judged
229
+ from it, so an adapter cannot accidentally write an entry that is born
230
+ stale or immortal.
231
+
232
+ A failed write never interrupts the caller. It is reported through the
233
+ return value and a bilingual warning, so the failure is visible instead
234
+ of silently costing the next run a full re-run.
235
+
236
+ Args:
237
+ key: Entry key.
238
+ payload: JSON-serializable fields to store beside the timestamp.
239
+
240
+ Returns:
241
+ True if the entry was written; False if the key is illegal (see
242
+ :class:`~wl_cli.utils.cache_store.KeyedStore`) or the write failed.
243
+ """
244
+ entry = {**payload, "timestamp": time.time()}
245
+ try:
246
+ written = self._store.put(key, entry)
247
+ except OSError:
248
+ # ``KeyedStore.put`` already degrades the failures it can see; this
249
+ # covers the ones that escape it (the store directory's parent is a
250
+ # file, so ``mkdir`` raises). A cache write must never break a run.
251
+ written = False
252
+
253
+ if not written:
254
+ preview = _preview(key)
255
+ log_warning_pair(
256
+ f"Could not write the cache entry {preview}; continuing without it",
257
+ f"无法写入缓存条目 {preview};已跳过该条目,继续运行",
258
+ )
259
+ return written
260
+
261
+ def touch(self, key: str, *, now: float | None = None) -> bool:
262
+ """Renew a fresh entry's timestamp, reporting whether it was a hit.
263
+
264
+ A hit used to be a silent read, so an entry's age was counted from the
265
+ run that recorded it: a verdict served on every run still expired seven
266
+ days after it was first written. The old single-document format cache
267
+ re-stamped every entry it served when it saved, and this restores that
268
+ sliding window (#213 review LOW-3) -- a fresh entry being used again
269
+ stays fresh for another TTL.
270
+
271
+ A stale entry is **not** renewed: the caller is going to re-run and
272
+ record a new verdict, and resurrecting an expired one would turn a
273
+ failed re-run into a false hit.
274
+
275
+ The renewal is best effort and silent. A failed renewal leaves the
276
+ entry with its old timestamp, which can only make it expire sooner --
277
+ the same outcome as not touching it -- so it is not a cache-write
278
+ failure worth a warning on every hit (compare :meth:`put`, where the
279
+ entry would otherwise never exist).
280
+
281
+ Args:
282
+ key: Entry key.
283
+ now: Current epoch time; ``time.time()`` when omitted.
284
+
285
+ Returns:
286
+ True when the entry existed and was fresh; False on a miss or a
287
+ stale entry. A failed renewal still reports True -- the hit stands.
288
+ """
289
+ entry = self.read(key)
290
+ if entry is None or not self.fresh(entry, now=now):
291
+ return False
292
+
293
+ payload = {name: value for name, value in entry.items() if name != "timestamp"}
294
+ payload["timestamp"] = time.time() if now is None else now
295
+ try:
296
+ self._store.put(key, payload)
297
+ except OSError:
298
+ # ``KeyedStore.put`` degrades what it can see; this covers what
299
+ # escapes it, exactly as ``put`` does. See the docstring: a lost
300
+ # renewal is not a lost entry.
301
+ pass
302
+ return True
303
+
304
+ def purge_stale(self, *, now: float | None = None) -> int:
305
+ """Delete every entry that can no longer be a hit.
306
+
307
+ An entry is reclaimed when it is older than this ledger's TTL, or when
308
+ it cannot be read as a JSON object at all: neither can ever be served,
309
+ so both only cost disk. Per-entry files never expire on their own --
310
+ the format adapter's old single-document ``save`` was what implicitly
311
+ pruned them -- so this is the reclaim step that keeps the store bounded
312
+ (#213 review MEDIUM-2).
313
+
314
+ Deletion goes through ``invalidate_file`` rather than ``invalidate``:
315
+ ``iter_entry_files`` also yields files whose names are not legal keys,
316
+ and those deserve reclaiming too.
317
+
318
+ Leftover ``*.tmp`` files are deliberately left alone: a temp name is
319
+ unique per write (#213 review NIT-6), so a leftover may belong to a
320
+ concurrent writer that is about to rename it. ``remove_temp_files`` is
321
+ the explicit sweep for those.
322
+
323
+ Args:
324
+ now: Current epoch time; ``time.time()`` when omitted.
325
+
326
+ Returns:
327
+ Number of entry files deleted.
328
+ """
329
+ deleted = 0
330
+ for key, entry in self._store.iter_entry_files():
331
+ if entry is not None and self.fresh(entry, now=now):
332
+ continue
333
+ if self._store.invalidate_file(key):
334
+ deleted += 1
335
+ return deleted
336
+
337
+ def invalidate(self, key: str) -> bool:
338
+ """Drop the entry under *key*.
339
+
340
+ Returns:
341
+ True if an entry was deleted; False if there was nothing to delete
342
+ or the deletion failed.
343
+ """
344
+ try:
345
+ return self._store.invalidate(key)
346
+ except OSError:
347
+ return False
348
+
349
+ def clear(self) -> int:
350
+ """Drop every entry in this ledger's store.
351
+
352
+ Returns:
353
+ Number of files deleted.
354
+ """
355
+ return self._store.clear()