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.
- py_wlcli-0.8.0.dist-info/METADATA +139 -0
- py_wlcli-0.8.0.dist-info/RECORD +320 -0
- py_wlcli-0.8.0.dist-info/WHEEL +4 -0
- py_wlcli-0.8.0.dist-info/entry_points.txt +2 -0
- py_wlcli-0.8.0.dist-info/licenses/LICENSE +21 -0
- wl_cli/__init__.py +27 -0
- wl_cli/__main__.py +130 -0
- wl_cli/capabilities.py +79 -0
- wl_cli/cc/__init__.py +5 -0
- wl_cli/cc/checks.py +1019 -0
- wl_cli/cc/cli.py +370 -0
- wl_cli/cc/mutate.py +1350 -0
- wl_cli/cli_click.py +156 -0
- wl_cli/commands/__init__.py +84 -0
- wl_cli/commands/base.py +80 -0
- wl_cli/commands/buildcommands/__init__.py +1 -0
- wl_cli/commands/buildcommands/build.py +71 -0
- wl_cli/commands/buildcommands/build_utils.py +47 -0
- wl_cli/commands/buildcommands/builders/__init__.py +18 -0
- wl_cli/commands/buildcommands/builders/cleanup.py +35 -0
- wl_cli/commands/buildcommands/builders/coordinator.py +122 -0
- wl_cli/commands/buildcommands/builders/environment.py +81 -0
- wl_cli/commands/buildcommands/builders/rust_builder.py +39 -0
- wl_cli/commands/buildcommands/builders/stubs.py +58 -0
- wl_cli/commands/buildcommands/dist.py +326 -0
- wl_cli/commands/buildcommands/test.py +89 -0
- wl_cli/commands/clean/__init__.py +1 -0
- wl_cli/commands/clean/build.py +151 -0
- wl_cli/commands/clean/clean.py +294 -0
- wl_cli/commands/clean/clean_utils.py +77 -0
- wl_cli/commands/clean/core.py +236 -0
- wl_cli/commands/clean/dead_code/__init__.py +0 -0
- wl_cli/commands/clean/dead_code/dead_code_command.py +70 -0
- wl_cli/commands/clean/dead_code/detector.py +161 -0
- wl_cli/commands/clean/dead_code/fixer.py +176 -0
- wl_cli/commands/clean/dead_code/reporter.py +295 -0
- wl_cli/commands/clean/dead_code/utils.py +0 -0
- wl_cli/commands/clean/lfs.py +215 -0
- wl_cli/commands/clean/python.py +81 -0
- wl_cli/commands/clean/rust.py +91 -0
- wl_cli/commands/clean/utils/__init__.py +34 -0
- wl_cli/commands/clean/venv.py +91 -0
- wl_cli/commands/cleanup.py +73 -0
- wl_cli/commands/click_base.py +79 -0
- wl_cli/commands/config/__init__.py +1 -0
- wl_cli/commands/config/config.py +291 -0
- wl_cli/commands/debug/__init__.py +6 -0
- wl_cli/commands/debug/debug.py +309 -0
- wl_cli/commands/debug/debug_cache.py +86 -0
- wl_cli/commands/debug/debug_subagent.py +156 -0
- wl_cli/commands/debug/output_formatter.py +165 -0
- wl_cli/commands/debug/source_hasher.py +92 -0
- wl_cli/commands/format/__init__.py +13 -0
- wl_cli/commands/format/format_cache_adapter.py +125 -0
- wl_cli/commands/format/format_command.py +197 -0
- wl_cli/commands/format/format_coordinator.py +191 -0
- wl_cli/commands/format/format_executor.py +470 -0
- wl_cli/commands/format/format_outcome.py +44 -0
- wl_cli/commands/format/format_path_handler.py +1016 -0
- wl_cli/commands/format/format_progress.py +62 -0
- wl_cli/commands/format/format_reporter.py +31 -0
- wl_cli/commands/format/python_formatter.py +506 -0
- wl_cli/commands/format/rust_formatter.py +106 -0
- wl_cli/commands/gate/__init__.py +1 -0
- wl_cli/commands/gate/gate_command.py +862 -0
- wl_cli/commands/gate/preflight.py +445 -0
- wl_cli/commands/git/__init__.py +1 -0
- wl_cli/commands/git/config.py +169 -0
- wl_cli/commands/git/git_facts.py +60 -0
- wl_cli/commands/git/git_flow.py +86 -0
- wl_cli/commands/git/hook.py +112 -0
- wl_cli/commands/git/policy.py +216 -0
- wl_cli/commands/git/push_update.py +82 -0
- wl_cli/commands/initenv/__init__.py +1 -0
- wl_cli/commands/initenv/common/__init__.py +23 -0
- wl_cli/commands/initenv/common/check_git_bash.py +78 -0
- wl_cli/commands/initenv/common/error_handler.py +68 -0
- wl_cli/commands/initenv/common/exceptions.py +52 -0
- wl_cli/commands/initenv/components/__init__.py +4 -0
- wl_cli/commands/initenv/components/cargo_sync.py +240 -0
- wl_cli/commands/initenv/components/config_manager.py +106 -0
- wl_cli/commands/initenv/components/directory_utils.py +71 -0
- wl_cli/commands/initenv/components/git/__init__.py +9 -0
- wl_cli/commands/initenv/components/git/branch_creator.py +143 -0
- wl_cli/commands/initenv/components/git/git_flow_interface.py +24 -0
- wl_cli/commands/initenv/components/git/git_flow_manager.py +159 -0
- wl_cli/commands/initenv/components/git/git_lfs_manager.py +272 -0
- wl_cli/commands/initenv/components/git/git_utility.py +254 -0
- wl_cli/commands/initenv/components/git/gitignore_manager.py +53 -0
- wl_cli/commands/initenv/components/git_initializer.py +212 -0
- wl_cli/commands/initenv/components/i18n_manager.py +104 -0
- wl_cli/commands/initenv/components/log_manager.py +241 -0
- wl_cli/commands/initenv/components/project_structure/__init__.py +23 -0
- wl_cli/commands/initenv/components/project_structure/config_generator.py +360 -0
- wl_cli/commands/initenv/components/project_structure/directory_creator.py +184 -0
- wl_cli/commands/initenv/components/project_structure/hook_source.py +110 -0
- wl_cli/commands/initenv/components/project_structure/hooks_manager.py +995 -0
- wl_cli/commands/initenv/components/project_structure/readme_handler.py +56 -0
- wl_cli/commands/initenv/components/project_structure/rust_files_handler.py +30 -0
- wl_cli/commands/initenv/components/project_structure/setup_handler.py +103 -0
- wl_cli/commands/initenv/components/project_structure/sop_generator.py +73 -0
- wl_cli/commands/initenv/components/project_structure/template_copier.py +104 -0
- wl_cli/commands/initenv/components/project_structure/template_manager.py +195 -0
- wl_cli/commands/initenv/components/pyproject_generator.py +194 -0
- wl_cli/commands/initenv/components/rust_initializer.py +145 -0
- wl_cli/commands/initenv/components/symlink_cleaner.py +99 -0
- wl_cli/commands/initenv/components/symlink_manager.py +52 -0
- wl_cli/commands/initenv/components/venv_manager.py +118 -0
- wl_cli/commands/initenv/initenv.py +169 -0
- wl_cli/commands/initenv/services/__init__.py +11 -0
- wl_cli/commands/initenv/services/environment_initializer.py +288 -0
- wl_cli/commands/initenv/services/git_lfs_initializer.py +26 -0
- wl_cli/commands/initenv/services/project_structure_initializer.py +52 -0
- wl_cli/commands/lfs/README.md +221 -0
- wl_cli/commands/lfs/__init__.py +5 -0
- wl_cli/commands/lfs/cli.py +106 -0
- wl_cli/commands/lfs/config.py +164 -0
- wl_cli/commands/lfs/hook.py +61 -0
- wl_cli/commands/lfs/initializer.py +531 -0
- wl_cli/commands/lfs/manager.py +95 -0
- wl_cli/commands/lfs/policy.py +165 -0
- wl_cli/commands/lfs/watcher.py +294 -0
- wl_cli/commands/lint/__init__.py +1 -0
- wl_cli/commands/lint/lint_command.py +297 -0
- wl_cli/commands/lint/lint_executor.py +409 -0
- wl_cli/commands/lint/lint_reporter.py +579 -0
- wl_cli/commands/lint/lint_run.py +258 -0
- wl_cli/commands/lint/mypy_config.py +90 -0
- wl_cli/commands/registry.py +103 -0
- wl_cli/commands/self/__init__.py +7 -0
- wl_cli/commands/self/common/__init__.py +8 -0
- wl_cli/commands/self/common/capabilities.py +30 -0
- wl_cli/commands/self/common/config.py +27 -0
- wl_cli/commands/self/common/version_cache.py +85 -0
- wl_cli/commands/self/handlers/__init__.py +19 -0
- wl_cli/commands/self/handlers/base_handler.py +28 -0
- wl_cli/commands/self/handlers/install_handler.py +85 -0
- wl_cli/commands/self/handlers/status_handler.py +66 -0
- wl_cli/commands/self/handlers/uninstall_handler.py +58 -0
- wl_cli/commands/self/handlers/update_handler.py +102 -0
- wl_cli/commands/self/handlers/version_handler.py +16 -0
- wl_cli/commands/self/self_command.py +237 -0
- wl_cli/commands/self/services/__init__.py +15 -0
- wl_cli/commands/self/services/installation_manager.py +187 -0
- wl_cli/commands/self/services/local_installer.py +165 -0
- wl_cli/commands/self/services/pypi_updater.py +278 -0
- wl_cli/commands/self/services/windows_update.py +174 -0
- wl_cli/commands/testcommands/__init__.py +8 -0
- wl_cli/commands/testcommands/base_dependency_analyzer.py +198 -0
- wl_cli/commands/testcommands/batch_processor.py +278 -0
- wl_cli/commands/testcommands/cache_manager.py +1156 -0
- wl_cli/commands/testcommands/coverage_analyzer.py +955 -0
- wl_cli/commands/testcommands/coverage_cache_manager.py +183 -0
- wl_cli/commands/testcommands/progress_manager.py +79 -0
- wl_cli/commands/testcommands/python_env_detector.py +207 -0
- wl_cli/commands/testcommands/report_generator.py +101 -0
- wl_cli/commands/testcommands/single_test_executor.py +250 -0
- wl_cli/commands/testcommands/speed_checker.py +118 -0
- wl_cli/commands/testcommands/test_command_builder.py +165 -0
- wl_cli/commands/testcommands/test_commands.py +786 -0
- wl_cli/commands/testcommands/test_reporter.py +181 -0
- wl_cli/commands/testcommands/test_result_handler.py +96 -0
- wl_cli/commands/testcommands/test_runner.py +1343 -0
- wl_cli/commands/testcommands/test_utils.py +197 -0
- wl_cli/commands/testcommands/utils/__init__.py +17 -0
- wl_cli/commands/testcommands/utils/coverage_parser.py +98 -0
- wl_cli/commands/testcommands/utils/env_info.py +36 -0
- wl_cli/commands/testcommands/utils/junit_parser.py +75 -0
- wl_cli/commands/testcommands/utils/markdown_utils.py +20 -0
- wl_cli/commands/testcommands/utils/project_root.py +61 -0
- wl_cli/commands/update/__init__.py +9 -0
- wl_cli/commands/update/services/__init__.py +21 -0
- wl_cli/commands/update/services/backup_manager.py +207 -0
- wl_cli/commands/update/services/component_manager.py +83 -0
- wl_cli/commands/update/services/conflict_resolver.py +189 -0
- wl_cli/commands/update/services/vendors_updater.py +85 -0
- wl_cli/commands/update/services/version_tracker.py +127 -0
- wl_cli/commands/update/update_command.py +270 -0
- wl_cli/commands/update/utils/__init__.py +3 -0
- wl_cli/commands/version/__init__.py +9 -0
- wl_cli/commands/version/version_command.py +155 -0
- wl_cli/commands/workflow.py +477 -0
- wl_cli/env/__init__.py +5 -0
- wl_cli/env/cli.py +167 -0
- wl_cli/env_status.py +157 -0
- wl_cli/envelope.py +91 -0
- wl_cli/exceptions.py +85 -0
- wl_cli/lib/__init__.py +3 -0
- wl_cli/lib/rust_utils.py +35 -0
- wl_cli/mcp/__init__.py +6 -0
- wl_cli/mcp/capabilities.py +19 -0
- wl_cli/mcp/cli.py +22 -0
- wl_cli/mcp/gitlab.py +553 -0
- wl_cli/mcp/k8s.py +344 -0
- wl_cli/mcp/protocol.py +159 -0
- wl_cli/platform.py +139 -0
- wl_cli/quality/__init__.py +18 -0
- wl_cli/utils/__init__.py +7 -0
- wl_cli/utils/cache.py +491 -0
- wl_cli/utils/cache_ledger.py +355 -0
- wl_cli/utils/cache_store.py +546 -0
- wl_cli/utils/config/__init__.py +27 -0
- wl_cli/utils/config/config_loader.py +163 -0
- wl_cli/utils/config/core.py +862 -0
- wl_cli/utils/config/defaults.py +142 -0
- wl_cli/utils/config/env_exporter.py +86 -0
- wl_cli/utils/config/env_parser.py +108 -0
- wl_cli/utils/config/validation.py +280 -0
- wl_cli/utils/error_handler.py +165 -0
- wl_cli/utils/file_copier.py +56 -0
- wl_cli/utils/file_operations.py +506 -0
- wl_cli/utils/language_utils.py +122 -0
- wl_cli/utils/lazy_import.py +240 -0
- wl_cli/utils/log_levels.py +35 -0
- wl_cli/utils/log_rotators.py +74 -0
- wl_cli/utils/logging.py +458 -0
- wl_cli/utils/output_context.py +142 -0
- wl_cli/utils/performance/__init__.py +1 -0
- wl_cli/utils/performance/decorators.py +111 -0
- wl_cli/utils/performance/exporter.py +38 -0
- wl_cli/utils/performance/models.py +16 -0
- wl_cli/utils/performance/monitor.py +161 -0
- wl_cli/utils/performance_monitor.py +32 -0
- wl_cli/utils/platform_adapter.py +131 -0
- wl_cli/utils/pre_commit.py +266 -0
- wl_cli/utils/pre_commit_config.py +72 -0
- wl_cli/utils/root.py +521 -0
- wl_cli/utils/root_fallback.py +256 -0
- wl_cli/utils/run_evidence.py +236 -0
- wl_cli/utils/structured_logger.py +18 -0
- wl_cli/utils/structured_logging/__init__.py +19 -0
- wl_cli/utils/structured_logging/config.py +337 -0
- wl_cli/utils/structured_logging/core.py +177 -0
- wl_cli/utils/structured_logging/env.py +56 -0
- wl_cli/utils/structured_logging/filtering.py +75 -0
- wl_cli/utils/structured_logging/filters.py +55 -0
- wl_cli/utils/structured_logging/formatters.py +251 -0
- wl_cli/utils/structured_logging/handlers.py +308 -0
- wl_cli/utils/structured_logging/logger.py +354 -0
- wl_cli/utils/subprocess.py +71 -0
- wl_cli/utils/subprocess_utils.py +260 -0
- wl_cli/utils/test_utils/__init__.py +5 -0
- wl_cli/utils/test_utils/test_filter.py +36 -0
- wl_cli/utils/uv_run.py +79 -0
- wl_cli/utils/uv_tool.py +117 -0
- wl_cli/utils/uv_tree.py +71 -0
- wl_cli/utils/uv_workspace.py +174 -0
- wl_cli/utils/version/__init__.py +15 -0
- wl_cli/utils/version/pypi_version_checker.py +95 -0
- wl_cli/utils/version/version_comparator.py +133 -0
- wl_cli/utils/version/version_detectors.py +312 -0
- wl_cli/utils/version/version_service.py +131 -0
- wl_cli/utils/version/version_updater.py +198 -0
- wl_cli/utils/wl_dir_updater.py +1440 -0
- wl_cli/utils/workspace_detector/__init__.py +28 -0
- wl_cli/utils/workspace_detector/core.py +161 -0
- wl_cli/utils/workspace_detector/detection_strategies.py +107 -0
- wl_cli/utils/workspace_detector/exceptions.py +5 -0
- wl_cli/utils/workspace_detector/types.py +17 -0
- wl_cli/utils/workspace_detector/venv_resolver.py +84 -0
- wl_cli/vendors/config/.codespellrc +2 -0
- wl_cli/vendors/config/dependencies.toml +10 -0
- wl_cli/vendors/config/mypy.ini +62 -0
- wl_cli/vendors/config/pyproject_defaults.toml +28 -0
- wl_cli/vendors/config/template_config.toml +24 -0
- wl_cli/vendors/config/tool_configs.toml +76 -0
- wl_cli/vendors/git/.gitignore +122 -0
- wl_cli/vendors/git/.pre-commit-config.yaml +37 -0
- wl_cli/vendors/hooks/applypatch-msg.sample +15 -0
- wl_cli/vendors/hooks/commit-msg.sample +24 -0
- wl_cli/vendors/hooks/fsmonitor-watchman.sample +174 -0
- wl_cli/vendors/hooks/post-update.sample +8 -0
- wl_cli/vendors/hooks/pre-applypatch.sample +14 -0
- wl_cli/vendors/hooks/pre-commit +45 -0
- wl_cli/vendors/hooks/pre-commit.legacy +31 -0
- wl_cli/vendors/hooks/pre-commit.sample +49 -0
- wl_cli/vendors/hooks/pre-merge-commit.sample +13 -0
- wl_cli/vendors/hooks/pre-push.sample +60 -0
- wl_cli/vendors/hooks/pre-rebase.sample +169 -0
- wl_cli/vendors/hooks/pre-receive.sample +24 -0
- wl_cli/vendors/hooks/prepare-commit-msg.sample +42 -0
- wl_cli/vendors/hooks/push-to-checkout.sample +78 -0
- wl_cli/vendors/hooks/sendemail-validate.sample +77 -0
- wl_cli/vendors/hooks/unix/applypatch-msg.sample +15 -0
- wl_cli/vendors/hooks/unix/check_lockfile.py +58 -0
- wl_cli/vendors/hooks/unix/check_lockfile.sh +36 -0
- wl_cli/vendors/hooks/unix/commit-msg.sample +24 -0
- wl_cli/vendors/hooks/unix/fsmonitor-watchman.sample +174 -0
- wl_cli/vendors/hooks/unix/post-update.sample +8 -0
- wl_cli/vendors/hooks/unix/pre-applypatch.sample +14 -0
- wl_cli/vendors/hooks/unix/pre-commit +190 -0
- wl_cli/vendors/hooks/unix/pre-commit.legacy +31 -0
- wl_cli/vendors/hooks/unix/pre-commit.py +81 -0
- wl_cli/vendors/hooks/unix/pre-commit.sample +49 -0
- wl_cli/vendors/hooks/unix/pre-merge-commit.sample +13 -0
- wl_cli/vendors/hooks/unix/pre-push +417 -0
- wl_cli/vendors/hooks/unix/pre-push.sample +53 -0
- wl_cli/vendors/hooks/unix/pre-rebase.sample +169 -0
- wl_cli/vendors/hooks/unix/pre-receive.sample +24 -0
- wl_cli/vendors/hooks/unix/prepare-commit-msg.sample +42 -0
- wl_cli/vendors/hooks/update.sample +128 -0
- wl_cli/vendors/hooks/win/work.bat +63 -0
- wl_cli/vendors/project/.gitignore +90 -0
- wl_cli/vendors/project/.python-version +1 -0
- wl_cli/vendors/project/LICENSE +21 -0
- wl_cli/vendors/project/README.md +0 -0
- wl_cli/vendors/project/pyproject.toml +27 -0
- wl_cli/vendors/project/pyproject_no_workspace.toml +16 -0
- wl_cli/vendors/project/pyproject_workspace.toml +14 -0
- wl_cli/vendors/readme/README.md +120 -0
- wl_cli/vendors/rust/Cargo.toml +14 -0
- wl_cli/vendors/rust/lib/__init__.py +3 -0
- wl_cli/vendors/rust/lib/rust_utils.py +31 -0
- wl_cli/vendors/rust/src/lib.rs +13 -0
- wl_cli/vendors/rust/src/main.rs +3 -0
- wl_cli/vendors/rust/tests/test_rust_fallback.py +40 -0
- wl_cli/vendors/sop_doc/git-dev-sop.md +220 -0
- wl_cli/vendors/sop_doc/industrial-standard-sop.md +145 -0
- wl_cli/vendors/sop_doc/python-sop.md +342 -0
- 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
|
+
}
|