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
wl_cli/utils/root.py ADDED
@@ -0,0 +1,521 @@
1
+ """The single module that answers "which root does this starting point belong to".
2
+
3
+ This module is the single answer to "which root does this starting point belong
4
+ to" for the whole CLI. The six same-shaped resolvers it replaces -- a PROJECT
5
+ marker walk, the test subsystem's CWD-reading spelling, its anchored mirror, a
6
+ DEPLOY resolver, a git work-tree probe and the bounded LEGACY walk -- were
7
+ deleted outright in the hard cut (#123). No alias and no deprecated wrapper
8
+ survives: a search for any of those old spellings now finds only the decision
9
+ record and the changelog, never code.
10
+
11
+ Interface
12
+ ---------
13
+
14
+ Exactly three public names:
15
+
16
+ * :class:`RootKind` -- *which* rule resolved the root, as a value;
17
+ * :class:`Root` -- the resolved root plus the derived paths below it;
18
+ * :func:`resolve` -- ``resolve(start, kind) -> Root | None``.
19
+
20
+ ``start`` is **required**: the module never reads an implicit current working
21
+ directory. "Not found" is a *result* (``None``), not an exception and not a
22
+ silent fallback -- the module does no fallback of its own. Falling back to the
23
+ CWD when nothing is found is the *caller's* policy and belongs in exactly one
24
+ place at the CLI top level; see ``docs/adr/0006-root-resolution-layering.md``
25
+ for why that layering was chosen and which alternatives were rejected.
26
+
27
+ Deliberately not part of this module
28
+ ------------------------------------
29
+
30
+ Two functions are sometimes grouped with the resolvers above by name only; both
31
+ answer a different question, and pulling either in would widen this interface
32
+ without deepening it:
33
+
34
+ * ``WorkspaceDetector._find_workspace_root`` answers "is this starting point a
35
+ member of a uv workspace" -- a *predicate*, not a locator. A predicate has no
36
+ business returning a :class:`Root`.
37
+ * ``VersionUpdater._resolve_project_root`` resolves **wl's own installation
38
+ location** (including a ``Path(__file__)`` fallback). That is a question about
39
+ the tool, not about the user's root, and it moves with the installation rather
40
+ than with the directory the user ran from.
41
+
42
+ Internals
43
+ ---------
44
+
45
+ * **One cache.** Keyed by ``(starting directory, resolution rule)`` and holding
46
+ the negative ("not found") answer too, so a directory with no root is not
47
+ re-walked on every ``wl`` invocation. There is a single invalidation entry
48
+ point, :func:`_clear_root_cache`, and it has a real production caller
49
+ (``GitInitializer.initialize``, right after ``git init``); before this ticket
50
+ two per-resolver caches shared the job, one of them with a clearer no
51
+ production code ever called.
52
+ * **git is an internal seam.** :class:`_GitProbe` is a protocol and
53
+ :data:`_git_probe` is the default subprocess implementation. Tests inject a
54
+ stand-in instead of monkey-patching ``subprocess`` globally -- which is what
55
+ used to force the git probe to carry a defensive "the stubbed result has no
56
+ usable ``stdout``" shape guard (#105). That guard is gone with the old probe
57
+ (#123): now that the seam is what tests drive, a completed process really is
58
+ one.
59
+ * Imports are underscore-aliased so that ``dir(module)`` -- the module's
60
+ namespace -- really is the three public names and nothing else.
61
+
62
+ The per-kind rules were lifted from the implementations this module replaces,
63
+ including their asymmetries. The comments keep the *reasoning* behind each
64
+ asymmetry, because those implementations are no longer here to explain
65
+ themselves.
66
+ """
67
+
68
+ import enum as _enum
69
+ import os as _os
70
+ import subprocess as _subprocess
71
+ from dataclasses import dataclass as _dataclass
72
+ from pathlib import Path as _Path
73
+ from typing import Protocol as _Protocol
74
+
75
+ __all__ = ["Root", "RootKind", "resolve"]
76
+
77
+ _WL_DIR_NAME = ".wl"
78
+ _CONFIG_FILE_NAME = "config.json"
79
+ _PYPROJECT_NAME = "pyproject.toml"
80
+
81
+
82
+ class RootKind(_enum.Enum):
83
+ """Which rule resolved a root -- the input selector *and* the output label.
84
+
85
+ Kind is both input and output because each kind is resolved by a different
86
+ rule, and a caller that must branch on "what kind of root is this" would
87
+ otherwise have to remember which function name it called.
88
+ """
89
+
90
+ #: The nearest ancestor carrying a ``.wl`` directory or a ``pyproject.toml``
91
+ #: file -- the plain PROJECT rule, with no git involvement.
92
+ PROJECT = "project"
93
+ #: git's own work-tree top (``git rev-parse --show-toplevel``).
94
+ WORKTREE = "worktree"
95
+ #: The root the ``.wl`` and the hooks leg are actually deployed under: the
96
+ #: git work-tree top when there is one, else the project root.
97
+ DEPLOY = "deploy"
98
+ #: An ancestor ``.wl`` in the pre-#95/#96 format that still has to be
99
+ #: migrated to the deploy root. Its boundaries are **completely different**
100
+ #: from :attr:`DEPLOY` -- a volume-root-bounded walk, a named system-scope
101
+ #: blacklist, and an explicit refusal of ``<home>/.wl`` -- which is why it
102
+ #: is its own value rather than a branch of ``DEPLOY``.
103
+ LEGACY = "legacy"
104
+
105
+
106
+ @_dataclass(frozen=True)
107
+ class Root:
108
+ """A resolved root and the paths derived below it.
109
+
110
+ ``path`` is the root directory itself; ``kind`` records which rule produced
111
+ it (see :class:`RootKind`). The derived-path methods are pure -- they build
112
+ a path from ``path`` and touch nothing on disk -- so they are testable
113
+ against a bare :class:`Root` without constructing a directory tree.
114
+
115
+ The methods are the only place the ``.wl`` layout is spelled out for the
116
+ roots this module owns; migrating the ~40 ``.wl`` literals and the bare
117
+ ``src/`` checks onto them is a separate ticket.
118
+ """
119
+
120
+ path: _Path
121
+ kind: RootKind
122
+
123
+ def wl_dir(self) -> _Path:
124
+ """The ``.wl`` control directory at this root."""
125
+ return self.path / _WL_DIR_NAME
126
+
127
+ def mypy_ini(self) -> _Path:
128
+ """The mypy baseline (``.wl/mypy.ini``) at this root."""
129
+ return self.wl_dir() / "mypy.ini"
130
+
131
+ def hooks_dir(self) -> _Path:
132
+ """The deployed git hooks directory (``.wl/hooks``) at this root."""
133
+ return self.wl_dir() / "hooks"
134
+
135
+ def src_dir(self) -> _Path:
136
+ """The source package directory (``src/``) at this root."""
137
+ return self.path / "src"
138
+
139
+
140
+ class _GitProbe(_Protocol):
141
+ """The git seam: answer "what is the work-tree top for *start*".
142
+
143
+ Implementations return ``None`` for every "there is no work tree here"
144
+ shape -- git missing, a failed call, a directory that is not in any work
145
+ tree -- so no caller has to distinguish them.
146
+ """
147
+
148
+ def work_tree_root(self, start: _Path) -> _Path | None: # pragma: no cover
149
+ ...
150
+
151
+
152
+ class _SubprocessGitProbe:
153
+ """Default :class:`_GitProbe`: ``git rev-parse --show-toplevel``.
154
+
155
+ Returns ``None`` for every "there is no work tree here" shape -- git
156
+ missing, a failed call, a directory that is not part of any work tree -- so
157
+ no caller has to distinguish them.
158
+
159
+ Tests drive :data:`_git_probe` directly, so this reads a real
160
+ ``CompletedProcess``. The old probe also guarded the *shape* of the result
161
+ because tests patched ``subprocess`` globally and could hand back one whose
162
+ ``stdout`` was ``None`` or bytes (#105); with no such caller left, that
163
+ guard was deleted along with the probe it belonged to (#123).
164
+ """
165
+
166
+ def work_tree_root(self, start: _Path) -> _Path | None:
167
+ try:
168
+ result = _subprocess.run(
169
+ ["git", "rev-parse", "--show-toplevel"],
170
+ cwd=start,
171
+ capture_output=True,
172
+ text=True,
173
+ check=False,
174
+ )
175
+ except (OSError, _subprocess.SubprocessError):
176
+ return None
177
+ if result.returncode != 0:
178
+ return None
179
+ value = result.stdout.strip()
180
+ return _Path(value) if value else None
181
+
182
+
183
+ #: The single git seam. Tests swap this for a stand-in rather than patching
184
+ #: ``subprocess`` module-wide; production never touches it.
185
+ _git_probe: _GitProbe = _SubprocessGitProbe()
186
+
187
+ #: The one cache: ``(resolved starting directory, kind) -> Root | None``.
188
+ #:
189
+ #: Keyed the way the old caches were individually keyed -- by the *resolved*
190
+ #: spelling of the directory the lookup ran from -- so that two spellings of the
191
+ #: same directory (a relative path, a symlinked ``/tmp``) still share an entry.
192
+ #: The negative answer is cached too: "no root from here" is precisely the case
193
+ #: that would otherwise re-walk the filesystem on every ``wl`` invocation.
194
+ _root_cache: dict[tuple[str, RootKind], Root | None] = {}
195
+
196
+
197
+ def _clear_root_cache() -> None:
198
+ """The one invalidation entry point for :data:`_root_cache`.
199
+
200
+ A layout change made *under an already-consulted directory* -- ``wl init``'s
201
+ ``git init`` is the production example -- can move the answer, and the memo
202
+ is per-directory, so the change has to be announced. This replaces the two
203
+ per-resolver caches and their separate clearers; the previous generation had
204
+ a clearer that no production code ever called, i.e. a cache that never
205
+ expired.
206
+
207
+ The production caller is ``GitInitializer.initialize``, right after a
208
+ successful ``git init``: the ``wl init`` group callback resolves the deploy
209
+ root *before* the subcommand runs, so the pre-creation answer is already
210
+ memoised for that directory and creating the work tree moves it. The
211
+ test-side entry point is the single ``_cold_root_cache`` fixture in
212
+ ``tests/conftest.py`` (#115).
213
+
214
+ It is underscore-prefixed on purpose: the public face is the three names
215
+ above, so this stays an internal seam.
216
+ """
217
+ _root_cache.clear()
218
+
219
+
220
+ def resolve(start: _Path, kind: RootKind) -> Root | None:
221
+ """Resolve the ``kind``-root that *start* belongs to, or ``None``.
222
+
223
+ ``start`` is required and is used exactly as given: the module never reads
224
+ an implicit current working directory, so a caller's own directory can never
225
+ leak into the answer (the failure an anchored mirror of the CWD-reading
226
+ spelling was written to avoid, #110). ``None`` means "no such root from
227
+ here" -- a result,
228
+ not an error; this module performs **no** fallback.
229
+
230
+ The result is memoised per ``(start, kind)``; see :func:`_clear_root_cache`
231
+ for the one way to invalidate it.
232
+ """
233
+ resolved = _safe_resolve(start)
234
+ key = (str(resolved), kind)
235
+ if key in _root_cache:
236
+ return _root_cache[key]
237
+ result = _compute(resolved, kind)
238
+ _root_cache[key] = result
239
+ return result
240
+
241
+
242
+ def _compute(start: _Path, kind: RootKind) -> Root | None:
243
+ """The uncached body of :func:`resolve`; *start* is already resolved."""
244
+ if kind is RootKind.PROJECT:
245
+ found = _project_marker_root(start)
246
+ return Root(found, kind) if found is not None else None
247
+ if kind is RootKind.WORKTREE:
248
+ top = _git_probe.work_tree_root(start)
249
+ return Root(top, kind) if top is not None else None
250
+ if kind is RootKind.DEPLOY:
251
+ return _deploy(start)
252
+ if kind is RootKind.LEGACY:
253
+ return _legacy(start)
254
+ raise ValueError(f"unknown RootKind: {kind!r}")
255
+
256
+
257
+ def _deploy(start: _Path) -> Root | None:
258
+ """``DEPLOY``: the git work-tree top, else the project root.
259
+
260
+ The rule is the one the deploy resolver this module replaces used -- "the
261
+ work-tree top when there is one, otherwise the project root" -- with the one
262
+ difference that is the whole point of this module: when the git lookup
263
+ misses *and* no project marker exists, that resolver returned the starting
264
+ directory (its own CWD fallback); here that is ``None``, and the caller
265
+ decides whether to substitute anything.
266
+ """
267
+ top = _git_probe.work_tree_root(start)
268
+ if top is not None:
269
+ return Root(top, RootKind.DEPLOY)
270
+ found = _project_marker_root(start)
271
+ return Root(found, RootKind.DEPLOY) if found is not None else None
272
+
273
+
274
+ def _legacy(start: _Path) -> Root | None:
275
+ """``LEGACY``: the nearest ``.wl`` still to be migrated, or ``None``.
276
+
277
+ ``start`` is the *invocation* directory, matching the one place the legacy
278
+ production walk ran from (``check_and_update_wl_dir`` passes the invocation
279
+ CWD). The deploy anchor the walk goes above is derived
280
+ internally by :func:`_deploy_anchor`; deriving it here rather than making
281
+ ``resolve`` take two arguments is what keeps the interface at one starting
282
+ point.
283
+
284
+ The returned :class:`Root` is the *parent* of the legacy ``.wl`` -- the root
285
+ whose ``.wl`` is the stale one -- so ``result.wl_dir()`` is the directory the
286
+ old walk returned.
287
+
288
+ Both shapes of that walk are reproduced:
289
+
290
+ * the invocation directory's project root, when it is not the deploy root
291
+ (a sub-package with its own ``pyproject.toml``, or a workspace whose
292
+ ``.wl`` the walk finds above);
293
+ * an ancestor ``.wl`` above the deploy root, bounded by
294
+ :func:`_is_out_of_scope_ancestor` and by the explicit ``<home>/.wl``
295
+ refusal.
296
+
297
+ The three bounds are copied from ``wl_dir_updater`` verbatim. They are *not*
298
+ ``DEPLOY``'s rules: the walk is bounded by the volume's filesystem root and
299
+ its direct children (plus the named :data:`_SYSTEM_WL_SCOPES`), and
300
+ ``<home>/.wl`` -- the *user-level* config, a different thing -- is refused by
301
+ realpath wherever the deploy root happens to sit (#104 N1).
302
+ """
303
+ deploy_anchor = _deploy_anchor(start)
304
+ deploy_wl = deploy_anchor / _WL_DIR_NAME
305
+
306
+ project = _project_marker_root(start)
307
+ # The old project walk never returned None, so the project anchor was the
308
+ # marker root or, when there was none, the starting directory itself.
309
+ project_wl = (project if project is not None else start) / _WL_DIR_NAME
310
+ if not _same_path(project_wl, deploy_wl) and _is_file(
311
+ project_wl / _CONFIG_FILE_NAME
312
+ ):
313
+ return Root(project_wl.parent, RootKind.LEGACY)
314
+
315
+ user_level_wl = [home / _WL_DIR_NAME for home in _user_home_dirs()]
316
+ ceiling = _walk_ceiling(deploy_anchor)
317
+
318
+ for ancestor in deploy_anchor.parents:
319
+ if _is_out_of_scope_ancestor(ancestor, ceiling):
320
+ # Ceiling / named system scope: stop, adopt nothing, and do not go
321
+ # higher -- nothing above them belongs to this project either.
322
+ break
323
+ candidate = ancestor / _WL_DIR_NAME
324
+ if any(_same_path(candidate, user_wl) for user_wl in user_level_wl):
325
+ break
326
+ if _same_path(candidate, deploy_wl):
327
+ continue
328
+ if _is_file(candidate / _CONFIG_FILE_NAME):
329
+ return Root(candidate.parent, RootKind.LEGACY)
330
+
331
+ return None
332
+
333
+
334
+ def _deploy_anchor(start: _Path) -> _Path:
335
+ """The deploy point, never ``None`` -- ``LEGACY``'s internal anchor.
336
+
337
+ ``LEGACY`` walks *above* the deploy point, so it needs the anchor to exist
338
+ even when there is no git work tree and no project marker; the old deploy
339
+ resolver got that from its starting-directory fallback. Spelled out here as
340
+ an internal step so that :func:`resolve` can still answer ``None`` for the
341
+ ``DEPLOY`` kind itself.
342
+ """
343
+ top = _git_probe.work_tree_root(start)
344
+ if top is not None:
345
+ return top
346
+ found = _project_marker_root(start)
347
+ return found if found is not None else start
348
+
349
+
350
+ def _project_marker_root(start: _Path) -> _Path | None:
351
+ """The ``PROJECT`` rule, step for step the one the old walk used.
352
+
353
+ That walk defined the markers but then papered over "not found" by returning
354
+ the starting path; here that outcome is ``None``.
355
+
356
+ A directory is a project root when it holds a ``.wl`` *directory* or a
357
+ ``pyproject.toml`` *file* -- the shapes matter, so a stray ``.wl`` file or a
358
+ ``pyproject.toml`` directory is not a marker. The nearest such ancestor wins,
359
+ and the walk stops before the filesystem root, which is never itself scanned.
360
+
361
+ The leading ``.wl`` check mirrors the original's ordering exactly, where the
362
+ starting directory was tested for ``.wl`` before the walk began. Since both
363
+ markers resolve to the *same* directory, that pre-check is redundant with the
364
+ walk's first iteration; it is kept only so the two rules stay line-by-line
365
+ comparable.
366
+ """
367
+ if _is_dir(start / _WL_DIR_NAME):
368
+ return start
369
+ check = start
370
+ while check.parent != check:
371
+ if _is_file(check / _PYPROJECT_NAME):
372
+ return check
373
+ if _is_dir(check / _WL_DIR_NAME):
374
+ return check
375
+ check = check.parent
376
+ return None
377
+
378
+
379
+ def _safe_resolve(path: _Path) -> _Path:
380
+ """``Path.resolve()`` is not total -- a symlink loop raises ``RuntimeError``.
381
+
382
+ The un-resolved spelling is the fallback: the subsequent marker walk returns
383
+ the same answer either way, and the cache key stays usable.
384
+ """
385
+ try:
386
+ return path.resolve()
387
+ except Exception:
388
+ return path
389
+
390
+
391
+ def _is_dir(path: _Path) -> bool:
392
+ """``is_dir`` without letting an unreadable component raise."""
393
+ try:
394
+ return path.is_dir()
395
+ except OSError: # pragma: no cover - platform dependent
396
+ return False
397
+
398
+
399
+ def _is_file(path: _Path) -> bool:
400
+ """``is_file`` without letting an unreadable component raise."""
401
+ try:
402
+ return path.is_file()
403
+ except OSError: # pragma: no cover - platform dependent
404
+ return False
405
+
406
+
407
+ def _same_path(a: _Path, b: _Path) -> bool:
408
+ """Whether two paths point at the same directory (tolerating symlinks)."""
409
+ try:
410
+ return a.resolve() == b.resolve()
411
+ except Exception:
412
+ return a == b
413
+
414
+
415
+ # --- LEGACY's bounds ---------------------------------------------------------
416
+ #
417
+ # These used to be duplicated: ``wl_dir_updater`` carried its own copy of the
418
+ # legacy walk and of these three bounds while this module was being expanded
419
+ # ahead of its call sites. The hard cut (#123) deleted that copy, so this is now
420
+ # the only one -- the bounds live with the rule that uses them.
421
+
422
+ #: Shared, machine-level scopes: a ``.wl`` sitting directly in one of these
423
+ #: belongs to the OS or to another user -- never to *this* checkout. Matched on
424
+ #: the realpath, because on macOS ``/tmp``, ``/var`` and ``/etc`` are symlinks
425
+ #: into ``/private`` while the deploy anchor is handed back resolved.
426
+ _SYSTEM_WL_SCOPES = frozenset(
427
+ {
428
+ "/tmp",
429
+ "/var",
430
+ "/etc",
431
+ "/usr",
432
+ "/opt",
433
+ "/srv",
434
+ "/bin",
435
+ "/sbin",
436
+ "/lib",
437
+ "/lib64",
438
+ "/boot",
439
+ "/dev",
440
+ "/proc",
441
+ "/sys",
442
+ "/run",
443
+ "/root",
444
+ "/home",
445
+ "/mnt",
446
+ "/media",
447
+ "/lost+found",
448
+ "/private",
449
+ "/System",
450
+ "/Library",
451
+ "/Volumes",
452
+ "/Applications",
453
+ "/app",
454
+ "/builds",
455
+ "/workspace",
456
+ "/workspaces",
457
+ }
458
+ )
459
+
460
+ #: :data:`_SYSTEM_WL_SCOPES` pre-resolved once, so the per-walk test is a set
461
+ #: lookup. Resolving a path that does not exist is harmless.
462
+ _RESOLVED_SYSTEM_WL_SCOPES = frozenset(_os.path.realpath(p) for p in _SYSTEM_WL_SCOPES)
463
+
464
+
465
+ def _user_home_dirs() -> list[_Path]:
466
+ """Every home directory the environment can vouch for (possibly none).
467
+
468
+ Deliberately *not* ``ConfigPathResolver.get_user_home_dir()``: when neither
469
+ ``$HOME`` nor the account database yields a home that method falls back to
470
+ ``os.getcwd()``, which would turn the caller's own directory into a bogus
471
+ "home" and silently disable every guard derived from it. Here an unknown
472
+ home is simply absent, and the walk ceiling still bounds the walk.
473
+
474
+ This distrust is warranted, not vestigial: the 2026-10-08 ruling kept that
475
+ caller-side CWD fallback in the config layer, and #126 ruled it stays there
476
+ (it answers the user's **home**, a different question from a project root, so
477
+ it is deliberately *not* folded into the single root-fallback policy) -- so
478
+ this module must go on deriving home from the environment alone.
479
+ """
480
+ candidates: list[_Path] = []
481
+ for var in ("HOME", "USERPROFILE"):
482
+ raw = _os.environ.get(var)
483
+ if raw:
484
+ candidates.append(_Path(raw))
485
+ try:
486
+ candidates.append(_Path.home())
487
+ except (RuntimeError, OSError): # pragma: no cover - platform dependent
488
+ pass
489
+
490
+ # A relative path here is not a home: ``Path.home()`` degrades to ``~`` when
491
+ # it cannot resolve, and an inherited ``HOME=~`` is nonsense. Sources
492
+ # routinely agree (``$HOME`` *is* what ``Path.home()`` reads), so dedupe.
493
+ return list(dict.fromkeys(home for home in candidates if home.is_absolute()))
494
+
495
+
496
+ def _walk_ceiling(deploy_root: _Path) -> _Path:
497
+ """Absolute, HOME-independent ceiling of the legacy ancestor walk.
498
+
499
+ The filesystem root of ``deploy_root``'s volume -- the bound that does not
500
+ care where the checkout lives or whether ``$HOME`` is usable at all.
501
+ """
502
+ anchor = _Path(_os.path.realpath(deploy_root)).anchor
503
+ return _Path(anchor) if anchor else _Path("/")
504
+
505
+
506
+ def _is_out_of_scope_ancestor(ancestor: _Path, ceiling: _Path) -> bool:
507
+ """Whether ``ancestor`` may not be a legacy deploy point -- nor walked above.
508
+
509
+ Two absolute, HOME-independent rules:
510
+
511
+ * **position** -- the walk never inspects ``ceiling`` (the volume's
512
+ filesystem root) or any of its direct children;
513
+ * **the named** :data:`_SYSTEM_WL_SCOPES` -- matched on the realpath so the
514
+ symlinked spellings line up (macOS ``/tmp`` *is* ``/private/tmp``).
515
+ """
516
+ if ancestor == ceiling or ancestor.parent == ceiling:
517
+ return True
518
+ resolved = _Path(_os.path.realpath(ancestor))
519
+ if resolved == ceiling or resolved.parent == ceiling:
520
+ return True
521
+ return str(resolved) in _RESOLVED_SYSTEM_WL_SCOPES