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,955 @@
1
+ """Coverage analyzer module for analyzing test coverage and generating improvement suggestions."""
2
+
3
+ import ast
4
+ import configparser
5
+ import fnmatch
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import tempfile
10
+ from collections.abc import Iterator
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import tomllib
16
+
17
+ from .test_utils import _probe_pytest_cov
18
+
19
+ # pytest's default test-module patterns: a basename matching neither can never
20
+ # be collected as a test module, whatever symbols it defines.
21
+ _PYTEST_PYTHON_FILES = ("test_*.py", "*_test.py")
22
+
23
+ # Files pytest reads for its configuration, in pytest's own precedence order.
24
+ _PYTEST_CONFIG_FILES = ("pytest.ini", "pyproject.toml", "tox.ini", "setup.cfg")
25
+
26
+ # Directories whose contents can never be coverage inputs (or sources): tool
27
+ # caches and virtualenvs are neither executed by pytest nor part of the tree.
28
+ _PRUNED_DIRECTORY_NAMES = frozenset(
29
+ {
30
+ ".git",
31
+ ".mypy_cache",
32
+ ".pytest_cache",
33
+ ".ruff_cache",
34
+ ".tox",
35
+ ".venv",
36
+ "__pycache__",
37
+ "build",
38
+ "dist",
39
+ "node_modules",
40
+ "venv",
41
+ }
42
+ )
43
+
44
+
45
+ def _read_pytest_options(project_root: Path) -> dict[str, list[str]]:
46
+ """Read ``testpaths`` from the config file pytest itself would load.
47
+
48
+ Follows pytest's precedence (``pytest.ini`` > ``pyproject.toml`` >
49
+ ``tox.ini`` > ``setup.cfg``) and stops at the first file that carries a
50
+ pytest config section, whether or not that file sets ``testpaths``. That
51
+ is the file pytest reads, so a lower-priority ``testpaths`` is ignored by
52
+ the test run itself and must not steer coverage roots either (rv237
53
+ MEDIUM-1). Unreadable or unparsable configs are treated as absent: a
54
+ broken config must not stop a coverage run from reporting.
55
+
56
+ Only ``testpaths`` is read, because it is the only option this subsystem
57
+ consumes (:func:`pytest_test_roots`). Filename-collection patterns are not
58
+ parsed here: the source scanner uses pytest's fixed defaults, never a
59
+ configured pattern, so reading them would be dead data (rv192 NIT-1).
60
+ """
61
+ for filename in _PYTEST_CONFIG_FILES:
62
+ config_path = project_root / filename
63
+ if not config_path.is_file():
64
+ continue
65
+ options = _read_pytest_options_from(config_path)
66
+ if options is not None:
67
+ return options
68
+ return {}
69
+
70
+
71
+ def _read_pytest_options_from(config_path: Path) -> dict[str, list[str]] | None:
72
+ """Parse ``testpaths`` from one config file; ``None`` if it is not a pytest config.
73
+
74
+ ``{}`` means the file *is* the config pytest would load but sets no
75
+ ``testpaths`` -- distinct from ``None``, where a lower-priority file still
76
+ gets a chance, exactly as pytest's own config discovery treats them.
77
+ """
78
+ if config_path.suffix == ".toml":
79
+ try:
80
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
81
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError):
82
+ return None
83
+ tool_section = data.get("tool")
84
+ if not isinstance(tool_section, dict):
85
+ return None
86
+ pytest_section = tool_section.get("pytest")
87
+ if not isinstance(pytest_section, dict):
88
+ return None
89
+ section = pytest_section.get("ini_options")
90
+ if not isinstance(section, dict):
91
+ return None
92
+ return {
93
+ str(key): _as_option_values(value)
94
+ for key, value in section.items()
95
+ if key == "testpaths"
96
+ }
97
+
98
+ section_name = "tool:pytest" if config_path.name == "setup.cfg" else "pytest"
99
+ parser = configparser.ConfigParser(strict=False)
100
+ try:
101
+ parser.read(config_path, encoding="utf-8")
102
+ except (OSError, configparser.Error, UnicodeDecodeError):
103
+ return None
104
+ if not parser.has_section(section_name):
105
+ # pytest.ini is always the config source, even without a [pytest]
106
+ # section; tox.ini and setup.cfg only count when it is present.
107
+ return {} if config_path.name == "pytest.ini" else None
108
+ return {
109
+ key: _as_option_values(raw)
110
+ for key, raw in parser.items(section_name)
111
+ if key == "testpaths"
112
+ }
113
+
114
+
115
+ def _as_option_values(value: Any) -> list[str]:
116
+ """Normalise a config option value (list or newline/comma string) to a list."""
117
+ if isinstance(value, (list, tuple)):
118
+ return [str(item).strip() for item in value if str(item).strip()]
119
+ return [
120
+ part.strip()
121
+ for line in str(value).splitlines()
122
+ for part in line.split(",")
123
+ if part.strip()
124
+ ]
125
+
126
+
127
+ def pytest_test_roots(project_root: Path) -> list[Path]:
128
+ """Directories ``run_coverage`` hands to pytest, in the project's own terms.
129
+
130
+ ``testpaths`` from the config file pytest itself would load when it sets
131
+ them; otherwise ``tests/`` when it exists. An empty list means the project
132
+ declares no test tree, and the caller keeps pytest's own argument
133
+ behaviour.
134
+ """
135
+ options = _read_pytest_options(project_root)
136
+ roots = [project_root / raw for raw in options.get("testpaths", []) if raw.strip()]
137
+ roots = [root for root in roots if root.is_dir()]
138
+ if roots:
139
+ return roots
140
+ default_root = project_root / "tests"
141
+ return [default_root] if default_root.is_dir() else []
142
+
143
+
144
+ def _iter_files(root: Path, pattern: str) -> Iterator[Path]:
145
+ """Yield files under ``root`` matching ``pattern``, skipping tool caches."""
146
+ for dirpath, dirnames, filenames in os.walk(root):
147
+ dirnames[:] = sorted(
148
+ name for name in dirnames if name not in _PRUNED_DIRECTORY_NAMES
149
+ )
150
+ for filename in sorted(filenames):
151
+ if fnmatch.fnmatchcase(filename, pattern):
152
+ yield Path(dirpath) / filename
153
+
154
+
155
+ def detect_source_packages(project_root: Path) -> list[Path]:
156
+ """Source package directories under the project's ``src/`` root.
157
+
158
+ Initialised packages (a directory with ``__init__.py``) win; a layout that
159
+ only has plain directories with modules (namespace packages, or a tree
160
+ without ``__init__.py``) falls back to those. This is what the coverage
161
+ target and the source-to-test mapping resolve against -- never a hardcoded
162
+ ``wl_cli``.
163
+ """
164
+ source_root = project_root / "src"
165
+ if not source_root.is_dir():
166
+ return []
167
+ candidates = [
168
+ path
169
+ for path in sorted(source_root.iterdir())
170
+ if path.is_dir()
171
+ and not path.name.startswith((".", "_"))
172
+ and path.name != "tests"
173
+ ]
174
+ initialised = [path for path in candidates if (path / "__init__.py").is_file()]
175
+ if initialised:
176
+ return initialised
177
+ return [path for path in candidates if any(path.rglob("*.py"))]
178
+
179
+
180
+ def coverage_targets(project_root: Path) -> list[str]:
181
+ """``--cov`` targets for the project, resolved from its actual layout.
182
+
183
+ Package names when ``src/<pkg>`` exists (the form ``pytest --cov`` accepts
184
+ as module names), then the ``src`` directory itself (a path target
185
+ pytest-cov measures recursively), then ``.`` so a project with neither
186
+ still gets a report instead of silently measuring the hardcoded
187
+ ``wl_cli``.
188
+ """
189
+ packages = detect_source_packages(project_root)
190
+ if packages:
191
+ return [package.name for package in packages]
192
+ if (project_root / "src").is_dir():
193
+ return ["src"]
194
+ return ["."]
195
+
196
+
197
+ def collect_coverage_inputs(project_root: Path) -> list[Path]:
198
+ """The Python files whose change can invalidate collected coverage data.
199
+
200
+ Deliberately wider than the source-to-test one-to-one mapping: that
201
+ mapping names at most one test module per source file, while
202
+ :meth:`CoverageAnalyzer.run_coverage` executes the *whole* test tree. The
203
+ cache key and the freshness check must cover what actually ran, so this
204
+ returns all Python modules under the resolved test roots, every
205
+ ``conftest.py`` below the project root, and the pytest configuration
206
+ files this analyzer recognises: ``pytest.ini``, ``pyproject.toml`` (its
207
+ ``[tool.pytest.ini_options]`` table only), ``tox.ini``, and ``setup.cfg``.
208
+ Any other config form (``pytest.toml``, ``.pytest.ini``, a bare
209
+ ``[tool.pytest]`` table) is not recognised and is treated as no
210
+ configuration at all: it is not tracked here and contributes no
211
+ ``testpaths``.
212
+
213
+ Every ``.py`` file under the resolved test roots is in scope, whether or
214
+ not pytest can import it: a syntactically broken helper still changes what
215
+ runs. Known boundary: non-Python fixtures (for example
216
+ ``tests/data/*.json``) are not tracked, so changing one does not
217
+ invalidate cached coverage.
218
+ """
219
+ inputs: set[Path] = set()
220
+ for test_root in pytest_test_roots(project_root):
221
+ inputs.update(_iter_files(test_root, "*.py"))
222
+ inputs.update(_iter_files(project_root, "conftest.py"))
223
+ for filename in _PYTEST_CONFIG_FILES:
224
+ config_path = project_root / filename
225
+ if config_path.is_file():
226
+ inputs.add(config_path)
227
+ return sorted(inputs)
228
+
229
+
230
+ def _is_click_decorator(decorator: ast.expr) -> bool:
231
+ """Whether a decorator is Click's ``command`` or ``group``.
232
+
233
+ Covers ``@click.command()`` / ``@click.group()``, the
234
+ ``from click import command`` spelling, and the stacked form where other
235
+ decorators (``@click.option(...)``) sit above or below it.
236
+ """
237
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
238
+ if isinstance(target, ast.Name):
239
+ return target.id in {"command", "group"}
240
+ if not isinstance(target, ast.Attribute):
241
+ return False
242
+ if target.attr not in {"command", "group"}:
243
+ return False
244
+ root: ast.expr = target.value
245
+ while isinstance(root, ast.Attribute):
246
+ root = root.value
247
+ return isinstance(root, ast.Name) and root.id == "click"
248
+
249
+
250
+ @dataclass
251
+ class CoverageStats:
252
+ """Statistics for a single file's coverage."""
253
+
254
+ filename: str
255
+ covered_lines: int
256
+ total_lines: int
257
+ coverage_percent: float
258
+ uncovered_lines: list[int]
259
+ uncovered_ranges: list[tuple[int, int]]
260
+
261
+
262
+ @dataclass
263
+ class CoverageSuggestion:
264
+ """Suggestion for improving coverage."""
265
+
266
+ filename: str
267
+ line_start: int
268
+ line_end: int
269
+ suggestion: str
270
+ suggestion_zh: str
271
+ severity: str # low, medium, high
272
+
273
+
274
+ class CoverageAnalyzer:
275
+ """Analyzes test coverage and generates improvement suggestions."""
276
+
277
+ def __init__(self, project_root: Path):
278
+ """Initialize coverage analyzer.
279
+
280
+ Args:
281
+ project_root: Root directory of the project
282
+ """
283
+ self.project_root = project_root
284
+ self.coverage_data: dict[str, Any] = {}
285
+ self.source_files: list[Path] = []
286
+ # Resolved once: the project's own packages, test roots and the files
287
+ # whose change invalidates a coverage report. Never a hardcoded
288
+ # ``wl_cli`` / ``tests`` pair (#192).
289
+ self.source_packages: list[Path] = detect_source_packages(project_root)
290
+ self.test_roots: list[Path] = pytest_test_roots(project_root)
291
+
292
+ def coverage_inputs(self) -> list[Path]:
293
+ """Files whose change must invalidate cached / on-disk coverage data."""
294
+ return collect_coverage_inputs(self.project_root)
295
+
296
+ def _coverage_cov_arguments(self) -> list[str]:
297
+ """``--cov=`` arguments for this project's layout."""
298
+ return [f"--cov={target}" for target in coverage_targets(self.project_root)]
299
+
300
+ def set_coverage_data(self, coverage_data: dict[str, Any]):
301
+ """Set coverage data directly from memory.
302
+
303
+ Args:
304
+ coverage_data: Coverage data dictionary in pytest-cov JSON format
305
+ """
306
+ self.coverage_data = coverage_data
307
+
308
+ def _find_source_files(self) -> list[Path]:
309
+ """Find all Python source files in the project.
310
+
311
+ Test modules are recognised the way pytest recognises them, not by a
312
+ ``test`` substring in the file name (#182): a file is an inline test
313
+ module only when pytest could collect it -- its basename matches
314
+ pytest's default test-file patterns (``test_*.py`` / ``*_test.py``)
315
+ *and* it defines a top-level ``test_*`` plain function. So
316
+ ``test_utils.py``,
317
+ ``buildcommands/test.py`` and ``gate/preflight.py`` stay in the list
318
+ even when they carry a ``test_*`` symbol, and a Click command named
319
+ ``test_command`` does not turn a production module into a test one.
320
+
321
+ A file is skipped when it is an ``__init__.py``, when any part of its
322
+ path below ``project_root`` is a ``tests`` directory, or when it is an
323
+ inline test module by the rule above.
324
+ """
325
+ source_dir = self.project_root / "src"
326
+ if not source_dir.exists():
327
+ return []
328
+
329
+ source_files = []
330
+ for py_file in source_dir.rglob("*.py"):
331
+ if py_file.name == "__init__.py":
332
+ continue
333
+ if "tests" in self._parts_below_project_root(py_file):
334
+ continue
335
+ if self._is_inline_test_module(py_file):
336
+ continue
337
+ source_files.append(py_file)
338
+
339
+ return sorted(source_files)
340
+
341
+ def _parts_below_project_root(self, py_file: Path) -> tuple[str, ...]:
342
+ """Path components of ``py_file`` relative to the project root.
343
+
344
+ Falls back to the absolute components when the file is not under the
345
+ project root (only reachable for symlinked or moved trees).
346
+ """
347
+ try:
348
+ return py_file.relative_to(self.project_root).parts
349
+ except ValueError:
350
+ return py_file.parts
351
+
352
+ @classmethod
353
+ def _is_inline_test_module(cls, py_file: Path) -> bool:
354
+ """Whether ``py_file`` is a module pytest would collect tests from.
355
+
356
+ Both halves are required (#182 review): a collectable basename --
357
+ ``gate/preflight.py``'s ``test_interpreter`` once hid a 434-line
358
+ production module -- and a top-level ``test_*`` that pytest would
359
+ actually run, so ``test_commands.py``'s ``@click.command()`` is not a
360
+ test function.
361
+ """
362
+ if not any(
363
+ fnmatch.fnmatchcase(py_file.name, pattern)
364
+ for pattern in _PYTEST_PYTHON_FILES
365
+ ):
366
+ return False
367
+ return cls._defines_plain_test_function(py_file)
368
+
369
+ @staticmethod
370
+ def _defines_plain_test_function(py_file: Path) -> bool:
371
+ """Whether the module has a top-level, non-Click ``test_*`` function.
372
+
373
+ Files that cannot be read or parsed are treated as regular source,
374
+ never silently dropped.
375
+ """
376
+ try:
377
+ tree = ast.parse(py_file.read_bytes(), filename=str(py_file))
378
+ except (OSError, SyntaxError, ValueError):
379
+ return False
380
+
381
+ for node in tree.body:
382
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
383
+ continue
384
+ if not node.name.startswith("test_"):
385
+ continue
386
+ if any(_is_click_decorator(decorator) for decorator in node.decorator_list):
387
+ continue
388
+ return True
389
+ return False
390
+
391
+ def run_coverage(
392
+ self,
393
+ python_executable: str,
394
+ output_path: Path | str | None = None,
395
+ step_by_step: bool = False,
396
+ ) -> dict | None:
397
+ """Run pytest with coverage collection.
398
+
399
+ Args:
400
+ python_executable: Path to Python executable
401
+ output_path: Where to write the coverage JSON report. Defaults to
402
+ ``todos/coverage.json`` when not provided.
403
+ step_by_step: Whether to run in step-by-step mode
404
+
405
+ Returns:
406
+ Parsed coverage data dictionary on success, or None when
407
+ pytest-cov is unavailable, pytest fails, or the coverage JSON is
408
+ missing or unparsable.
409
+ """
410
+ if output_path is None:
411
+ coverage_json_path = self.project_root / "todos" / "coverage.json"
412
+ else:
413
+ coverage_json_path = Path(output_path)
414
+ coverage_json_path.parent.mkdir(parents=True, exist_ok=True)
415
+ if coverage_json_path.exists():
416
+ coverage_json_path.unlink()
417
+
418
+ if not self._check_pytest_cov_available(python_executable):
419
+ return None
420
+
421
+ # The test tree and the covered package both come from the project's
422
+ # own layout/config (#192): ``tests/`` + ``--cov=wl_cli`` only describe
423
+ # this repository, not every project that runs ``wl test --cover``.
424
+ test_paths = [str(root) for root in self.test_roots] or ["tests/"]
425
+ cmd_args = [
426
+ python_executable,
427
+ "-m",
428
+ "pytest",
429
+ *test_paths,
430
+ *self._coverage_cov_arguments(),
431
+ "--cov-branch",
432
+ "--cov-context=test",
433
+ f"--cov-report=json:{coverage_json_path}",
434
+ "--no-cov-on-fail",
435
+ "-q",
436
+ ]
437
+
438
+ if step_by_step:
439
+ # In step-by-step mode, capture all output and show progress
440
+ # First, collect test count
441
+ collect_cmd = cmd_args + ["--collect-only"]
442
+ collect_result = subprocess.run(
443
+ collect_cmd,
444
+ cwd=self.project_root,
445
+ check=False,
446
+ capture_output=True,
447
+ text=True,
448
+ )
449
+
450
+ test_count = 0
451
+ if collect_result.stdout:
452
+ import re
453
+
454
+ match = re.search(r"collected\s+(\d+)\s+items", collect_result.stdout)
455
+ if match:
456
+ test_count = int(match.group(1))
457
+
458
+ # Show progress bar and run tests
459
+ if test_count > 0:
460
+ from tqdm import tqdm
461
+
462
+ with tqdm(
463
+ total=test_count,
464
+ unit="test",
465
+ dynamic_ncols=True,
466
+ bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]",
467
+ desc="Running tests",
468
+ ) as pbar:
469
+ # Run tests and capture output
470
+ result = subprocess.run(
471
+ cmd_args,
472
+ cwd=self.project_root,
473
+ check=False,
474
+ capture_output=True,
475
+ text=True,
476
+ )
477
+
478
+ # Update progress bar to 100%
479
+ pbar.update(test_count)
480
+ else:
481
+ # Fall back if can't parse test count
482
+ result = subprocess.run(
483
+ cmd_args,
484
+ cwd=self.project_root,
485
+ check=False,
486
+ capture_output=True,
487
+ text=True,
488
+ )
489
+
490
+ # Show test failure output only if there was an error
491
+ if result.returncode != 0:
492
+ print("[Coverage] Tests failed during coverage collection:")
493
+ if result.stdout:
494
+ print(
495
+ result.stdout[-2000:]
496
+ if len(result.stdout) > 2000
497
+ else result.stdout
498
+ )
499
+ if result.stderr:
500
+ print(
501
+ result.stderr[-1000:]
502
+ if len(result.stderr) > 1000
503
+ else result.stderr
504
+ )
505
+ else:
506
+ # Normal mode: capture output to match the pre-#39 runner UX (no
507
+ # live pytest chatter); on failure show the tail of pytest stderr.
508
+ result = subprocess.run(
509
+ cmd_args,
510
+ cwd=self.project_root,
511
+ check=False,
512
+ capture_output=True,
513
+ text=True,
514
+ )
515
+
516
+ if result.returncode != 0 and result.stderr:
517
+ print("Last 20 lines of pytest stderr:")
518
+ print("\n".join(result.stderr.splitlines()[-20:]))
519
+
520
+ return self._read_coverage_json(coverage_json_path)
521
+
522
+ def run_coverage_for_file(
523
+ self,
524
+ python_executable: str,
525
+ source_file: Path,
526
+ fail_under: float = 0.0,
527
+ output_path: Path | str | None = None,
528
+ ) -> bool:
529
+ """Run pytest with coverage collection for a single source file.
530
+
531
+ The per-file coverage JSON is written to a temporary file (or the
532
+ provided ``output_path``) so the main coverage JSON is never
533
+ overwritten. The measured coverage percentage for ``source_file`` is
534
+ compared against ``fail_under``.
535
+
536
+ Args:
537
+ python_executable: Path to Python executable
538
+ source_file: Path to the source file to test
539
+ fail_under: Coverage percentage threshold. Defaults to ``0.0`` so a
540
+ plain call reports whether coverage was collected at all.
541
+ output_path: Where to write the per-file coverage JSON. Defaults to
542
+ a temporary file when not provided.
543
+
544
+ Returns:
545
+ True when coverage was collected and the file's measured coverage
546
+ is at or above ``fail_under``. False on any error or shortfall.
547
+ """
548
+ # Find corresponding test file
549
+ test_file = self._find_test_file(source_file)
550
+ if not test_file:
551
+ return False
552
+
553
+ if output_path is None:
554
+ owns_temp_path = True
555
+ temp_file = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
556
+ coverage_json_path = Path(temp_file.name)
557
+ temp_file.close()
558
+ else:
559
+ owns_temp_path = False
560
+ coverage_json_path = Path(output_path)
561
+ coverage_json_path.parent.mkdir(parents=True, exist_ok=True)
562
+ if coverage_json_path.exists():
563
+ coverage_json_path.unlink()
564
+
565
+ try:
566
+ # Build command with specific test file
567
+ cmd_args = [
568
+ python_executable,
569
+ "-m",
570
+ "pytest",
571
+ str(test_file),
572
+ *self._coverage_cov_arguments(),
573
+ "--cov-branch",
574
+ "--cov-context=test",
575
+ f"--cov-report=json:{coverage_json_path}",
576
+ "--no-cov-on-fail",
577
+ "-q",
578
+ ]
579
+
580
+ result = subprocess.run(
581
+ cmd_args,
582
+ cwd=self.project_root,
583
+ check=False,
584
+ capture_output=True,
585
+ text=True,
586
+ )
587
+
588
+ if result.returncode != 0:
589
+ return False
590
+
591
+ coverage_data = self._read_coverage_json(coverage_json_path)
592
+ if coverage_data is None:
593
+ return False
594
+
595
+ stats = self._parse_coverage_for_file(source_file)
596
+ if stats is None:
597
+ return False
598
+ return stats.coverage_percent >= fail_under
599
+ finally:
600
+ if owns_temp_path:
601
+ coverage_json_path.unlink(missing_ok=True)
602
+
603
+ def _read_coverage_json(self, path: Path) -> dict | None:
604
+ """Read and parse a coverage JSON report.
605
+
606
+ Args:
607
+ path: Path to the coverage JSON report
608
+
609
+ Returns:
610
+ Parsed dictionary, or None when missing, empty, or unparsable.
611
+ """
612
+ if not path.exists():
613
+ return None
614
+ try:
615
+ content = path.read_text()
616
+ if not content.strip():
617
+ return None
618
+ data = json.loads(content)
619
+ if not isinstance(data, dict):
620
+ return None
621
+ self.coverage_data = data
622
+ return data
623
+ except (OSError, json.JSONDecodeError):
624
+ return None
625
+
626
+ def _check_pytest_cov_available(self, python_executable: str) -> bool:
627
+ """pytest-cov 是否可被**执行 pytest 的那个解释器**导入。
628
+
629
+ `run_coverage` 用传入的 `python_executable` 起 pytest,判定就必须问同一
630
+ 个解释器:只在 wl 进程内 `__import__` 时,`--cover` 会按 wl 的环境决定
631
+ 是否传 `--cov*` —— 两者不一致时要么漏跑 coverage,要么把 pytest 认不出
632
+ 的 flag 交给子进程。此处不做缓存(一次 `--cover` 只探一次)。
633
+
634
+ 执行用 env 未显式传给子进程(`subprocess.run` 不带 `env=`),故探针同样
635
+ 只传解释器:判定的环境与执行的环境一致。
636
+
637
+ 探针本体是 `test_utils._probe_pytest_cov`(#112 起三处入口共用)。
638
+ """
639
+ if _probe_pytest_cov(python_executable):
640
+ return True
641
+ print(
642
+ "[Coverage] pytest-cov is not installed. Install with: "
643
+ "pip install py_wlcli[test]"
644
+ )
645
+ return False
646
+
647
+ def _find_test_file(self, source_file: Path) -> Path | None:
648
+ """Find the corresponding test file for a source file.
649
+
650
+ Args:
651
+ source_file: Path to the source file
652
+
653
+ Returns:
654
+ Path to the test file if found, None otherwise
655
+ """
656
+ # Locate the source package this project actually ships instead of the
657
+ # hardcoded ``src/wl_cli`` (#192).
658
+ rel_path: Path | None = None
659
+ for package_dir in self.source_packages:
660
+ try:
661
+ rel_path = source_file.relative_to(package_dir)
662
+ except ValueError:
663
+ continue
664
+ break
665
+ if rel_path is None:
666
+ return None
667
+
668
+ # Build test file path under the project's own test root
669
+ test_root = (
670
+ self.test_roots[0] if self.test_roots else self.project_root / "tests"
671
+ )
672
+ test_path = test_root / rel_path
673
+ test_path = test_path.with_name(f"test_{test_path.name}")
674
+
675
+ if test_path.exists():
676
+ return test_path
677
+
678
+ # Try alternative test locations
679
+ alternatives = [
680
+ test_root / "testcommands" / f"test_{source_file.name}",
681
+ test_root / f"test_{source_file.name}",
682
+ ]
683
+
684
+ for alt_path in alternatives:
685
+ if alt_path.exists():
686
+ return alt_path
687
+
688
+ return None
689
+
690
+ def _parse_coverage_for_file(
691
+ self, file_path: Path, file_lines: list[str] | None = None
692
+ ) -> CoverageStats | None:
693
+ """Parse coverage data for a single file.
694
+
695
+ Args:
696
+ file_path: Path to the source file
697
+ file_lines: Pre-read file content (for performance optimization)
698
+
699
+ Returns:
700
+ CoverageStats if file was found in coverage data, None otherwise
701
+ """
702
+ file_key = str(file_path.resolve())
703
+
704
+ # Try different path formats
705
+ files_data = self.coverage_data.get("files", {})
706
+ if file_key not in files_data:
707
+ # Try relative path (normalize to forward slashes for cross-platform compatibility)
708
+ rel_path = file_path.relative_to(self.project_root)
709
+ file_key = str(rel_path).replace("\\", "/")
710
+
711
+ if file_key not in files_data:
712
+ return None
713
+
714
+ file_data = self.coverage_data["files"][file_key]
715
+ lines = file_data.get("executed_lines", [])
716
+ missing = file_data.get("missing_lines", [])
717
+ excluded = file_data.get("excluded_lines", [])
718
+ summary = file_data.get("summary", {})
719
+
720
+ total_lines = int(summary.get("num_statements") or 0)
721
+ if total_lines == 0:
722
+ total_lines = int(summary.get("num_lines") or 0)
723
+ if total_lines == 0:
724
+ if file_lines is not None:
725
+ total_lines = len(file_lines)
726
+ else:
727
+ try:
728
+ with open(file_path) as f:
729
+ total_lines = len(f.readlines())
730
+ except Exception:
731
+ total_lines = 0
732
+
733
+ covered_lines = int(summary.get("covered_lines") or len(lines))
734
+ uncovered_lines = sorted(set(missing) - set(excluded))
735
+
736
+ summary_percent = summary.get("percent_covered")
737
+ if summary_percent is not None:
738
+ coverage_percent = float(summary_percent)
739
+ elif total_lines > 0:
740
+ coverage_percent = (covered_lines / total_lines) * 100
741
+ else:
742
+ coverage_percent = 0.0
743
+
744
+ # Group uncovered lines into ranges
745
+ uncovered_ranges = self._group_lines_into_ranges(uncovered_lines)
746
+
747
+ return CoverageStats(
748
+ filename=str(file_path.relative_to(self.project_root)),
749
+ covered_lines=covered_lines,
750
+ total_lines=total_lines,
751
+ coverage_percent=coverage_percent,
752
+ uncovered_lines=uncovered_lines,
753
+ uncovered_ranges=uncovered_ranges,
754
+ )
755
+
756
+ def _group_lines_into_ranges(self, lines: list[int]) -> list[tuple[int, int]]:
757
+ """Group consecutive lines into ranges.
758
+
759
+ Args:
760
+ lines: Sorted list of line numbers
761
+
762
+ Returns:
763
+ List of (start, end) tuples representing ranges
764
+ """
765
+ if not lines:
766
+ return []
767
+
768
+ ranges = []
769
+ start = lines[0]
770
+ end = lines[0]
771
+
772
+ for line in lines[1:]:
773
+ if line == end + 1:
774
+ end = line
775
+ else:
776
+ ranges.append((start, end))
777
+ start = line
778
+ end = line
779
+
780
+ ranges.append((start, end))
781
+ return ranges
782
+
783
+ def analyze_file(
784
+ self, file_path: Path
785
+ ) -> tuple[CoverageStats, list[CoverageSuggestion]]:
786
+ """Analyze coverage for a single file and generate suggestions.
787
+
788
+ Args:
789
+ file_path: Path to the source file
790
+
791
+ Returns:
792
+ Tuple of CoverageStats and list of CoverageSuggestion
793
+ """
794
+ # Read file content once and reuse to avoid multiple file I/O operations
795
+ try:
796
+ with open(file_path) as f:
797
+ file_lines = f.readlines()
798
+ except Exception:
799
+ file_lines = []
800
+
801
+ stats = self._parse_coverage_for_file(file_path, file_lines)
802
+ if stats is None:
803
+ # Create empty stats if no coverage data found
804
+ stats = CoverageStats(
805
+ filename=str(file_path.relative_to(self.project_root)),
806
+ covered_lines=0,
807
+ total_lines=len(file_lines),
808
+ coverage_percent=0.0,
809
+ uncovered_lines=[],
810
+ uncovered_ranges=[],
811
+ )
812
+
813
+ suggestions = self._generate_suggestions(file_path, stats, file_lines)
814
+ return stats, suggestions
815
+
816
+ def _generate_suggestions(
817
+ self, file_path: Path, stats: CoverageStats, file_lines: list[str] | None = None
818
+ ) -> list[CoverageSuggestion]:
819
+ """Generate coverage improvement suggestions for a file.
820
+
821
+ Args:
822
+ file_path: Path to the source file
823
+ stats: Coverage statistics for the file
824
+ file_lines: Pre-read file content (for performance optimization)
825
+
826
+ Returns:
827
+ List of coverage improvement suggestions
828
+ """
829
+ suggestions: list[CoverageSuggestion] = []
830
+
831
+ if not stats.uncovered_ranges:
832
+ return suggestions
833
+
834
+ # Get file lines from pre-read content or read file
835
+ if file_lines is None:
836
+ try:
837
+ with open(file_path) as f:
838
+ file_lines = f.readlines()
839
+ except Exception:
840
+ return suggestions
841
+
842
+ for start, end in stats.uncovered_ranges:
843
+ # Extract the code snippet
844
+ snippet = ""
845
+ for i in range(max(0, start - 1), min(len(file_lines), end)):
846
+ snippet += file_lines[i]
847
+
848
+ suggestion, suggestion_zh, severity = self._analyze_code_snippet(
849
+ file_path.name, start, end, snippet
850
+ )
851
+
852
+ suggestions.append(
853
+ CoverageSuggestion(
854
+ filename=stats.filename,
855
+ line_start=start,
856
+ line_end=end,
857
+ suggestion=suggestion,
858
+ suggestion_zh=suggestion_zh,
859
+ severity=severity,
860
+ )
861
+ )
862
+
863
+ return suggestions
864
+
865
+ def _analyze_code_snippet(
866
+ self, filename: str, start: int, end: int, snippet: str
867
+ ) -> tuple[str, str, str]:
868
+ """Analyze a code snippet to generate a specific suggestion.
869
+
870
+ Args:
871
+ filename: Name of the file
872
+ start: Starting line number
873
+ end: Ending line number
874
+ snippet: The code snippet to analyze
875
+
876
+ Returns:
877
+ Tuple of (suggestion_en, suggestion_zh, severity)
878
+ """
879
+ lines = snippet.strip().split("\n")
880
+
881
+ # Detect function definitions
882
+ for line in lines:
883
+ if line.strip().startswith("def "):
884
+ func_name = line.strip()[4:].split("(")[0].strip()
885
+ suggestion = f"Function '{func_name}' (lines {start}-{end}) is not covered by tests"
886
+ suggestion_zh = f"函数 '{func_name}'(第 {start}-{end} 行)没有测试覆盖"
887
+ severity = "high"
888
+ return suggestion, suggestion_zh, severity
889
+
890
+ if line.strip().startswith("class "):
891
+ class_name = line.strip()[6:].split("(")[0].strip()
892
+ suggestion = (
893
+ f"Class '{class_name}' (lines {start}-{end}) is not fully covered"
894
+ )
895
+ suggestion_zh = f"类 '{class_name}'(第 {start}-{end} 行)未完全覆盖"
896
+ severity = "high"
897
+ return suggestion, suggestion_zh, severity
898
+
899
+ if "except " in line or "try:" in line:
900
+ suggestion = (
901
+ f"Exception handling block (lines {start}-{end}) is not covered"
902
+ )
903
+ suggestion_zh = f"异常处理块(第 {start}-{end} 行)没有测试覆盖"
904
+ severity = "medium"
905
+ return suggestion, suggestion_zh, severity
906
+
907
+ if "if " in line or "elif " in line:
908
+ suggestion = f"Conditional branch (lines {start}-{end}) is not covered"
909
+ suggestion_zh = f"条件分支(第 {start}-{end} 行)没有测试覆盖"
910
+ severity = "medium"
911
+ return suggestion, suggestion_zh, severity
912
+
913
+ # Default suggestion
914
+ suggestion = f"Code block (lines {start}-{end}) is not covered by tests"
915
+ suggestion_zh = f"代码块(第 {start}-{end} 行)没有测试覆盖"
916
+ severity = "low"
917
+
918
+ return suggestion, suggestion_zh, severity
919
+
920
+ def get_overall_stats(self) -> dict[str, Any]:
921
+ """Get overall coverage statistics for the project.
922
+
923
+ Returns:
924
+ Dictionary with overall coverage statistics
925
+ """
926
+ if not self.coverage_data:
927
+ return {
928
+ "total_covered": 0,
929
+ "total_lines": 0,
930
+ "overall_percent": 0.0,
931
+ "files_analyzed": 0,
932
+ }
933
+
934
+ files_data = self.coverage_data.get("files", {})
935
+ total_covered = 0
936
+ total_lines = 0
937
+
938
+ for _file_key, file_data in files_data.items():
939
+ if "summary" in file_data:
940
+ # Ensure integer conversion
941
+ covered = file_data["summary"].get("covered_lines", 0)
942
+ statements = file_data["summary"].get("num_statements", 0)
943
+ total_covered += int(covered) if covered else 0
944
+ total_lines += int(statements) if statements else 0
945
+
946
+ overall_percent = (
947
+ (total_covered / total_lines) * 100 if total_lines > 0 else 0.0
948
+ )
949
+
950
+ return {
951
+ "total_covered": total_covered,
952
+ "total_lines": total_lines,
953
+ "overall_percent": float(overall_percent),
954
+ "files_analyzed": len(files_data),
955
+ }