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,1016 @@
1
+ """Format path handler module."""
2
+
3
+ import os
4
+ import re
5
+ from collections.abc import Iterable, Iterator
6
+ from pathlib import Path
7
+ from typing import cast
8
+
9
+ import tomllib
10
+
11
+ from ...utils.logging import log_info_pair, log_warning_pair
12
+ from ...utils.pre_commit import PreCommitRun, run_pre_commit
13
+ from ...utils.pre_commit_config import managed_pre_commit_config
14
+ from ...utils.root import RootKind
15
+ from ...utils.root_fallback import current_root
16
+ from ...utils.subprocess_utils import SubprocessExecutor
17
+ from ...utils.uv_run import run_uv_command
18
+ from .format_executor import FormatExecutor
19
+ from .format_outcome import FormatFailure, FormatOutcome
20
+ from .python_formatter import format_with_python_tools
21
+ from .rust_formatter import format_rust_code
22
+
23
+ #: Directories discovery never descends into (#187): virtualenvs, dependency
24
+ #: trees, packaging output and caches. Unconditional -- unlike the ruff
25
+ #: patterns below, an explicit config cannot re-include them, because handing
26
+ #: one of their files to ruff as an *explicit* path is exactly the ``.venv``
27
+ #: rewrite this ticket reports (ruff does not apply ``exclude`` to explicit
28
+ #: files). ``dist`` and ``build`` are in ruff's own default ``exclude``; they
29
+ #: are repeated here because a project config replaces those defaults outright
30
+ #: and the user docs promise they stay ignored (#206).
31
+ _FIXED_IGNORED_DIRS = frozenset(
32
+ {
33
+ ".git",
34
+ ".hg",
35
+ ".svn",
36
+ ".venv",
37
+ "venv",
38
+ "node_modules",
39
+ "site-packages",
40
+ "dist-packages",
41
+ "dist",
42
+ "build",
43
+ "__pycache__",
44
+ ".mypy_cache",
45
+ ".pytest_cache",
46
+ ".ruff_cache",
47
+ ".tox",
48
+ ".nox",
49
+ ".hypothesis",
50
+ ".eggs",
51
+ }
52
+ )
53
+
54
+ #: Compiled exclude pattern, in config order.
55
+ _ExcludeMatcher = re.Pattern[str]
56
+
57
+
58
+ def _ignored_dir_name(name: str) -> bool:
59
+ """Whether *name* is in the unconditional ignore set."""
60
+ return name in _FIXED_IGNORED_DIRS or name.endswith(".egg-info")
61
+
62
+
63
+ def _load_toml(path: Path) -> dict | None:
64
+ """Read *path* as TOML; ``None`` when it is unreadable or malformed."""
65
+ try:
66
+ with path.open("rb") as handle:
67
+ return tomllib.load(handle)
68
+ except (OSError, tomllib.TOMLDecodeError):
69
+ return None
70
+
71
+
72
+ def _string_list(value: object) -> tuple[str, ...]:
73
+ """The string entries of a ruff list option (anything else is ignored)."""
74
+ if not isinstance(value, list):
75
+ return ()
76
+ return tuple(item for item in value if isinstance(item, str))
77
+
78
+
79
+ def _base_exclude_patterns(ruff_config: dict) -> tuple[str, ...]:
80
+ """The top-level ``exclude`` / ``extend-exclude`` ruff's resolver applies.
81
+
82
+ These are directory patterns: an excluded directory excludes everything
83
+ below it, which is why they are compiled with descendant matching.
84
+ ``ruff check --show-files`` already applies them, so discovery only needs
85
+ them on the local-walk fallback.
86
+ """
87
+ patterns: list[str] = []
88
+ for key in ("exclude", "extend-exclude"):
89
+ patterns.extend(_string_list(ruff_config.get(key)))
90
+ return tuple(patterns)
91
+
92
+
93
+ def _mode_exclude_patterns(ruff_config: dict) -> tuple[str, ...]:
94
+ """``lint.exclude`` / ``format.exclude``, applied per mode after discovery.
95
+
96
+ Ruff matches these against **file paths**, not against directory prefixes:
97
+ with ``exclude = ["gen"]`` ruff still visits ``gen/x.py``; ``gen/*.py`` or
98
+ ``**/gen/**`` is what excludes it (ruff 0.15). ``*`` and ``?`` cross ``/``
99
+ there, so ``gen/*.py`` also drops ``gen/sub/y.py``. Discovery has to
100
+ mirror that exactly, because it hands every file to ruff as an *explicit*
101
+ path, where ruff never applies ``exclude`` at all: a file dropped here is
102
+ never linted, and a file kept here is linted whatever the mode list says.
103
+
104
+ Both mode lists are invisible to ``ruff check --show-files``, which stops
105
+ at the base resolver (#195), and both follow the *nearest* config -- so a
106
+ subpackage's own ``[tool.ruff.lint] exclude`` governs its files (#206).
107
+ The ``lint`` / ``format`` tables have no ``extend-exclude`` of their own
108
+ (ruff rejects the key as unknown), so only ``exclude`` is read.
109
+
110
+ The two lists are **unioned** here and applied to both commands (#206):
111
+ ``wl lint`` runs ``ruff check`` *and* ``ruff format``, so the union is the
112
+ safe reading for it; ``wl format`` is consequently conservative -- a file
113
+ only ``lint.exclude`` names is not formatted even though ``ruff format``
114
+ would format it. Documented as a trade-off in ``docs/user/commands/lint.md``;
115
+ splitting the matcher per command would need the mode threaded through
116
+ discovery, which the union avoids.
117
+ """
118
+ patterns: list[str] = []
119
+ for mode in ("lint", "format"):
120
+ section = ruff_config.get(mode)
121
+ if isinstance(section, dict):
122
+ patterns.extend(_string_list(section.get("exclude")))
123
+ return tuple(patterns)
124
+
125
+
126
+ def _ruff_config_for(directory: Path) -> tuple[Path, dict | None]:
127
+ """The directory and ruff config table governing files under *directory*.
128
+
129
+ Mirrors ruff's own discovery: the nearest ancestor carrying
130
+ ``ruff.toml`` / ``.ruff.toml`` (same-directory files win over
131
+ ``pyproject.toml``), else the nearest ``pyproject.toml`` with a
132
+ ``[tool.ruff]`` table. Files without ruff config are transparent. The
133
+ lookup starts at each file's own directory, so a nested config governs its
134
+ own subtree exactly as ruff's hierarchical configuration does (#206).
135
+ """
136
+ try:
137
+ start = directory.resolve()
138
+ except OSError:
139
+ start = directory
140
+ for candidate in (start, *start.parents):
141
+ for name in ("ruff.toml", ".ruff.toml"):
142
+ config_file = candidate / name
143
+ if config_file.is_file():
144
+ data = _load_toml(config_file)
145
+ if data is not None:
146
+ return candidate, data
147
+ pyproject = candidate / "pyproject.toml"
148
+ if pyproject.is_file():
149
+ data = _load_toml(pyproject)
150
+ if data is not None:
151
+ tool = data.get("tool")
152
+ ruff = tool.get("ruff") if isinstance(tool, dict) else None
153
+ if isinstance(ruff, dict):
154
+ return candidate, ruff
155
+ return start, None
156
+
157
+
158
+ class _RuffExcludes:
159
+ """Per-directory cache of the exclude matchers that govern a directory.
160
+
161
+ Discovery asks once per directory rather than once per file: a scan can
162
+ list hundreds of files and every lookup walks ancestors and parses TOML.
163
+ The cache lives for one discovery call, so a config edited mid-process is
164
+ still read fresh next time.
165
+ """
166
+
167
+ def __init__(self) -> None:
168
+ self._cache: dict[
169
+ Path, tuple[Path, tuple[_ExcludeMatcher, ...], tuple[_ExcludeMatcher, ...]]
170
+ ] = {}
171
+
172
+ def for_directory(
173
+ self, directory: Path
174
+ ) -> tuple[Path, tuple[_ExcludeMatcher, ...], tuple[_ExcludeMatcher, ...]]:
175
+ """``(config directory, base matchers, mode matchers)`` for *directory*."""
176
+ cached = self._cache.get(directory)
177
+ if cached is not None:
178
+ return cached
179
+ config_dir, ruff_config = _ruff_config_for(directory)
180
+ ruff_config = ruff_config or {}
181
+ entry = (
182
+ config_dir,
183
+ _compile_excludes(_base_exclude_patterns(ruff_config)),
184
+ _compile_excludes(_mode_exclude_patterns(ruff_config), descendants=False),
185
+ )
186
+ self._cache[directory] = entry
187
+ return entry
188
+
189
+
190
+ def _warn_listing_fallback(directory: Path, reason: str) -> None:
191
+ """Say out loud that discovery degraded to the local walk (#206).
192
+
193
+ A warning, not a hard failure: the formatting run that follows is the
194
+ authority on a broken ruff and reports its own error, while failing here
195
+ would preempt that with "could not list files". What must not happen is
196
+ the pre-#206 silence -- the local walk loses ruff's default excludes,
197
+ ``respect-gitignore`` and nested configs, so a caller has to know the run
198
+ is only a partial mirror of what ruff would do.
199
+ """
200
+ log_warning_pair(
201
+ f"ruff could not list files under {directory} ({reason}); falling back to "
202
+ "wl's own walk, which cannot fully mirror ruff's exclude rules",
203
+ f"ruff 无法枚举 {directory} 下的文件({reason}),改用 wl 自己的遍历;"
204
+ "该遍历无法完全对齐 ruff 的排除规则",
205
+ )
206
+
207
+
208
+ def _ruff_listed_files(directory: Path, env: dict | None) -> tuple[Path, ...] | None:
209
+ """The files ruff itself would visit under *directory*, or ``None``.
210
+
211
+ ``ruff check --show-files`` *is* ruff's file resolution (#195), so
212
+ discovery stops re-implementing it: the built-in default ``exclude``
213
+ (``dist``, ``.venv`` ...), the top-level project ``exclude`` /
214
+ ``extend-exclude``, nested configs and ``respect-gitignore`` all come from
215
+ ruff rather than from a partial local copy -- which is how ``dist`` used
216
+ to be handed over as an *explicit* file, where ruff never applies
217
+ ``exclude``.
218
+
219
+ It stops at the *base* resolver. ``[tool.ruff.lint]`` and
220
+ ``[tool.ruff.format]`` ``exclude`` are applied per mode, afterwards, so the
221
+ caller keeps filtering with those lists (see
222
+ :meth:`FormatPathHandler._discovered_python_files`); ``ruff format`` has no
223
+ ``--show-files`` at all.
224
+
225
+ Returns ``None`` when ruff could not be asked at all (not installed,
226
+ refused the config). Discovery then falls back to the local walk rather
227
+ than failing the gate: the formatting run itself reports a broken ruff, and
228
+ failing closed here would replace that diagnosis with a less actionable
229
+ one. The fallback is no longer silent, though (#206): it cannot mirror
230
+ ruff's default excludes, ``respect-gitignore`` or nested configs, so the
231
+ degradation is announced by :func:`_warn_listing_fallback`.
232
+ """
233
+ try:
234
+ result = run_uv_command(
235
+ SubprocessExecutor(),
236
+ ["uv", "run", "ruff", "check", "--show-files", str(directory)],
237
+ # An empty mapping would strip PATH from the child and make every
238
+ # launch fail; ``None`` inherits, which is what "no environment
239
+ # given" means at both call sites.
240
+ env=env or None,
241
+ quiet=True,
242
+ )
243
+ except Exception as exc:
244
+ _warn_listing_fallback(directory, f"{type(exc).__name__}: {exc}")
245
+ return None
246
+ if result.returncode != 0:
247
+ _warn_listing_fallback(directory, f"exit code {result.returncode}")
248
+ return None
249
+ return tuple(
250
+ Path(line.strip()) for line in result.stdout.splitlines() if line.strip()
251
+ )
252
+
253
+
254
+ def _relative_to_root(path: Path, root: Path) -> Path | None:
255
+ """Where *path* sits under *root*, ``None`` when it sits outside.
256
+
257
+ Ruff echoes the prefix it was handed (``/var/...``) while *root* is
258
+ resolved (``/private/var/...`` on macOS), so the plain subtraction is
259
+ retried with the file's *parent* canonicalised. Only the parent is
260
+ canonicalised on purpose: a symlinked file must keep its own identity so
261
+ the caller can still recognise (and skip) it.
262
+ """
263
+ try:
264
+ return path.relative_to(root)
265
+ except ValueError:
266
+ pass
267
+ try:
268
+ canonical = Path(os.path.realpath(path.parent)) / path.name
269
+ return canonical.relative_to(root)
270
+ except (OSError, ValueError):
271
+ return None
272
+
273
+
274
+ def _glob_to_regex(pattern: str, *, descendants: bool = True) -> re.Pattern[str] | None:
275
+ """Translate one ruff exclude pattern into an anchored regex.
276
+
277
+ Ruff compiles ``exclude`` entries with ``globset`` **without**
278
+ ``literal_separator``, which is not gitignore syntax: ``*`` and ``?``
279
+ match ``/`` too, so ``a/*`` matches ``a/b/w.py`` and ``gen/*.py`` matches
280
+ ``gen/sub/y.py`` (verified against ruff 0.15.21). ``**`` is equivalent for
281
+ path matching; the only place it differs is the zero-directory form
282
+ ``**/`` at the start or middle of a pattern, which also matches no
283
+ directory at all. Character classes, a leading ``/`` anchor and the
284
+ ``!`` literal (ruff does **not** support negation) are handled too.
285
+
286
+ ``descendants`` selects which ruff matcher is being mirrored (#206):
287
+
288
+ * ``True`` -- the base ``exclude`` / ``extend-exclude`` resolved by
289
+ ``ruff check --show-files`` (and mirrored on wl's fallback walk). It is
290
+ directory-aware: an unanchored pattern is matched at any depth
291
+ (``w.py`` excludes ``a/b/w.py``) and a matched directory excludes its
292
+ contents.
293
+ * ``False`` -- ``lint.exclude`` / ``format.exclude``. Ruff matches those
294
+ against **file paths**, never directory prefixes: a bare ``gen`` does
295
+ not match ``gen/x.py`` (a matched directory does not pull its contents
296
+ in). Depth still applies, though -- ruff resolves a pattern without
297
+ ``/`` at any depth (``w.py`` drops ``a/b/w.py``), and a pattern with
298
+ ``/`` is anchored at its config root (``gen/*.py`` leaves
299
+ ``a/gen/x.py`` alone). Cross-directory reach also comes from ``*``.
300
+ """
301
+ body = pattern
302
+ anchored = body.startswith("/")
303
+ if anchored:
304
+ body = body[1:]
305
+ body = body.rstrip("/")
306
+ if not body:
307
+ return None
308
+ anchored = anchored or "/" in body
309
+
310
+ parts: list[str] = []
311
+ index = 0
312
+ while index < len(body):
313
+ char = body[index]
314
+ if char == "*":
315
+ if body.startswith("**", index):
316
+ index += 2
317
+ if index < len(body) and body[index] == "/":
318
+ index += 1
319
+ parts.append("(?:.*/)?")
320
+ else:
321
+ parts.append(".*")
322
+ continue
323
+ # globset's default: ``*`` crosses path separators.
324
+ parts.append(".*")
325
+ elif char == "?":
326
+ parts.append(".")
327
+ elif char == "[" and (end := body.find("]", index + 1)) != -1:
328
+ stuff = body[index + 1 : end]
329
+ if stuff.startswith("!"):
330
+ stuff = "^" + stuff[1:]
331
+ parts.append(f"[{stuff}]")
332
+ index = end
333
+ else:
334
+ parts.append(re.escape(char))
335
+ index += 1
336
+
337
+ # Both ruff matchers resolve a pattern without "/" at any depth: the base
338
+ # walker and the mode lists (``lint.exclude = ["w.py"]`` drops
339
+ # ``a/b/w.py``). Only the directory-descendant suffix is base-only, since
340
+ # a mode list never matches a directory (#206).
341
+ prefix = "" if anchored else r"(?:.*/)?"
342
+ suffix = r"(?:/.*)?" if descendants else ""
343
+ return re.compile(prefix + "".join(parts) + suffix + r"\Z")
344
+
345
+
346
+ def _compile_excludes(
347
+ patterns: Iterable[str], *, descendants: bool = True
348
+ ) -> tuple[_ExcludeMatcher, ...]:
349
+ """Compile exclude patterns in config order.
350
+
351
+ ``!`` is not a negation: ruff compiles exclude entries as globs, so a
352
+ leading ``!`` is an ordinary character (#206). Applying gitignore-style
353
+ negation here would keep a file ruff excludes and hand it to
354
+ ``ruff --fix``.
355
+ """
356
+ matchers: list[_ExcludeMatcher] = []
357
+ for pattern in patterns:
358
+ regex = _glob_to_regex(pattern, descendants=descendants)
359
+ if regex is not None:
360
+ matchers.append(regex)
361
+ return tuple(matchers)
362
+
363
+
364
+ def _is_excluded(relative: str, matchers: tuple[_ExcludeMatcher, ...]) -> bool:
365
+ """Whether *relative* matches any exclude pattern."""
366
+ return any(regex.match(relative) for regex in matchers)
367
+
368
+
369
+ def _relative_posix(path: Path, root: Path) -> str:
370
+ """*path* relative to *root* as a posix string, for pattern matching.
371
+
372
+ *root* is a resolved directory (the config's), so the plain subtraction is
373
+ tried first; resolving is only needed when the scan root is a symlink.
374
+ """
375
+ try:
376
+ return path.relative_to(root).as_posix()
377
+ except ValueError:
378
+ pass
379
+ try:
380
+ return path.resolve().relative_to(root).as_posix()
381
+ except (OSError, ValueError):
382
+ return path.as_posix()
383
+
384
+
385
+ class FormatPathHandler:
386
+ """Handler for formatting paths."""
387
+
388
+ def __init__(self):
389
+ self.executor = FormatExecutor()
390
+
391
+ @staticmethod
392
+ def _as_path(path: object) -> Path:
393
+ """Normalise a path argument (``str`` or ``Path``-like) to ``Path``."""
394
+ if (
395
+ hasattr(path, "exists")
396
+ and hasattr(path, "is_file")
397
+ and hasattr(path, "is_dir")
398
+ and hasattr(path, "name")
399
+ ):
400
+ return cast("Path", path)
401
+ return Path(cast("str", path))
402
+
403
+ @staticmethod
404
+ def _is_discovered_file(path_obj: Path) -> bool:
405
+ """Whether a discovered ``.py`` file may be rewritten by the gate.
406
+
407
+ A symlink can point anywhere (including a virtualenv's sources), so
408
+ the walk never follows one (#187).
409
+ """
410
+ return path_obj.suffix == ".py" and not path_obj.is_symlink()
411
+
412
+ @staticmethod
413
+ def _is_named_python_file(path_obj: Path) -> bool:
414
+ """Whether an *explicitly named* path names a Python file to rewrite.
415
+
416
+ ``Path.is_file`` follows a symlink, and that is the point (#195): the
417
+ user named this path, ruff follows it too, and ``wl format <link>``
418
+ rewrote through it before #187 stopped the *walk* from following
419
+ links. ``wl format -s link.py`` silently doing nothing is the
420
+ regression this restores.
421
+ """
422
+ return path_obj.suffix == ".py"
423
+
424
+ @staticmethod
425
+ def _in_ignored_directory(path_obj: Path) -> bool:
426
+ """Whether any ancestor of an *explicit* path is in the ignore set."""
427
+ return any(_ignored_dir_name(part) for part in path_obj.parts[:-1])
428
+
429
+ def _get_all_python_files(self, path, env: dict | None = None):
430
+ """Get the Python files under *path* that ruff would actually format.
431
+
432
+ An explicit path is taken at face value; a directory is resolved by
433
+ ruff's own file resolution (#195) plus the filters ruff cannot express
434
+ downstream (see :meth:`_discovered_python_files`).
435
+ """
436
+ path_obj = self._as_path(path)
437
+ if not path_obj.exists():
438
+ return []
439
+ if path_obj.is_file():
440
+ if not self._is_named_python_file(path_obj):
441
+ return []
442
+ return [] if self._in_ignored_directory(path_obj) else [path_obj]
443
+ if not path_obj.is_dir() or _ignored_dir_name(path_obj.name):
444
+ return []
445
+ return self._discovered_python_files(path_obj, env)
446
+
447
+ def _discovered_python_files(
448
+ self, directory: Path, env: dict | None = None
449
+ ) -> list[Path]:
450
+ """The ``.py`` files under *directory* that ``wl format`` may rewrite.
451
+
452
+ The candidate set comes from ruff itself (:func:`_ruff_listed_files`),
453
+ which is the only thing that knows the *effective* base exclude:
454
+ built-in defaults, project config, nested configs and
455
+ ``respect-gitignore``. Three filters stay here because ``--show-files``
456
+ cannot apply them:
457
+
458
+ * the unconditional ignore set -- a project that writes its own
459
+ ``exclude`` replaces ruff's defaults outright, so ``.venv`` would
460
+ reappear (#187);
461
+ * symlinks -- ruff *does* list one inside a scanned directory, and it
462
+ may point into a virtualenv (#187);
463
+ * ``[tool.ruff.lint]`` / ``[tool.ruff.format] exclude`` -- ruff applies
464
+ those *after* the base file resolver ``--show-files`` reports, so
465
+ neither leg's own list is visible there (#195), and each file's
466
+ *nearest* config supplies them (#206).
467
+ """
468
+ listed = _ruff_listed_files(directory, env)
469
+ if listed is None:
470
+ # Ruff could not be asked; the local walk is still better than
471
+ # handing the directory over blindly (the failure was warned).
472
+ return list(self._iter_python_files(directory))
473
+
474
+ try:
475
+ root = directory.resolve()
476
+ except OSError:
477
+ root = directory
478
+ excludes = _RuffExcludes()
479
+
480
+ discovered: list[Path] = []
481
+ for absolute in listed:
482
+ relative = _relative_to_root(absolute, root)
483
+ if relative is None:
484
+ continue
485
+ candidate = directory / relative
486
+ if not self._is_discovered_file(candidate):
487
+ continue
488
+ if any(_ignored_dir_name(part) for part in relative.parts[:-1]):
489
+ continue
490
+ config_dir, _base, mode = excludes.for_directory(candidate.parent)
491
+ if mode and _is_excluded(_relative_posix(candidate, config_dir), mode):
492
+ continue
493
+ discovered.append(candidate)
494
+ return discovered
495
+
496
+ def _iter_python_files(self, directory: Path) -> Iterator[Path]:
497
+ """Walk *directory*, for when ruff cannot list the files itself.
498
+
499
+ Everything ruff's resolver would have applied has to be re-applied
500
+ here, from each file's own config: the base lists with their
501
+ directory semantics, the mode lists with ruff's file-path semantics.
502
+ """
503
+ excludes = _RuffExcludes()
504
+ for dirpath, dirnames, filenames in os.walk(directory, followlinks=False):
505
+ current = Path(dirpath)
506
+ config_dir, base, mode = excludes.for_directory(current)
507
+ # Base patterns are directory-aware, exactly as ruff's walker
508
+ # treats them: a matched directory is pruned with its contents.
509
+ # Mode lists never prune -- ruff matches them against file paths
510
+ # only (#206).
511
+ kept: list[str] = []
512
+ for name in sorted(dirnames):
513
+ if _ignored_dir_name(name):
514
+ continue
515
+ candidate = current / name
516
+ if base and _is_excluded(_relative_posix(candidate, config_dir), base):
517
+ continue
518
+ kept.append(name)
519
+ dirnames[:] = kept
520
+
521
+ for name in sorted(filenames):
522
+ if not name.endswith(".py"):
523
+ continue
524
+ candidate = current / name
525
+ if not self._is_discovered_file(candidate):
526
+ continue
527
+ relative = _relative_posix(candidate, config_dir)
528
+ if base and _is_excluded(relative, base):
529
+ continue
530
+ if mode and _is_excluded(relative, mode):
531
+ continue
532
+ yield candidate
533
+
534
+ def _step_by_step_processing(
535
+ self, paths, env, quiet, unsafe, for_lint: bool = False
536
+ ) -> FormatOutcome:
537
+ """Process files in step-by-step mode with parallel execution.
538
+
539
+ Step-by-step truncates at the first failure: a ruff/format failure
540
+ skips pre-commit entirely, and the *result* keeps only the first
541
+ failure. The executor aggregates every file (audit patch 2 / F3) --
542
+ truncation lives here, not there.
543
+
544
+ A directory named on the command line that is called ``rust`` also
545
+ goes through ``format_rust_code`` (#187): ``wl format -s rust`` used
546
+ to skip Rust entirely, so the step-by-step gate never judged it.
547
+
548
+ ``for_lint`` skips pre-commit (#180): ``wl lint`` owns that run
549
+ through ``LintExecutor.run_all``, so the format prefix must not judge
550
+ the same hooks a second time.
551
+ """
552
+ # Get all Python files from all specified paths
553
+ all_python_files: list[Path] = []
554
+ rust_dirs: list[Path] = []
555
+ for path in paths:
556
+ path_obj = self._as_path(path)
557
+ all_python_files.extend(self._get_all_python_files(path_obj, env))
558
+ if path_obj.is_dir() and path_obj.name == "rust":
559
+ rust_dirs.append(path_obj)
560
+
561
+ # Process files in parallel
562
+ failures: tuple[FormatFailure, ...] = ()
563
+ if all_python_files:
564
+ # Use parallel execution with caching
565
+ failures = self.executor.run_format_parallel(
566
+ all_python_files, env, quiet, unsafe
567
+ )
568
+
569
+ if failures:
570
+ # Step-by-step reports the first failure only (input order).
571
+ return FormatOutcome(failures[:1])
572
+
573
+ # An explicit ``rust`` path is formatted here too (#187).
574
+ for rust_dir in rust_dirs:
575
+ rust_failure = format_rust_code(str(rust_dir), env, quiet)
576
+ if rust_failure is not None:
577
+ return FormatOutcome((rust_failure,))
578
+
579
+ # Run pre-commit on all files after step-by-step processing is complete.
580
+ # A hook that *failed* (as opposed to merely auto-fixing) is a real
581
+ # failure and must make the step-by-step run red (#149). ``not
582
+ # for_lint`` alone decides: whether Python files were *discovered*
583
+ # must not gate the verdict, or a subdirectory run with none skips
584
+ # every hook and exits 0 (#187). ``wl lint`` runs pre-commit itself,
585
+ # so its prefix never lands here (#180).
586
+ if not for_lint:
587
+ pre_commit_failure = self._run_pre_commit_on_all_files(env, quiet)
588
+ if pre_commit_failure is not None:
589
+ return FormatOutcome((pre_commit_failure,))
590
+
591
+ return FormatOutcome()
592
+
593
+ def _non_step_by_step_processing(
594
+ self, paths, env, quiet, unsafe, for_lint: bool = False
595
+ ) -> tuple[FormatFailure, ...]:
596
+ """Process files in normal (non-step-by-step) mode.
597
+
598
+ Aggregates every failure -- ruff first, pre-commit last -- rather than
599
+ stopping at the first one (#164).
600
+
601
+ ``for_lint`` keeps pre-commit out of the ``wl lint`` prefix (#180): the
602
+ lint run owns that verdict.
603
+ """
604
+ failures: list[FormatFailure] = []
605
+
606
+ # Original behavior: process paths normally
607
+ for path in paths:
608
+ path_obj = self._as_path(path)
609
+
610
+ if path_obj.exists():
611
+ if path_obj.is_file() and path_obj.suffix == ".py":
612
+ failure = self.executor.format_python_file(
613
+ path_obj, path, env, quiet, unsafe
614
+ )
615
+ if failure is not None:
616
+ failures.append(failure)
617
+ elif path_obj.is_dir():
618
+ failures.extend(
619
+ self._format_directory(
620
+ path_obj, path, env, quiet, unsafe, for_lint
621
+ )
622
+ )
623
+ else:
624
+ if not quiet:
625
+ print(f"Warning: Path {path} does not exist")
626
+
627
+ return tuple(failures)
628
+
629
+ def format_specified_paths(
630
+ self, paths, env, quiet, unsafe=False, step_by_step=False, for_lint=False
631
+ ) -> FormatOutcome:
632
+ """Format specified paths and report what failed (#164).
633
+
634
+ ``for_lint`` is the ``wl lint`` prefix (#180): it still formats, but
635
+ the pre-commit verdict belongs to the lint run, so it is never taken
636
+ here.
637
+ """
638
+ if step_by_step:
639
+ return self._step_by_step_processing(paths, env, quiet, unsafe, for_lint)
640
+ return FormatOutcome(
641
+ self._non_step_by_step_processing(paths, env, quiet, unsafe, for_lint)
642
+ )
643
+
644
+ def _format_directory(
645
+ self, path_obj, original_path, env, quiet, unsafe, for_lint: bool = False
646
+ ) -> tuple[FormatFailure, ...]:
647
+ """Format a directory; returns ruff failures then the pre-commit one.
648
+
649
+ ``for_lint`` drops the pre-commit verdict: ``wl lint`` runs the hooks
650
+ once itself, so its format prefix only formats (#180).
651
+ """
652
+ failures = list(
653
+ format_with_python_tools(str(path_obj), env, quiet, unsafe=unsafe)
654
+ )
655
+
656
+ # Special handling for rust directory; the cargo verdict is a failure
657
+ # like any other, never a printed warning (#187).
658
+ if path_obj.name == "rust":
659
+ rust_failure = format_rust_code(str(path_obj), env, quiet)
660
+ if rust_failure is not None:
661
+ failures.append(rust_failure)
662
+
663
+ # Run pre-commit on the directory
664
+ if not for_lint:
665
+ pre_commit_failure = self._run_pre_commit_on_directory(
666
+ path_obj, original_path, env, quiet
667
+ )
668
+ if pre_commit_failure is not None:
669
+ failures.append(pre_commit_failure)
670
+ return tuple(failures)
671
+
672
+ def _get_pre_commit_config_path(self):
673
+ """Find the pre-commit config path.
674
+
675
+ Priority: the managed ``.wl/.pre-commit-config.yaml`` of the **DEPLOY**
676
+ root -> ``.pre-commit-config.yaml`` (fallback).
677
+
678
+ The self-sync writes the managed file at the *deploy* root (the git
679
+ work-tree top), so that is where this lookup anchors too (#189).
680
+ Anchoring it at the PROJECT root -- as it did before -- made the file
681
+ invisible whenever the project root is a sub-package below the git top:
682
+ the fallback was a CWD-relative path that does not exist either, so
683
+ every hook was skipped and ``wl format`` exited 0. The root anchor
684
+ still comes from the process CWD (:func:`current_root`), exactly as
685
+ #125 established; only the *kind* moved, and the shared locator owns
686
+ the path itself.
687
+ """
688
+ precommit_config_path = str(
689
+ managed_pre_commit_config(current_root(RootKind.PROJECT).path)
690
+ )
691
+ if not os.path.exists(precommit_config_path):
692
+ precommit_config_path = ".pre-commit-config.yaml"
693
+ return precommit_config_path
694
+
695
+ def _run_git_flow_hint(self, executor, env, quiet) -> None:
696
+ """Run git-flow check and display simplified output for AI agent."""
697
+ result = executor.run(
698
+ ["uv", "run", "wl", "git-flow", "check"], env=env, quiet=True
699
+ )
700
+ if result.stdout:
701
+ info = self._parse_git_flow_output(result.stdout)
702
+ self._print_git_flow_info(info)
703
+ if result.stderr and not quiet:
704
+ print(result.stderr)
705
+
706
+ def _parse_git_flow_output(self, output):
707
+ """Parse git-flow output into a dictionary."""
708
+ info = {}
709
+ lines = output.strip().split("\n")
710
+ for line in lines:
711
+ if ":" in line and '"' in line:
712
+ key_part, value_part = line.split(":", 1)
713
+ key = key_part.strip()
714
+ value = value_part.strip().strip('",')
715
+ info[key] = value
716
+ return info
717
+
718
+ def _print_git_flow_info(self, info):
719
+ """Print git-flow info in AI-agent-friendly format."""
720
+ status = info.get("status")
721
+ if not status or status == "OK":
722
+ return
723
+
724
+ output_lines = [f"Git Flow [{status}]"]
725
+ keys_to_print = [
726
+ ("branch", "branch"),
727
+ ("branch_type", "type"),
728
+ ("recommended_action", "action"),
729
+ ("source_branch", "source_branch"),
730
+ ("allowed_push", "allowed_push"),
731
+ ("rename_required", "rename_required"),
732
+ ("direct_push_allowed", "direct_push_allowed"),
733
+ ("merge_targets", "merge_targets"),
734
+ ("suggested_branch_patterns", "suggested_patterns"),
735
+ ("next_step", "next_step"),
736
+ ]
737
+
738
+ for key, label in keys_to_print:
739
+ value = info.get(key)
740
+ if value:
741
+ output_lines.append(f" {label}: {value}")
742
+
743
+ print("\n".join(output_lines))
744
+
745
+ def _run_pre_commit_command(
746
+ self, env, quiet, description=None, original_path=None
747
+ ) -> FormatFailure | None:
748
+ """Run pre-commit and report whether a hook judged the check failed.
749
+
750
+ Returns a :class:`FormatFailure` only when a hook *failed* -- the case
751
+ ``wl format`` has to propagate so the gate cannot go falsely green
752
+ (#149). A run whose only non-zero effect was auto-fixing files returns
753
+ ``None``: that is the expected behaviour of running formatters and must
754
+ stay green.
755
+
756
+ The reading of the run itself lives in
757
+ :class:`wl_cli.utils.pre_commit.PreCommitRun` (#165); this method only
758
+ chooses what format means by "no config" and how the result is shown.
759
+ """
760
+ from ...utils.subprocess_utils import SubprocessExecutor
761
+
762
+ try:
763
+ executor = SubprocessExecutor()
764
+ precommit_config_path = self._get_pre_commit_config_path()
765
+
766
+ # The single skip decision every format path reaches (#172): for
767
+ # ``wl format`` a missing managed config means "nothing to check",
768
+ # never a failure. ``_run_pre_commit_defaults`` used to repeat this
769
+ # check and own the only warning.
770
+ if not os.path.exists(precommit_config_path):
771
+ if not quiet:
772
+ print(
773
+ "Warning: .pre-commit-config.yaml not found in current directory or .wl directory, skipping pre-commit hooks"
774
+ )
775
+ return None
776
+
777
+ self._run_git_flow_hint(executor, env, quiet)
778
+ if not quiet and description:
779
+ print(description)
780
+
781
+ # The run's exit code is deliberately *not* propagated as-is:
782
+ # pre-commit also exits non-zero when a hook simply rewrote files.
783
+ run = run_pre_commit(
784
+ files=None,
785
+ config=Path(precommit_config_path),
786
+ cwd=Path.cwd(),
787
+ env=env,
788
+ quiet=True,
789
+ )
790
+ hooks_failed = run.hooks_failed
791
+
792
+ # Show the same blocks the verdict was computed from (#172), and
793
+ # read stderr as well: a run that failed through stderr alone used
794
+ # to be invisible here. ``filtered_output`` stays lint's view --
795
+ # text filtering can include auto-fix sections the verdict ignores.
796
+ if run.combined_output:
797
+ failed_blocks = run.failed_blocks
798
+ if failed_blocks:
799
+ for block in failed_blocks:
800
+ print("\n".join(block))
801
+ elif hooks_failed:
802
+ # Failed without any hook ``Failed`` block: pre-commit
803
+ # erroring before it could judge (e.g. an invalid config).
804
+ # Show what it said rather than claiming the hooks passed
805
+ # while exiting non-zero.
806
+ print(run.combined_output)
807
+ elif not quiet:
808
+ print("All pre-commit hooks passed.")
809
+
810
+ if not hooks_failed:
811
+ return None
812
+
813
+ return FormatFailure(
814
+ path=Path(original_path) if original_path else Path.cwd(),
815
+ kind="pre_commit",
816
+ message="pre-commit reported a failing hook",
817
+ details=self._pre_commit_failure_details(run),
818
+ )
819
+
820
+ except Exception as e:
821
+ # A hook that cannot be launched at all is a *warning*, not a hook
822
+ # verdict: keep the historical lenient behaviour here so a machine
823
+ # without pre-commit does not turn ``wl format`` red.
824
+ if not quiet:
825
+ if original_path:
826
+ print(f"Warning: Pre-commit failed for {original_path}: {e}")
827
+ else:
828
+ print(f"Warning: Pre-commit failed: {e}")
829
+ return None
830
+
831
+ def _pre_commit_failure_details(self, run: PreCommitRun) -> tuple[str, ...]:
832
+ """One detail entry per failing hook, already joined into a block.
833
+
834
+ Taken straight from ``run.failed_blocks``, which already excludes
835
+ auto-fix blocks for the same reason they are not failures (#149): they
836
+ only rewrote files. When pre-commit errored before printing any hook
837
+ verdict there are no blocks, so the raw output is the detail.
838
+ """
839
+ details = tuple("\n".join(block) for block in run.failed_blocks)
840
+ if details:
841
+ return details
842
+ output = run.combined_output.strip()
843
+ return (output,) if output else ()
844
+
845
+ def _run_pre_commit_on_all_files(self, env, quiet) -> FormatFailure | None:
846
+ """Run pre-commit on all files; failure when a hook judged failure."""
847
+ description = "\nRunning pre-commit on all files..."
848
+ return self._run_pre_commit_command(env, quiet, description)
849
+
850
+ def _run_pre_commit_on_directory(
851
+ self, path_obj, original_path, env, quiet
852
+ ) -> FormatFailure | None:
853
+ """Run pre-commit on a directory; failure when a hook judged failure."""
854
+ return self._run_pre_commit_command(env, quiet, original_path=original_path)
855
+
856
+ def _step_by_step_defaults(
857
+ self, current_path: Path, env: dict, quiet: bool, unsafe: bool, for_lint: bool
858
+ ) -> FormatOutcome:
859
+ """Process default paths in step-by-step mode with parallel execution.
860
+
861
+ The rust branch matches the non-step-by-step default path (#180): it
862
+ used to be missing here, so ``wl format -s`` -- the documented
863
+ pre-commit quality gate -- never formatted ``rust/``.
864
+ """
865
+ src_path = current_path / "src"
866
+ tools_path = current_path / "tools"
867
+ tests_path = current_path / "tests"
868
+
869
+ # Get all Python files from all default paths
870
+ all_python_files = []
871
+ for path in [src_path, tools_path, tests_path]:
872
+ if path.exists():
873
+ python_files = self._get_all_python_files(path, env)
874
+ all_python_files.extend(python_files)
875
+
876
+ # Process files in parallel
877
+ failures: tuple[FormatFailure, ...] = ()
878
+ if all_python_files:
879
+ # Use parallel execution with caching
880
+ failures = self.executor.run_format_parallel(
881
+ all_python_files, env, quiet, unsafe
882
+ )
883
+
884
+ if failures:
885
+ # Step-by-step reports the first failure only (input order).
886
+ return FormatOutcome(failures[:1])
887
+
888
+ # Same rust branch as the non-step-by-step default path (#180); the
889
+ # cargo verdict is folded into the outcome instead of being dropped
890
+ # (#187).
891
+ rust_failure = self._format_rust_if_present(current_path, env, quiet, for_lint)
892
+ if rust_failure is not None:
893
+ return FormatOutcome((rust_failure,))
894
+
895
+ # Run pre-commit on all files after step-by-step processing is complete.
896
+ # A hook that *failed* (not merely auto-fixed) makes the run red (#149).
897
+ # ``not for_lint`` alone decides (#187): discovery results no longer
898
+ # gate the verdict, so a run with no Python files still judges hooks.
899
+ # ``wl lint`` runs pre-commit itself, so its prefix skips it (#180).
900
+ if not for_lint:
901
+ pre_commit_failure = self._run_pre_commit_on_all_files(env, quiet)
902
+ if pre_commit_failure is not None:
903
+ return FormatOutcome((pre_commit_failure,))
904
+
905
+ return FormatOutcome()
906
+
907
+ def _format_rust_if_present(
908
+ self, current_path: Path, env: dict, quiet: bool, for_lint: bool
909
+ ) -> FormatFailure | None:
910
+ """Format ``rust/`` under *current_path* when it exists and not linting.
911
+
912
+ Both default paths -- step-by-step and not -- go through this one
913
+ condition, so the two cannot drift apart (#180). ``wl lint`` skips it:
914
+ Rust formatting is not what lint judges (the non-step path always did).
915
+ A failing ``cargo fmt`` is returned so the caller can fold it into
916
+ ``FormatOutcome`` (#187).
917
+ """
918
+ rust_path = current_path / "rust"
919
+ if rust_path.exists() and not for_lint:
920
+ if not quiet:
921
+ log_info_pair("Formatting Rust code...", "正在格式化 Rust 代码...")
922
+ return format_rust_code(str(rust_path), env, quiet)
923
+ return None
924
+
925
+ def _process_python_directory(
926
+ self,
927
+ path: Path,
928
+ env: dict,
929
+ quiet: bool,
930
+ unsafe: bool,
931
+ description: tuple[str, str],
932
+ ) -> tuple[FormatFailure, ...]:
933
+ """Process a single Python directory if it exists."""
934
+ if path.exists():
935
+ if not quiet:
936
+ # Spelled out rather than ``*description`` so the ledger can
937
+ # register the pair site by name (#187).
938
+ log_info_pair(description[0], description[1])
939
+ return format_with_python_tools(str(path), env, quiet, unsafe=unsafe)
940
+ return ()
941
+
942
+ def _non_step_by_step_defaults(
943
+ self, current_path: Path, env: dict, quiet: bool, unsafe: bool, for_lint: bool
944
+ ) -> tuple[FormatFailure, ...]:
945
+ """Process default paths in normal (non-step-by-step) mode.
946
+
947
+ Collects every ruff failure (src/tools/tests) then the pre-commit
948
+ verdict, in that order (#164).
949
+ """
950
+ failures: list[FormatFailure] = []
951
+
952
+ # Process standard Python directories
953
+ directories = [
954
+ (
955
+ current_path / "src",
956
+ ("Formatting src directory...", "正在格式化 src 目录..."),
957
+ ),
958
+ (
959
+ current_path / "tools",
960
+ ("Formatting tools directory...", "正在格式化 tools 目录..."),
961
+ ),
962
+ (
963
+ current_path / "tests",
964
+ ("Formatting tests directory...", "正在格式化 tests 目录..."),
965
+ ),
966
+ ]
967
+
968
+ for path, description in directories:
969
+ failures.extend(
970
+ self._process_python_directory(path, env, quiet, unsafe, description)
971
+ )
972
+
973
+ # Type stubs generation removed from format; handled in build dist
974
+ rust_failure = self._format_rust_if_present(current_path, env, quiet, for_lint)
975
+ if rust_failure is not None:
976
+ failures.append(rust_failure)
977
+
978
+ # Run pre-commit on all files -- except for the ``wl lint`` prefix,
979
+ # whose pre-commit run belongs to ``LintExecutor.run_all`` (#180).
980
+ if not for_lint:
981
+ pre_commit_failure = self._run_pre_commit_defaults(env, quiet)
982
+ if pre_commit_failure is not None:
983
+ failures.append(pre_commit_failure)
984
+ return tuple(failures)
985
+
986
+ def format_defaults(
987
+ self,
988
+ current_path: Path,
989
+ env: dict,
990
+ quiet: bool,
991
+ unsafe: bool,
992
+ for_lint: bool,
993
+ step_by_step=False,
994
+ ) -> FormatOutcome:
995
+ """Format default paths and report what failed (#164).
996
+
997
+ ``for_lint`` marks the ``wl lint`` format prefix (#180): it formats,
998
+ but leaves pre-commit and Rust to the lint run.
999
+ """
1000
+ if step_by_step:
1001
+ return self._step_by_step_defaults(
1002
+ current_path, env, quiet, unsafe, for_lint
1003
+ )
1004
+ return FormatOutcome(
1005
+ self._non_step_by_step_defaults(current_path, env, quiet, unsafe, for_lint)
1006
+ )
1007
+
1008
+ def _run_pre_commit_defaults(self, env: dict, quiet: bool) -> FormatFailure | None:
1009
+ """Run pre-commit for default formatting mode.
1010
+
1011
+ Returns a failure when a hook judged the check failed. The missing-
1012
+ config skip, its warning, and the one "Running pre-commit" message all
1013
+ live in :meth:`_run_pre_commit_command`, the single choke point every
1014
+ format entry path reaches (#172).
1015
+ """
1016
+ return self._run_pre_commit_on_all_files(env, quiet)