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,546 @@
1
+ """Generic keyed store with atomic writes.
2
+
3
+ Extracted from ``CacheStorage`` in
4
+ :mod:`wl_cli.commands.testcommands.cache_manager` -- the storage mechanics
5
+ (temp file + rename, including the Windows retry loop) -- so they can be reused
6
+ and tested on their own, without the test-cache specifics (default cache root,
7
+ dependency analysis, statistics).
8
+
9
+ Lives in ``utils`` (not ``commands/testcommands``) so every cache adapter can
10
+ reach it without importing the test subsystem (#213, ADR-0011). The ledger in
11
+ :mod:`wl_cli.utils.cache_ledger` is the production path to it; the format
12
+ adapter goes through the ledger, never through this module.
13
+
14
+ The interface is deliberately narrow:
15
+
16
+ - :meth:`KeyedStore.put` -- write a JSON dict under a key, atomically
17
+ - :meth:`KeyedStore.get` -- read it back, or ``None`` on miss
18
+ - :meth:`KeyedStore.invalidate` -- drop it
19
+ - :meth:`KeyedStore.invalidate_file` -- drop an entry by its file-name-derived
20
+ key, including one whose name is not a legal key
21
+ - :meth:`KeyedStore.iter_entries` -- enumerate the readable entries
22
+ - :meth:`KeyedStore.iter_entry_files` -- enumerate every entry file, unusable
23
+ ones included
24
+ - :meth:`KeyedStore.clear` -- drop every entry
25
+ - :meth:`KeyedStore.remove_temp_files` -- drop leftovers from interrupted writes
26
+ - :meth:`KeyedStore.total_size_bytes` -- the store's on-disk footprint
27
+
28
+ The store does not judge freshness. Entries carry their own ``timestamp`` field,
29
+ and :func:`is_fresh` -- defined here, next to the store -- is the single
30
+ freshness judgement in the cache stack: ``CacheLedger`` (in
31
+ :mod:`wl_cli.utils.cache_ledger`) owns the clock and passes the TTL its adapter
32
+ chose (24 hours for test results, 7 days for coverage and format). Nothing
33
+ expires by file mtime.
34
+
35
+ Keys become file names, so an illegal key is rejected rather than sanitized --
36
+ sanitizing would alias distinct keys onto one file. A key is illegal when it is
37
+ not a ``str``, is the empty string, contains a path separator (``/``, ``\\``)
38
+ or a C0 control character (including NUL) or DEL, is one of the directory
39
+ references ``.`` and ``..``, or holds a character that cannot be encoded as a
40
+ file name (a lone surrogate, which no file-system encoding accepts). These are
41
+ pure string checks: POSIX rejects a NUL in a path and Windows rejects several
42
+ other control characters, so rejecting C0 and DEL on every platform keeps that
43
+ part of the naming rule identical across platforms. Rejection never raises out
44
+ of the public interface: like every other unusable state, it degrades to the
45
+ documented miss/failure return -- :meth:`KeyedStore.put` and
46
+ :meth:`KeyedStore.invalidate` report ``False``, :meth:`KeyedStore.get` reports
47
+ ``None``. Only the private :meth:`KeyedStore._entry_path` raises, and the
48
+ public methods go through :meth:`KeyedStore._safe_entry_path` to turn that into
49
+ the documented return.
50
+ """
51
+
52
+ import json
53
+ import os
54
+ import time
55
+ import uuid
56
+ from collections.abc import Iterator
57
+ from pathlib import Path
58
+ from typing import Any
59
+
60
+ from .platform_adapter import PlatformAdapter
61
+
62
+ _ENTRY_SUFFIX = ".json"
63
+ _TEMP_SUFFIX = ".tmp"
64
+
65
+ # Keys become file names, so anything that could escape the store directory
66
+ # (or land outside the entry-file naming scheme) is rejected rather than
67
+ # silently sanitized -- silent sanitizing would alias distinct keys together.
68
+ _KEY_PATH_SEPARATORS = ("/", "\\")
69
+ _KEY_DIRECTORY_REFERENCES = (".", "..")
70
+
71
+ # C0 control characters (including NUL) and DEL are rejected on every platform:
72
+ # a NUL in a path is rejected by POSIX and Windows alike, and Windows rejects
73
+ # several of the others, so applying the same rule everywhere keeps that part of
74
+ # the naming rule platform-independent. (Windows-only restrictions such as
75
+ # reserved device names are deliberately out of scope.)
76
+ _FIRST_PRINTABLE_CODEPOINT = 0x20
77
+ _DELETE_CODEPOINT = 0x7F
78
+
79
+ _WINDOWS_RENAME_ATTEMPTS = 5
80
+ _WINDOWS_RENAME_DELAY_SECONDS = 0.1
81
+
82
+
83
+ def is_fresh(entry: Any, max_age_seconds: float, *, now: float | None = None) -> bool:
84
+ """Judge whether an entry's payload timestamp is within ``max_age_seconds``.
85
+
86
+ This is the single freshness judgement in the cache stack. Every adapter
87
+ reaches it through ``CacheLedger``, which passes the age limit the adapter
88
+ owns: 24 hours for the test-result cache, 7 days for coverage and format.
89
+
90
+ Only the entry's own ``timestamp`` field is consulted -- never the file
91
+ mtime. An entry that is not a dict, carries no timestamp, or carries a
92
+ non-numeric one counts as stale, so a purge removes it.
93
+
94
+ A timestamp in the future (clock skew between writers, or a value stored in
95
+ milliseconds rather than seconds) yields a negative age and therefore
96
+ counts as fresh. That is deliberate and pinned by tests: the predicate has
97
+ no upper bound, so such an entry stays cached until it is invalidated or a
98
+ purge is asked to drop it by other means.
99
+
100
+ Args:
101
+ entry: The stored entry, as returned by :meth:`KeyedStore.get`.
102
+ max_age_seconds: Maximum age of a fresh entry, in seconds.
103
+ now: Current epoch time; ``time.time()`` when omitted. Injectable so
104
+ the boundary can be tested exactly.
105
+
106
+ Returns:
107
+ True when ``now - entry["timestamp"] < max_age_seconds``; False
108
+ otherwise, including for a malformed or timestamp-less entry.
109
+ """
110
+ if not isinstance(entry, dict):
111
+ return False
112
+
113
+ timestamp = entry.get("timestamp")
114
+ if not isinstance(timestamp, (int, float)):
115
+ return False
116
+
117
+ current_time = time.time() if now is None else now
118
+ return current_time - timestamp < max_age_seconds
119
+
120
+
121
+ class KeyedStore:
122
+ """A directory of JSON entries addressed by key, written atomically.
123
+
124
+ Responsibilities:
125
+ - Create the store directory if it doesn't exist
126
+ - Read an entry (``None`` on miss)
127
+ - Write an entry atomically, with a retry loop on Windows file locking
128
+ - Delete an entry
129
+ - Delete an entry by its file-name-derived key, even when that name is not
130
+ a legal key (housekeeping over files this store would never have written)
131
+ - Enumerate the readable entries, and report their count and on-disk size
132
+ - Reject an illegal key by degrading to the documented miss/failure return,
133
+ never by raising (see the module docstring)
134
+ """
135
+
136
+ def __init__(self, store_dir: Path) -> None:
137
+ """Initialize the store.
138
+
139
+ Args:
140
+ store_dir: Directory holding the entry files. Created lazily on
141
+ first write, not here.
142
+ """
143
+ self._store_dir = store_dir
144
+
145
+ @property
146
+ def store_dir(self) -> Path:
147
+ """Get the directory holding the entry files."""
148
+ return self._store_dir
149
+
150
+ def ensure_store_dir_exists(self) -> None:
151
+ """Ensure the store directory exists, create it if not."""
152
+ if not self._store_dir.exists():
153
+ self._store_dir.mkdir(parents=True, exist_ok=True)
154
+
155
+ def put(self, key: str, value: dict[str, Any]) -> bool:
156
+ """Write an entry atomically.
157
+
158
+ Writes to a temporary file first and renames it into place, so a reader
159
+ never observes a half-written entry. Includes a retry loop for Windows
160
+ rename failures caused by file locking. The temporary name is unique
161
+ per write (see :meth:`_temp_path`), so two processes writing the same
162
+ key do not stage into -- and steal -- one another's file.
163
+
164
+ Args:
165
+ key: Entry key.
166
+ value: JSON-serializable dict to store.
167
+
168
+ Returns:
169
+ True if the write succeeded; False if the key is illegal (see the
170
+ module docstring) or the write failed, in which case the temporary
171
+ file has been cleaned up and no half-written entry is visible. The
172
+ previous entry file is removed just before the rename, so a write
173
+ that fails at the rename stage can leave the entry absent rather
174
+ than stale.
175
+ """
176
+ entry_file = self._safe_entry_path(key)
177
+ if entry_file is None:
178
+ return False
179
+
180
+ self.ensure_store_dir_exists()
181
+ temp_file = self._temp_path(entry_file)
182
+
183
+ try:
184
+ # Write to a temporary file first for atomicity
185
+ with open(temp_file, "w", encoding="utf-8") as f:
186
+ json.dump(value, f, indent=2, ensure_ascii=False)
187
+ f.write("\n")
188
+ except OSError:
189
+ self._discard(temp_file)
190
+ return False
191
+
192
+ try:
193
+ return self._rename_into_place(temp_file, entry_file)
194
+ except OSError:
195
+ self._discard(temp_file)
196
+ return False
197
+
198
+ def get(self, key: str) -> dict[str, Any] | None:
199
+ """Read an entry.
200
+
201
+ Args:
202
+ key: Entry key.
203
+
204
+ Returns:
205
+ The stored dict, or None if the key is illegal (see the module
206
+ docstring), or the entry is missing, unreadable, or holds something
207
+ other than a JSON object. Freshness is the caller's business --
208
+ see :func:`is_fresh`.
209
+ """
210
+ entry_file = self._safe_entry_path(key)
211
+ if entry_file is None or not entry_file.exists():
212
+ return None
213
+
214
+ try:
215
+ with open(entry_file, encoding="utf-8") as f:
216
+ data = json.load(f)
217
+ except (OSError, ValueError, RecursionError):
218
+ # ValueError covers both malformed JSON and bytes that are not
219
+ # valid UTF-8; RecursionError covers a pathologically deep document.
220
+ # A hand-written or corrupted entry must degrade to a miss, never
221
+ # escape as an exception.
222
+ return None
223
+
224
+ # Only ``put`` writes dicts, but an entry can also be hand-written or
225
+ # left by another tool; anything that is not a JSON object is a miss
226
+ # rather than a value that violates the declared return type.
227
+ return data if isinstance(data, dict) else None
228
+
229
+ def invalidate(self, key: str) -> bool:
230
+ """Delete an entry.
231
+
232
+ Args:
233
+ key: Entry key.
234
+
235
+ Returns:
236
+ True if an entry was deleted, False if the key is illegal (see the
237
+ module docstring), there was nothing to delete, or the deletion
238
+ failed.
239
+ """
240
+ entry_file = self._safe_entry_path(key)
241
+ if entry_file is None or not entry_file.exists():
242
+ return False
243
+
244
+ try:
245
+ entry_file.unlink()
246
+ return True
247
+ except OSError:
248
+ return False
249
+
250
+ def invalidate_file(self, key: object) -> bool:
251
+ """Delete an entry by the key encoded in its file name.
252
+
253
+ :meth:`iter_entry_files` yields every ``*.json`` file's stem, including
254
+ names that are not legal keys -- an entry file literally named
255
+ ``.json`` yields the empty key, and ``a<TAB>b.json`` yields a key
256
+ rejected for its control character. :meth:`invalidate` refuses those
257
+ keys by design (it is the key-addressed interface), so maintenance
258
+ could count such a file in its statistics but never reclaim it
259
+ (#172 wave 9). This is the housekeeping entry point: it accepts any
260
+ name that cannot escape the store directory, so every file
261
+ :meth:`iter_entry_files` reports is deletable.
262
+
263
+ The name is *not* held to the key-naming rule: a file whose name this
264
+ store would never have written still deserves deletion. Only path
265
+ traversal is refused (a separator, or NUL) -- the store appends the
266
+ ``.json`` suffix itself, so ``..`` cannot escape either.
267
+
268
+ Args:
269
+ key: Entry-file stem (a ``str``), as yielded by
270
+ :meth:`iter_entry_files`. Typed ``object`` because a caller
271
+ that breaks the declared type must still degrade, not crash.
272
+
273
+ Returns:
274
+ True if a file was deleted, False if the name is unusable, there
275
+ was nothing to delete, or the deletion failed.
276
+ """
277
+ if not isinstance(key, str):
278
+ return False
279
+ if "\x00" in key or any(separator in key for separator in _KEY_PATH_SEPARATORS):
280
+ return False
281
+
282
+ entry_file = self._store_dir / f"{key}{_ENTRY_SUFFIX}"
283
+ try:
284
+ entry_file.unlink()
285
+ return True
286
+ except (OSError, UnicodeError):
287
+ # Missing, a directory, or an unremovable file: all the same
288
+ # documented failure return, like every other unusable state.
289
+ # ``UnicodeError`` covers a lone surrogate, which reaches the
290
+ # filesystem encoding as a ``UnicodeEncodeError`` at ``unlink`` --
291
+ # a ``str`` that passed the path checks above but cannot name a
292
+ # file must still degrade, not crash (#172 review, MINOR-2).
293
+ return False
294
+
295
+ def iter_entry_files(self) -> Iterator[tuple[str, dict[str, Any] | None]]:
296
+ """Iterate over every entry file, with its payload when readable.
297
+
298
+ Unlike :meth:`iter_entries`, this does not hide unusable entries: a file
299
+ that cannot be read (unreadable, bytes that are not valid UTF-8, a
300
+ pathologically deep document) or that holds something other than a JSON
301
+ object is yielded with ``None`` as its payload. That lets a purge see --
302
+ and delete -- the same set of files the statistics count and measure.
303
+
304
+ The key is the entry file's name, so a file whose name is not a legal
305
+ key (see the module docstring) is still yielded but cannot be addressed
306
+ by :meth:`invalidate`.
307
+
308
+ Yields:
309
+ ``(key, entry-or-None)`` pairs in file-name order.
310
+ """
311
+ if not self._store_dir.exists():
312
+ return
313
+
314
+ for entry_file in sorted(self._store_dir.glob(f"*{_ENTRY_SUFFIX}")):
315
+ if not entry_file.is_file():
316
+ continue
317
+
318
+ try:
319
+ with open(entry_file, encoding="utf-8") as f:
320
+ data = json.load(f)
321
+ except (OSError, ValueError, RecursionError):
322
+ data = None
323
+
324
+ key = entry_file.name[: -len(_ENTRY_SUFFIX)]
325
+ yield key, data if isinstance(data, dict) else None
326
+
327
+ def iter_entries(self) -> Iterator[tuple[str, dict[str, Any]]]:
328
+ """Iterate over the readable entries in the store.
329
+
330
+ Entries are addressed by the key encoded in their file name. Files that
331
+ are not entry files (leftover ``.tmp`` files), entries that cannot be
332
+ read, and entries holding something other than a JSON object are
333
+ skipped -- they are not values this store could ever have written. Use
334
+ :meth:`iter_entry_files` when the unusable ones matter (purging).
335
+
336
+ Yields:
337
+ ``(key, entry)`` pairs in file-name order.
338
+ """
339
+ for key, entry in self.iter_entry_files():
340
+ if entry is not None:
341
+ yield key, entry
342
+
343
+ def remove_temp_files(self) -> int:
344
+ """Delete leftover temporary files from interrupted writes.
345
+
346
+ A write stages its entry in ``<key>.<pid>.<random>.tmp`` and renames it
347
+ into place; a process killed between those steps leaves the temporary
348
+ file behind.
349
+
350
+ Returns:
351
+ Number of temporary files deleted.
352
+ """
353
+ if not self._store_dir.exists():
354
+ return 0
355
+
356
+ deleted = 0
357
+ for temp_file in self._store_dir.glob(f"*{_TEMP_SUFFIX}"):
358
+ try:
359
+ if temp_file.is_file():
360
+ temp_file.unlink()
361
+ deleted += 1
362
+ except OSError:
363
+ continue
364
+ return deleted
365
+
366
+ def clear(self) -> int:
367
+ """Delete every file in the store directory.
368
+
369
+ Returns:
370
+ Number of files deleted. ``0`` when the store directory does not
371
+ exist or nothing could be removed.
372
+ """
373
+ if not self._store_dir.exists():
374
+ return 0
375
+
376
+ deleted = 0
377
+ for item in self._store_dir.glob("*"):
378
+ try:
379
+ if item.is_file():
380
+ item.unlink()
381
+ deleted += 1
382
+ except OSError:
383
+ continue
384
+ return deleted
385
+
386
+ def total_size_bytes(self) -> int:
387
+ """Get the total size of the entry files in bytes.
388
+
389
+ Returns:
390
+ Total size in bytes; ``0`` when the store directory does not exist.
391
+ """
392
+ if not self._store_dir.exists():
393
+ return 0
394
+
395
+ total = 0
396
+ for entry_file in self._store_dir.glob(f"*{_ENTRY_SUFFIX}"):
397
+ if entry_file.is_file():
398
+ try:
399
+ total += entry_file.stat().st_size
400
+ except OSError:
401
+ continue
402
+ return total
403
+
404
+ def _safe_entry_path(self, key: object) -> Path | None:
405
+ """Get the entry file path for a key, or ``None`` when the key is illegal.
406
+
407
+ The public methods go through here so that an illegal key degrades to
408
+ their documented miss/failure return instead of raising -- see the
409
+ module docstring. The key is typed ``object`` because a caller that
410
+ breaks the declared ``str`` type must still degrade, not crash.
411
+
412
+ Args:
413
+ key: Entry key.
414
+
415
+ Returns:
416
+ Full Path to the entry file, or ``None`` if the key is illegal.
417
+ """
418
+ try:
419
+ return self._entry_path(key)
420
+ except ValueError:
421
+ return None
422
+
423
+ def _entry_path(self, key: object) -> Path:
424
+ """Get the entry file path for a key.
425
+
426
+ Args:
427
+ key: Entry key, which must be a ``str``, must not be empty, must not
428
+ contain a path separator or a control character (C0 or DEL),
429
+ must not be a directory reference, and must be encodable as a
430
+ file name.
431
+
432
+ Returns:
433
+ Full Path to the entry file.
434
+
435
+ Raises:
436
+ ValueError: If the key is not a ``str``, is empty, contains a path
437
+ separator or a control character, is a directory reference, or
438
+ cannot be encoded as a file name (a lone surrogate).
439
+ """
440
+ if not isinstance(key, str):
441
+ raise ValueError(f"Store key must be a str, not {type(key).__name__}")
442
+ if not key:
443
+ raise ValueError("Store key must not be empty")
444
+ if any(separator in key for separator in _KEY_PATH_SEPARATORS):
445
+ raise ValueError(f"Store key must not contain a path separator: {key!r}")
446
+ if key in _KEY_DIRECTORY_REFERENCES:
447
+ raise ValueError(f"Store key must not be a directory reference: {key!r}")
448
+ if any(
449
+ ord(character) < _FIRST_PRINTABLE_CODEPOINT
450
+ or ord(character) == _DELETE_CODEPOINT
451
+ for character in key
452
+ ):
453
+ raise ValueError(f"Store key must not contain control characters: {key!r}")
454
+ # A lone surrogate cannot be encoded by any file-system encoding (POSIX
455
+ # paths are UTF-8 bytes, Windows paths are UTF-16), so it can never name
456
+ # a file; reject it here rather than let the file-system layer raise
457
+ # ``UnicodeEncodeError`` -- a ``ValueError`` that would slip past the
458
+ # ``except OSError`` guards in ``put``.
459
+ try:
460
+ key.encode("utf-8")
461
+ except UnicodeEncodeError:
462
+ raise ValueError(
463
+ f"Store key must be encodable as a file name: {key!r}"
464
+ ) from None
465
+
466
+ return self._store_dir / f"{key}{_ENTRY_SUFFIX}"
467
+
468
+ @staticmethod
469
+ def _temp_path(entry_file: Path) -> Path:
470
+ """A temporary path next to *entry_file*, unique to this write.
471
+
472
+ Staging under the fixed ``<key>.tmp`` made two writers of one key share
473
+ a file: whichever renamed first took it away, and the other's rename
474
+ failed with ``ENOENT`` -- reported as a cache write failure even though
475
+ the entry was fine (#213 review NIT-6). The process id and a random
476
+ suffix keep concurrent writers apart; ``_TEMP_SUFFIX`` stays last so
477
+ :meth:`remove_temp_files` still finds every leftover.
478
+
479
+ Args:
480
+ entry_file: The entry file this write is staging.
481
+
482
+ Returns:
483
+ Path of the temporary file to write.
484
+ """
485
+ return entry_file.with_name(
486
+ f"{entry_file.stem}.{os.getpid()}.{uuid.uuid4().hex[:8]}{_TEMP_SUFFIX}"
487
+ )
488
+
489
+ @staticmethod
490
+ def _is_windows() -> bool:
491
+ """Report whether the rename needs the Windows file-locking retry loop.
492
+
493
+ Delegates to :class:`PlatformAdapter` so platform detection lives on a
494
+ single seam rather than as a bare ``sys.platform`` check here.
495
+
496
+ Returns:
497
+ True on Windows, as reported by :meth:`PlatformAdapter.is_windows`
498
+ -- platform detection is never read from ``sys.platform`` here
499
+ (review !159 minor).
500
+ """
501
+ return PlatformAdapter.is_windows()
502
+
503
+ def _rename_into_place(self, temp_file: Path, entry_file: Path) -> bool:
504
+ """Move the temporary file onto the entry file.
505
+
506
+ Args:
507
+ temp_file: Fully written temporary file.
508
+ entry_file: Destination entry file.
509
+
510
+ Returns:
511
+ True if the rename succeeded, False if every Windows retry failed.
512
+ """
513
+ if self._is_windows():
514
+ # On Windows, rename might fail due to file locking.
515
+ # Retry a few times with short delays.
516
+ for attempt in range(_WINDOWS_RENAME_ATTEMPTS):
517
+ try:
518
+ if entry_file.exists():
519
+ entry_file.unlink()
520
+ temp_file.rename(entry_file)
521
+ return True
522
+ except OSError:
523
+ if attempt < _WINDOWS_RENAME_ATTEMPTS - 1:
524
+ time.sleep(_WINDOWS_RENAME_DELAY_SECONDS)
525
+
526
+ # All retries failed
527
+ self._discard(temp_file)
528
+ return False
529
+
530
+ # On Unix, direct rename is atomic. A concurrent writer of the same key
531
+ # may have removed the entry between the existence check and the
532
+ # unlink; that is not a failure -- the rename publishes our copy.
533
+ try:
534
+ entry_file.unlink()
535
+ except FileNotFoundError:
536
+ pass
537
+ temp_file.rename(entry_file)
538
+ return True
539
+
540
+ def _discard(self, temp_file: Path) -> None:
541
+ """Remove a temporary file, ignoring failures."""
542
+ try:
543
+ if temp_file.exists():
544
+ temp_file.unlink()
545
+ except OSError:
546
+ pass
@@ -0,0 +1,27 @@
1
+ """Configuration utilities for WL Commands."""
2
+
3
+ from .core import (
4
+ ConfigContext,
5
+ ConfigManager,
6
+ ConfigProvider,
7
+ EnvExporter,
8
+ get_config,
9
+ get_config_manager,
10
+ get_message,
11
+ reload_config,
12
+ reset_config_manager,
13
+ set_config,
14
+ )
15
+
16
+ __all__ = [
17
+ "ConfigContext",
18
+ "ConfigManager",
19
+ "ConfigProvider",
20
+ "EnvExporter",
21
+ "get_config",
22
+ "get_config_manager",
23
+ "reload_config",
24
+ "reset_config_manager",
25
+ "set_config",
26
+ "get_message",
27
+ ]