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,579 @@
1
+ """Lint reporter module.
2
+ Handles report generation for lint command.
3
+
4
+ Rendering only (#170): :meth:`LintReporter.render` reads a finished
5
+ :class:`~wl_cli.commands.lint.lint_run.LintRun` and writes the report files.
6
+ It starts no child process -- the executor has already run each tool exactly
7
+ once -- so the content cannot disagree with the verdict, and the number of
8
+ executions is visible in one place.
9
+
10
+ Freshness contract (#200): every report file :meth:`LintReporter.render`
11
+ writes carries a ``- Generated at: <ISO-8601>`` line naming the run that wrote
12
+ it. Two rounds over the same scope with the same findings therefore differ in
13
+ that line even though every other byte is identical -- before this, a report
14
+ could only be shown to be "this run's" by asserting the run wrote it, and two
15
+ such reports compared byte-for-byte. The stamp is computed once per
16
+ :meth:`render` call, so every file of one run names the same moment. It is a
17
+ timestamp rather than a random id so the report also answers *when* it was
18
+ produced; two runs inside the same microsecond would share it, which is the
19
+ one residual ambiguity in the contract.
20
+ """
21
+
22
+ import re
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+
26
+ from ...utils.logging import log_info_pair
27
+ from ...utils.pre_commit import PreCommitRun
28
+ from .lint_run import LintRun, RuffBlock, display_path
29
+
30
+ #: The mypy scopes ``error.md`` reports on separately. One mypy run covers
31
+ #: both; the output is split by these path prefixes.
32
+ _MYPY_SCOPES = ("src/", "tests/")
33
+
34
+
35
+ def _run_stamp(generated_at: datetime | None = None) -> str:
36
+ """The run stamp a report carries: ``generated_at`` or the current moment.
37
+
38
+ Microseconds, not seconds, are what makes two consecutive runs
39
+ distinguishable; the offset keeps the stamp unambiguous when the report
40
+ travels between machines (#200).
41
+
42
+ The only clock read this module performs: every report of one ``render``
43
+ call is passed the string it returns, so the files cannot disagree about
44
+ the moment. A naive ``generated_at`` is read as local wall time and given
45
+ the local offset (LOW-1) rather than emitting an offset-less ISO string
46
+ that reads as UTC to a distant reader.
47
+ """
48
+ moment = generated_at if generated_at is not None else datetime.now().astimezone()
49
+ if moment.tzinfo is None:
50
+ moment = moment.astimezone()
51
+ return moment.isoformat(timespec="microseconds")
52
+
53
+
54
+ class LintReporter:
55
+ """Reporter for lint command."""
56
+
57
+ def render(self, run: LintRun, *, generated_at: datetime | None = None) -> None:
58
+ """Write every lint report from *run*.
59
+
60
+ The files and their locations are unchanged: ``todos/lint_report.md``,
61
+ ``todos/error.md``, and ``todos/to_packaging_modularization.md`` when
62
+ oversized modules exist.
63
+
64
+ #200: each file names this run with a ``- Generated at:`` line. The
65
+ moment is read once here, so all files of one run agree; callers pass
66
+ nothing in production. ``generated_at`` exists for tests: the committed
67
+ snapshots pin everything except the clock, which would otherwise make
68
+ them unrecordable and would hide the very line the freshness contract
69
+ is about.
70
+ """
71
+ stamp = _run_stamp(generated_at)
72
+
73
+ # Create todos directory in current working directory
74
+ todos_dir = Path("todos")
75
+ todos_dir.mkdir(exist_ok=True)
76
+
77
+ report_file = todos_dir / "lint_report.md"
78
+ error_file = todos_dir / "error.md"
79
+
80
+ paths = list(run.paths) or None
81
+ large_files = self._find_large_python_files(run.project_root, paths)
82
+
83
+ # Generate main lint report content
84
+ lint_report_content = self._generate_lint_report(run, large_files, stamp=stamp)
85
+ self._write_report_file(report_file, lint_report_content)
86
+
87
+ # Generate error report content
88
+ error_report_content = self._generate_error_report(run, stamp=stamp)
89
+ self._write_report_file(error_file, error_report_content)
90
+
91
+ # Handle large files report if needed
92
+ if large_files:
93
+ self._generate_packaging_todo(
94
+ large_files, run.project_root, run.quiet, stamp=stamp
95
+ )
96
+
97
+ # Log report generation messages
98
+ if not run.quiet:
99
+ log_info_pair(
100
+ f"Lint report generated at {report_file}",
101
+ f"静态检查报告已生成到 {report_file}",
102
+ )
103
+ log_info_pair(
104
+ f"Error report generated at {error_file}",
105
+ f"错误报告已生成到 {error_file}",
106
+ )
107
+
108
+ def issue_text(self, run: LintRun) -> str:
109
+ """The ruff findings as terminal text, exactly as ruff rendered them.
110
+
111
+ The one ruff run is asked for ``--output-format full`` (#170 review
112
+ F1), so its stdout is already the text the old reporter printed. A
113
+ previous cut captured JSON here and re-rendered one concise line per
114
+ finding; that is what shrank the report (and this terminal view) away
115
+ from ruff's snippets, carets and ``help:`` lines.
116
+ """
117
+ return run.ruff.stdout
118
+
119
+ def _render_pre_commit(self, pre_commit: PreCommitRun | None) -> str:
120
+ """Render the pre-commit section from the already-finished run.
121
+
122
+ The verdict, the blocks and the filtered text all come from
123
+ :class:`PreCommitRun` (#165); this method only chooses the wording.
124
+ """
125
+ if pre_commit is None:
126
+ return "pre-commit was not run."
127
+ if pre_commit.result.launch_error is not None:
128
+ # ``launch_error`` covers every way the launcher can raise -- a
129
+ # missing executable, but also a malformed environment or an
130
+ # undecodable stream -- so name the failure instead of guessing
131
+ # "not installed" (#165/#172).
132
+ return f"pre-commit could not be started: {pre_commit.result.launch_error}"
133
+
134
+ # A non-zero exit with no hook verdict is pre-commit itself failing
135
+ # (e.g. an invalid config), not "nothing to report" (#165 patch 4).
136
+ filtered = pre_commit.filtered_output
137
+ if filtered:
138
+ return filtered
139
+ if pre_commit.hooks_failed:
140
+ return pre_commit.combined_output.strip()
141
+ return "No pre-commit issues found."
142
+
143
+ def _render_ruff_sections(self, run: LintRun) -> list[str]:
144
+ """Render the per-directory ruff sections from the one verbatim run.
145
+
146
+ Each diagnostic block is ruff's own text; only its placement changes.
147
+ The run's tail -- ruff's summary, with its own ``[*]`` fixable count
148
+ and ``--unsafe-fixes`` hint -- is rendered once, after every section: a
149
+ single invocation can only produce one summary (#170 review F2/F5).
150
+ """
151
+ sections = self._existing_sections(run.project_root, list(run.paths) or None)
152
+ parsed = run.ruff_output
153
+ if parsed.empty:
154
+ return []
155
+
156
+ grouped: dict[str, list[RuffBlock]] = {title: [] for title, _ in sections}
157
+ ungrouped: list[RuffBlock] = []
158
+ for block in parsed.blocks:
159
+ title = _enclosing_section(block.path, sections, run.project_root)
160
+ if title is None:
161
+ ungrouped.append(block)
162
+ else:
163
+ grouped[title].append(block)
164
+
165
+ lines: list[str] = []
166
+ for title, _ in sections:
167
+ lines.extend(self._render_ruff_section(title, grouped[title]))
168
+ # Findings outside every section (a root-level module, say) used to be
169
+ # dropped from the report while still failing the run. Keep them, under
170
+ # the same "." the header uses for the default scope.
171
+ if ungrouped:
172
+ lines.extend(self._render_ruff_section(".", ungrouped))
173
+ if parsed.tail:
174
+ lines.extend(self._render_ruff_summary(parsed.tail))
175
+ return lines
176
+
177
+ def _render_ruff_section(self, title: str, blocks: list[RuffBlock]) -> list[str]:
178
+ """One ``## Issues in <title>`` fence, holding ruff's blocks verbatim."""
179
+ if not blocks:
180
+ return []
181
+ body = "\n\n".join(block.text for block in blocks)
182
+ return [
183
+ f"## Issues in {title}\n\n",
184
+ "```\n",
185
+ body + "\n",
186
+ "```\n\n",
187
+ ]
188
+
189
+ def _render_ruff_summary(self, tail: tuple[str, ...]) -> list[str]:
190
+ """ruff's own closing lines: totals, fixable count, unsafe hint."""
191
+ return [
192
+ "## Ruff Summary\n\n",
193
+ "```\n",
194
+ "\n".join(tail) + "\n",
195
+ "```\n\n",
196
+ ]
197
+
198
+ @staticmethod
199
+ def _start_section(lines: list[str], heading: str) -> None:
200
+ """Append *heading* with exactly one blank line before it (#170 F10)."""
201
+ while lines and lines[-1] == "":
202
+ lines.pop()
203
+ if lines:
204
+ text = "".join(lines)
205
+ if not text.endswith("\n\n"):
206
+ lines.append("\n" if text.endswith("\n") else "\n\n")
207
+ lines.append(heading)
208
+
209
+ def _existing_sections(
210
+ self, project_root: Path, paths: list[str] | None
211
+ ) -> list[tuple[str, Path]]:
212
+ return [
213
+ (title, path)
214
+ for title, path in self._resolve_sections(project_root, paths)
215
+ if path.exists()
216
+ ]
217
+
218
+ def _find_large_python_files(
219
+ self, project_root: Path, paths: list[str] | None = None
220
+ ) -> list[tuple[Path, int]]:
221
+ search_paths = self._build_search_paths(project_root, paths)
222
+ results: list[tuple[Path, int]] = []
223
+ for sp in search_paths:
224
+ for py_file in self._iter_python_files(sp):
225
+ count = self._count_lines(py_file)
226
+ if count is not None and count > 200:
227
+ results.append((py_file, count))
228
+ results.sort(key=lambda x: x[1], reverse=True)
229
+ return results
230
+
231
+ def _build_search_paths(
232
+ self, project_root: Path, paths: list[str] | None
233
+ ) -> list[Path]:
234
+ if paths:
235
+ resolved: list[Path] = []
236
+ for p in paths:
237
+ path = Path(p)
238
+ if not path.is_absolute():
239
+ path = project_root / path
240
+ if path.exists():
241
+ resolved.append(path)
242
+ return resolved
243
+ base = [project_root / "src"]
244
+ t = project_root / "tools"
245
+ e = project_root / "examples"
246
+ if t.exists():
247
+ base.append(t)
248
+ if e.exists():
249
+ base.append(e)
250
+ return base
251
+
252
+ def _iter_python_files(self, path: Path):
253
+ if not path.exists():
254
+ return []
255
+ if path.is_file() and path.suffix == ".py":
256
+ return [path]
257
+ if path.is_dir():
258
+ return [p for p in path.rglob("*.py") if "__pycache__" not in str(p)]
259
+ return []
260
+
261
+ def _count_lines(self, file_path: Path) -> int | None:
262
+ try:
263
+ with open(file_path, encoding="utf-8") as f:
264
+ return sum(1 for _ in f)
265
+ except Exception:
266
+ return None
267
+
268
+ def _build_header(
269
+ self,
270
+ quiet: bool,
271
+ fix: bool,
272
+ paths: list[str] | None,
273
+ *,
274
+ stamp: str,
275
+ ) -> list[str]:
276
+ """The report's header. ``stamp`` is required: rendering a file never
277
+ reads the clock itself, it only carries the moment :meth:`render`
278
+ settled on (LOW-2 -- the ``stamp or _run_stamp()`` fallback let a
279
+ private helper start a second clock inside one run)."""
280
+ lines: list[str] = []
281
+ lines.append("# Lint Report\n\n")
282
+ lines.append(f"- Generated at: {stamp}\n\n")
283
+ lines.append("## Configuration\n\n")
284
+ lines.append(f"- Quiet mode: {quiet}\n")
285
+ lines.append(f"- Fix enabled: {fix}\n")
286
+ lines.append(f"- Paths: {', '.join(paths) if paths else '.'}\n\n")
287
+ return lines
288
+
289
+ def _resolve_sections(
290
+ self, project_root: Path, paths: list[str] | None
291
+ ) -> list[tuple[str, Path]]:
292
+ """The report's per-directory sections, in the order they render.
293
+
294
+ Section rule (#172): the user's ``paths`` are the sections when any
295
+ were given -- each title is the path exactly as typed. With no paths
296
+ the default scope is ``src/``, ``tools/`` and ``examples/``. A finding
297
+ that belongs to no section -- ``tests/`` under that default scope, or
298
+ a root-level module -- is rendered under ``.`` by
299
+ :meth:`_render_ruff_sections`. :meth:`_existing_sections` then drops
300
+ sections whose directory does not exist, so a fresh project with no
301
+ ``tools/`` renders no empty section.
302
+ """
303
+ if paths:
304
+ items: list[tuple[str, Path]] = []
305
+ for p in paths:
306
+ path = Path(p)
307
+ if not path.is_absolute():
308
+ path = project_root / path
309
+ items.append((p, path))
310
+ return items
311
+ return [
312
+ ("src/", project_root / "src"),
313
+ ("tools/", project_root / "tools"),
314
+ ("examples/", project_root / "examples"),
315
+ ]
316
+
317
+ def _generate_lint_report(
318
+ self,
319
+ run: LintRun,
320
+ large_files: list[tuple[Path, int]],
321
+ *,
322
+ stamp: str,
323
+ ) -> list[str]:
324
+ """Generate the main lint report content from the run's one stamp."""
325
+ paths = list(run.paths) or None
326
+ lines = self._build_header(run.quiet, run.fix, paths, stamp=stamp)
327
+
328
+ lines.extend(self._render_ruff_sections(run))
329
+ parsed_ruff = run.ruff_output
330
+
331
+ # Pre-commit results, from the run the executor already finished
332
+ self._start_section(lines, "## Pre-commit Dry Run Results\n\n")
333
+ lines.append("```\n")
334
+ lines.append(self._render_pre_commit(run.pre_commit))
335
+ lines.append("```\n")
336
+
337
+ # MyPy results (one run over ``src/ tests/``)
338
+ self._start_section(lines, "## MyPy Results\n\n")
339
+ mypy_result = run.mypy
340
+ if mypy_result is None:
341
+ lines.append("mypy was not run.\n")
342
+ elif mypy_result.stdout:
343
+ lines.append("```\n")
344
+ lines.append(mypy_result.stdout)
345
+ lines.append("```\n")
346
+ if run.mypy_failure is not None:
347
+ # ``rc >= 2`` can print partial diagnostics before dying; the
348
+ # fenced stdout alone would read as an ordinary type-error
349
+ # listing, so keep the verdict next to it (#172 r1 review F1).
350
+ lines.append(run.mypy_failure + "\n")
351
+ elif run.mypy_failure is not None:
352
+ # A mypy that never produced a verdict is a failure, not "clean":
353
+ # the old shape answered "No MyPy issues found." to a run that
354
+ # could not even find its config (#170 review F4).
355
+ lines.append(run.mypy_failure + "\n")
356
+ else:
357
+ lines.append("No MyPy issues found.\n")
358
+ if mypy_result is not None and mypy_result.stderr:
359
+ self._start_section(lines, "## MyPy Errors\n\n")
360
+ lines.append("```\n")
361
+ lines.append(mypy_result.stderr)
362
+ lines.append("```\n")
363
+
364
+ mypy_output = "" if mypy_result is None else (mypy_result.stdout or "")
365
+ if (
366
+ not run.issues_found
367
+ and not large_files
368
+ and not mypy_output
369
+ and run.mypy_failure is None
370
+ ):
371
+ lines.append("No lint issues found.\n")
372
+ # ruff's stderr used to be rendered only when there were no findings at
373
+ # all, so a run that emitted diagnostics *and* a warning lost the
374
+ # warning (#170 review F8). Keep it whenever ruff actually produced
375
+ # findings or produced nothing at all; a clean run's config warnings
376
+ # stay out of the report, as they did before.
377
+ if run.ruff.stderr.strip() and (parsed_ruff.blocks or parsed_ruff.empty):
378
+ self._start_section(lines, "## Errors\n\n")
379
+ lines.append("```\n")
380
+ lines.append(run.ruff.stderr)
381
+ lines.append("```\n")
382
+
383
+ return lines
384
+
385
+ def _generate_error_report(self, run: LintRun, *, stamp: str) -> list[str]:
386
+ """Generate the per-scope mypy error report from the single run.
387
+
388
+ ``src/`` and ``tests/`` used to be two mypy invocations; now the one
389
+ run's output is split by path prefix. Only scopes that carry output get
390
+ a section -- the old report printed a section only when that scope's
391
+ run said something, so an empty scope never rendered an "No issues
392
+ found." placeholder (#170 review F7). The one run's totals stay in a
393
+ tail block because they cannot be attributed to either scope.
394
+ """
395
+ error_lines = ["# MyPy Error Report\n\n"]
396
+ error_lines.append(f"- Generated at: {stamp}\n\n")
397
+ mypy_result = run.mypy
398
+ if mypy_result is None:
399
+ error_lines.append("mypy was not run.\n")
400
+ return error_lines
401
+
402
+ failure = run.mypy_failure
403
+ if failure is not None:
404
+ error_lines.append(f"{failure}\n\n")
405
+
406
+ grouped, tail = _split_mypy_output(mypy_result.stdout or "", run.project_root)
407
+
408
+ for scope in _MYPY_SCOPES:
409
+ body = grouped[scope]
410
+ if not body:
411
+ continue
412
+ error_lines.append(f"## MyPy Results for {scope}\n\n")
413
+ error_lines.append("```\n")
414
+ error_lines.append("\n".join(body) + "\n")
415
+ error_lines.append("```\n\n")
416
+
417
+ # The one run's summary ("Found N errors in M files ...") cannot be
418
+ # attributed to either scope; keep it rather than dropping the totals.
419
+ if tail:
420
+ error_lines.append("```\n")
421
+ error_lines.append("\n".join(tail) + "\n")
422
+ error_lines.append("```\n")
423
+
424
+ if mypy_result.stderr:
425
+ self._start_section(error_lines, "## MyPy Errors\n\n")
426
+ error_lines.append("```\n")
427
+ error_lines.append(mypy_result.stderr)
428
+ error_lines.append("```\n")
429
+
430
+ return error_lines
431
+
432
+ def _write_report_file(self, file_path: Path, content: list[str]) -> None:
433
+ """Write report content to the specified file."""
434
+ with open(file_path, "w", encoding="utf-8") as f:
435
+ f.writelines(content)
436
+
437
+ def _generate_packaging_todo(
438
+ self,
439
+ large_files: list[tuple[Path, int]],
440
+ project_root: Path,
441
+ quiet: bool,
442
+ *,
443
+ stamp: str,
444
+ ) -> None:
445
+ """
446
+ 生成用于打包模块化的待办事项报告(``stamp`` 必填:一次 render 只读一次时钟)
447
+ """
448
+ # Create todos directory in current working directory
449
+ todos_dir = Path("todos")
450
+ todos_dir.mkdir(exist_ok=True)
451
+
452
+ packaging_report_file = todos_dir / "to_packaging_modularization.md"
453
+
454
+ lines: list[str] = []
455
+ lines.append("# To Packaging Modularization\n\n")
456
+ lines.append(f"- Generated at: {stamp}\n\n")
457
+ lines.append("## Large Python Files (>200 lines)\n\n")
458
+ lines.append(
459
+ "These files are candidates for modularization due to their size.\n\n"
460
+ )
461
+ lines.append("| File Path | Line Count |\n")
462
+ lines.append("|-----------|------------|\n")
463
+ for file_path, line_count in large_files:
464
+ # 将路径转换为相对于项目根目录的路径
465
+ relative_path = file_path.relative_to(project_root)
466
+ lines.append(f"| {relative_path} | {line_count} |\n")
467
+ lines.append("\n")
468
+
469
+ with open(packaging_report_file, "w", encoding="utf-8") as f:
470
+ f.writelines(lines)
471
+
472
+ if not quiet:
473
+ log_info_pair(
474
+ f"Packaging todo list generated at {packaging_report_file}",
475
+ f"打包模块化待办列表已生成到 {packaging_report_file}",
476
+ )
477
+
478
+
479
+ def _enclosing_section(
480
+ path: str,
481
+ sections: list[tuple[str, Path]],
482
+ project_root: Path,
483
+ ) -> str | None:
484
+ """The title of the section *path* belongs to, or ``None``.
485
+
486
+ ``None`` means the finding is outside every section; the caller renders
487
+ those under ``.`` -- which is why a ``tests/`` finding (not one of the
488
+ default sections, see :meth:`LintReporter._resolve_sections`) still
489
+ appears in the report instead of being dropped. When sections overlap
490
+ (``src`` and ``src/pkg``) the longest matching prefix wins, so each
491
+ finding is shown once, in its most specific section. Both sides are
492
+ resolved for the same symlinked-root reason as :func:`display_path`.
493
+ """
494
+ candidate = Path(path)
495
+ if not candidate.is_absolute():
496
+ candidate = project_root / candidate
497
+ try:
498
+ resolved = candidate.resolve()
499
+ except OSError:
500
+ resolved = candidate
501
+
502
+ best: tuple[str, Path] | None = None
503
+ for title, section in sections:
504
+ try:
505
+ section_resolved = section.resolve()
506
+ except OSError:
507
+ section_resolved = section
508
+ if resolved == section_resolved or section_resolved in resolved.parents:
509
+ if best is None or len(section_resolved.parts) > len(best[1].parts):
510
+ best = (title, section_resolved)
511
+ return None if best is None else best[0]
512
+
513
+
514
+ #: mypy's diagnostic prefix: ``path:line: error:``, ``path:line:col: error:``
515
+ #: or ``path: note:``. Diagnostic lines start at column 0; continuation lines
516
+ #: (wrapped message, snippet, caret) are indented, which is what keeps a source
517
+ #: line that happens to contain ``error:`` from being read as a new diagnostic.
518
+ _MYPY_DIAGNOSTIC = re.compile(
519
+ r"^(?P<path>\S.*?):(?:(?P<row>\d+):(?:(?P<column>\d+):)?)?\s*"
520
+ r"(?P<severity>error|note|warning):"
521
+ )
522
+
523
+
524
+ def _split_mypy_output(
525
+ output: str, project_root: Path
526
+ ) -> tuple[dict[str, list[str]], list[str]]:
527
+ """Split one mypy run's output into the ``src/`` and ``tests/`` scopes.
528
+
529
+ mypy prints one diagnostic per prefixed line, followed by continuation
530
+ lines -- wrapped messages, source snippets, carets -- that carry no prefix
531
+ of their own. Those belong to the diagnostic above them, so the split
532
+ tracks the target of the last *diagnostic* line rather than classifying
533
+ every line on its own.
534
+
535
+ A diagnostic whose path is not under either scope (``tools/helper.py``,
536
+ an absolute path outside the project, or mypy's own ``mypy: error: ...``)
537
+ starts a tail target of its own. The previous version only looked at the
538
+ last scope it had seen, so such a line was appended to the *previous*
539
+ scope's section -- and an absolute path under ``src/`` was classified as
540
+ "outside", leaving both sections saying "No issues found." while the
541
+ finding hid in the tail (#170 review F6).
542
+ """
543
+ grouped: dict[str, list[str]] = {scope: [] for scope in _MYPY_SCOPES}
544
+ tail: list[str] = []
545
+ current: list[str] | None = None
546
+ for line in (output or "").splitlines():
547
+ if line.startswith(("Found ", "Success:")):
548
+ current = tail
549
+ tail.append(line)
550
+ continue
551
+ match = _MYPY_DIAGNOSTIC.match(line)
552
+ if match is not None:
553
+ path = match.group("path")
554
+ scope = _mypy_scope(path, project_root)
555
+ current = tail if scope is None else grouped[scope]
556
+ current.append(line)
557
+ continue
558
+ if current is None:
559
+ tail.append(line)
560
+ else:
561
+ current.append(line)
562
+ return grouped, tail
563
+
564
+
565
+ def _mypy_scope(path: str, project_root: Path) -> str | None:
566
+ """The scope whose path prefix covers *path*, or ``None``.
567
+
568
+ Absolute paths are expressed relative to *project_root* first (with both
569
+ sides resolved, so a symlinked root such as ``/tmp`` still matches), then
570
+ the same prefix rule the report uses applies to either form.
571
+ """
572
+ shown = display_path(path, project_root).replace("\\", "/")
573
+ while shown.startswith("./"):
574
+ shown = shown[2:]
575
+ for scope in _MYPY_SCOPES:
576
+ name = scope.rstrip("/")
577
+ if shown == name or shown.startswith(scope):
578
+ return scope
579
+ return None