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,786 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test command implementation for wl tool.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from ...utils.config import ConfigProvider
|
|
16
|
+
from ...utils.output_context import (
|
|
17
|
+
get_output_config,
|
|
18
|
+
is_concise_mode,
|
|
19
|
+
)
|
|
20
|
+
from ...utils.root import Root, RootKind
|
|
21
|
+
from ..base import BaseCommand
|
|
22
|
+
from .cache_manager import CacheManager
|
|
23
|
+
from .coverage_analyzer import (
|
|
24
|
+
CoverageAnalyzer,
|
|
25
|
+
CoverageStats,
|
|
26
|
+
collect_coverage_inputs,
|
|
27
|
+
)
|
|
28
|
+
from .coverage_cache_manager import CoverageCacheManager
|
|
29
|
+
from .progress_manager import ProgressManager
|
|
30
|
+
|
|
31
|
+
# Import modular components
|
|
32
|
+
from .python_env_detector import PythonEnvDetector
|
|
33
|
+
from .test_command_builder import TestCommandBuilder
|
|
34
|
+
from .test_result_handler import TestResultHandler
|
|
35
|
+
from .test_runner import TestRunner, _iter_python_files
|
|
36
|
+
from .test_utils import _ensure_pytest_available, _process_command_args
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ExistingCoverageResult:
|
|
41
|
+
coverage_data: dict[str, Any] | None = None
|
|
42
|
+
stale: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# pytest's default ``python_files`` patterns: what a directory selector expands
|
|
46
|
+
# to in step-by-step mode (step-by-step runs files, not node ids).
|
|
47
|
+
_PYTEST_FILE_PATTERNS = ("test_*.py", "*_test.py")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _split_nodeid(selector: str) -> tuple[str, str]:
|
|
51
|
+
"""Split ``tests/test_mod.py::TestFoo::test_bar`` into path and node part."""
|
|
52
|
+
path_part, _, node_part = selector.partition("::")
|
|
53
|
+
return path_part, node_part
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _expand_directory(directory: Path) -> list[Path]:
|
|
57
|
+
"""Test modules below ``directory``, by pytest's default file patterns.
|
|
58
|
+
|
|
59
|
+
The walk is ``test_runner._iter_python_files`` -- the one definition #191
|
|
60
|
+
gave a directory selector, so ``.venv``/``venv``/``site-packages``/
|
|
61
|
+
``node_modules`` and hidden directories never become step-by-step targets.
|
|
62
|
+
A plain ``rglob`` turned this repository's own ``.venv`` into 39 extra
|
|
63
|
+
"test files" (rv192 MINOR-1).
|
|
64
|
+
"""
|
|
65
|
+
return sorted(
|
|
66
|
+
path
|
|
67
|
+
for path in _iter_python_files(directory)
|
|
68
|
+
if any(
|
|
69
|
+
fnmatch.fnmatchcase(path.name, pattern) for pattern in _PYTEST_FILE_PATTERNS
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_step_selectors(
|
|
75
|
+
selectors: list[str], project_root: str | Path
|
|
76
|
+
) -> tuple[list[Path], list[str]]:
|
|
77
|
+
"""Resolve ``wl test -s`` arguments to the test files they name.
|
|
78
|
+
|
|
79
|
+
Step-by-step mode runs one file at a time, so a selector may be a test
|
|
80
|
+
module, a directory of modules, or a pytest node id (``file::node``) --
|
|
81
|
+
the node id narrows the run to its file. Anything else (a stray option, a
|
|
82
|
+
missing path, a node id without a file) is returned as an error: silently
|
|
83
|
+
widening to the whole suite would run far more than the user asked for
|
|
84
|
+
(#192).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
``(files, errors)`` -- files are de-duplicated in selector order.
|
|
88
|
+
"""
|
|
89
|
+
files: list[Path] = []
|
|
90
|
+
errors: list[str] = []
|
|
91
|
+
root = Path(project_root)
|
|
92
|
+
|
|
93
|
+
def add(candidate: Path) -> None:
|
|
94
|
+
if candidate not in files:
|
|
95
|
+
files.append(candidate)
|
|
96
|
+
|
|
97
|
+
for selector in selectors:
|
|
98
|
+
if selector.startswith("-"):
|
|
99
|
+
errors.append(
|
|
100
|
+
f"unsupported option in step-by-step mode: {selector!r} "
|
|
101
|
+
"(pytest options are only forwarded in plain 'wl test')"
|
|
102
|
+
)
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
path_part, node_part = _split_nodeid(selector)
|
|
106
|
+
if not path_part:
|
|
107
|
+
errors.append(f"node id has no file before '::': {selector!r}")
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
candidate = Path(path_part)
|
|
111
|
+
if not candidate.exists():
|
|
112
|
+
candidate = root / path_part
|
|
113
|
+
if not candidate.exists():
|
|
114
|
+
errors.append(f"no such test file or directory: {path_part!r}")
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
if candidate.is_dir():
|
|
118
|
+
expanded = _expand_directory(candidate)
|
|
119
|
+
if not expanded:
|
|
120
|
+
errors.append(f"no test modules under directory: {path_part!r}")
|
|
121
|
+
continue
|
|
122
|
+
for path in expanded:
|
|
123
|
+
add(path)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if candidate.suffix != ".py":
|
|
127
|
+
errors.append(f"not a Python test file: {path_part!r}")
|
|
128
|
+
continue
|
|
129
|
+
if node_part:
|
|
130
|
+
print(f"[Step] Node id {selector!r} runs as its whole file: {candidate}")
|
|
131
|
+
add(candidate)
|
|
132
|
+
|
|
133
|
+
return files, errors
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestCommand(BaseCommand):
|
|
137
|
+
"""Command to run tests for the project."""
|
|
138
|
+
|
|
139
|
+
# Tell pytest not to collect this class as a test class
|
|
140
|
+
__test__ = False
|
|
141
|
+
|
|
142
|
+
def __init__(self):
|
|
143
|
+
"""Initialize TestCommand with modular components."""
|
|
144
|
+
super().__init__()
|
|
145
|
+
self.python_detector = PythonEnvDetector()
|
|
146
|
+
self.command_builder = TestCommandBuilder(self.python_detector)
|
|
147
|
+
self.result_handler = TestResultHandler()
|
|
148
|
+
self.cache_manager = CacheManager()
|
|
149
|
+
self.test_runner = TestRunner(self.cache_manager)
|
|
150
|
+
self.progress_manager = ProgressManager()
|
|
151
|
+
self.config_provider = ConfigProvider()
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def name(self) -> str:
|
|
155
|
+
"""Get the command name."""
|
|
156
|
+
return "test"
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def help(self) -> str:
|
|
160
|
+
"""Get the command help text."""
|
|
161
|
+
return "Run project tests"
|
|
162
|
+
|
|
163
|
+
def execute(self, *_, **kwargs: Any) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Execute the test command.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
**kwargs: Keyword arguments
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
# 处理命令行参数获取quiet/verbose first for output config
|
|
172
|
+
quiet = kwargs.get("quiet", False)
|
|
173
|
+
verbose = kwargs.get("verbose", False)
|
|
174
|
+
output_config = get_output_config(
|
|
175
|
+
override_quiet=quiet, override_verbose=verbose
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# 显示当前使用的Python环境路径 - only in verbose mode
|
|
179
|
+
python_executable = self.python_detector.get_python_executable()
|
|
180
|
+
if output_config.verbose:
|
|
181
|
+
print(f"Running tests using Python environment: {python_executable}")
|
|
182
|
+
|
|
183
|
+
# 处理命令行参数
|
|
184
|
+
(
|
|
185
|
+
report_mode,
|
|
186
|
+
debug_mode,
|
|
187
|
+
step_by_step,
|
|
188
|
+
no_cache,
|
|
189
|
+
clean_cache,
|
|
190
|
+
cache_info,
|
|
191
|
+
quiet,
|
|
192
|
+
verbose,
|
|
193
|
+
passed_args,
|
|
194
|
+
) = _process_command_args(self, kwargs)
|
|
195
|
+
|
|
196
|
+
# 获取coverage选项
|
|
197
|
+
cover_mode = kwargs.get("cover", False)
|
|
198
|
+
|
|
199
|
+
# 命令行参数优先级高于配置文件
|
|
200
|
+
fail_under_cmd = kwargs.get("fail_under", None)
|
|
201
|
+
if fail_under_cmd is not None:
|
|
202
|
+
fail_under = float(fail_under_cmd)
|
|
203
|
+
else:
|
|
204
|
+
# 从配置读取覆盖率阈值
|
|
205
|
+
fail_under = self.config_provider.get_config(
|
|
206
|
+
"coverage.fail_under", 70.0
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# 检查并安装pytest(如果需要)
|
|
210
|
+
python_executable = _ensure_pytest_available(python_executable)
|
|
211
|
+
|
|
212
|
+
# 处理缓存信息选项
|
|
213
|
+
if cache_info:
|
|
214
|
+
self.cache_manager.info()
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
# 处理清理缓存选项
|
|
218
|
+
if clean_cache:
|
|
219
|
+
self.cache_manager.purge_all()
|
|
220
|
+
|
|
221
|
+
# 获取coverage缓存和并行选项
|
|
222
|
+
no_cover_cache = kwargs.get("no_cover_cache", False)
|
|
223
|
+
cover_parallel_workers = kwargs.get("cover_parallel_workers", None)
|
|
224
|
+
no_skip = kwargs.get("no_skip", False)
|
|
225
|
+
evidence_json = kwargs.get("evidence_json")
|
|
226
|
+
evidence_path = Path(evidence_json) if evidence_json else None
|
|
227
|
+
|
|
228
|
+
# 如果是coverage模式,运行覆盖率分析
|
|
229
|
+
if cover_mode:
|
|
230
|
+
self._run_coverage_analysis(
|
|
231
|
+
python_executable,
|
|
232
|
+
step_by_step,
|
|
233
|
+
fail_under,
|
|
234
|
+
no_cover_cache,
|
|
235
|
+
cover_parallel_workers,
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
# 运行测试
|
|
239
|
+
self._run_tests(
|
|
240
|
+
python_executable,
|
|
241
|
+
report_mode,
|
|
242
|
+
debug_mode,
|
|
243
|
+
step_by_step,
|
|
244
|
+
no_cache,
|
|
245
|
+
quiet,
|
|
246
|
+
verbose,
|
|
247
|
+
passed_args,
|
|
248
|
+
no_skip=no_skip,
|
|
249
|
+
evidence_path=evidence_path,
|
|
250
|
+
)
|
|
251
|
+
except SystemExit:
|
|
252
|
+
# Re-raise SystemExit to allow tests to capture it
|
|
253
|
+
raise
|
|
254
|
+
except FileNotFoundError:
|
|
255
|
+
print(
|
|
256
|
+
"Error: pytest not found. Please install it using 'pip install pytest'"
|
|
257
|
+
)
|
|
258
|
+
sys.exit(1)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
print(f"Error running tests: {e}")
|
|
261
|
+
sys.exit(1)
|
|
262
|
+
|
|
263
|
+
def _run_tests(
|
|
264
|
+
self,
|
|
265
|
+
python_executable: str,
|
|
266
|
+
report_mode: bool,
|
|
267
|
+
debug_mode: bool,
|
|
268
|
+
step_by_step: bool,
|
|
269
|
+
no_cache: bool,
|
|
270
|
+
quiet: bool,
|
|
271
|
+
verbose: bool,
|
|
272
|
+
passed_args: list[str],
|
|
273
|
+
no_skip: bool = False,
|
|
274
|
+
evidence_path: Path | None = None,
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Run tests with the specified configuration."""
|
|
277
|
+
# ADR 0006: 回退是调用方策略 —— 由 project_root() 显式兜底 CWD
|
|
278
|
+
project_root = self.progress_manager.project_root()
|
|
279
|
+
# Create todos directory in current working directory
|
|
280
|
+
todo_dir = Path("todos")
|
|
281
|
+
todo_dir.mkdir(exist_ok=True)
|
|
282
|
+
concise = is_concise_mode(override_quiet=quiet, override_verbose=verbose)
|
|
283
|
+
self.progress_manager._log_project_paths(project_root, todo_dir, concise)
|
|
284
|
+
|
|
285
|
+
env = self.progress_manager._build_env(project_root)
|
|
286
|
+
|
|
287
|
+
# Handle step-by-step mode
|
|
288
|
+
if step_by_step:
|
|
289
|
+
if passed_args:
|
|
290
|
+
# Resolve selectors (files, directories, node ids) explicitly.
|
|
291
|
+
# An unrecognised selector is an error, never a silent fallback
|
|
292
|
+
# to the whole suite (#192).
|
|
293
|
+
test_files, selector_errors = _resolve_step_selectors(
|
|
294
|
+
passed_args, project_root
|
|
295
|
+
)
|
|
296
|
+
# Both exits happen before any test run starts, so they write no
|
|
297
|
+
# `--evidence-json` record: there is no run for the file to
|
|
298
|
+
# describe (see docs/user/commands/test.md, "运行证据"). The
|
|
299
|
+
# contract covers terminating paths *after* a run begins.
|
|
300
|
+
if selector_errors:
|
|
301
|
+
for error in selector_errors:
|
|
302
|
+
print(f"Error: {error}")
|
|
303
|
+
print("No tests were run. Fix the selector(s) and retry.")
|
|
304
|
+
sys.exit(2)
|
|
305
|
+
if not test_files:
|
|
306
|
+
print("Error: no test files matched the given selectors.")
|
|
307
|
+
sys.exit(2)
|
|
308
|
+
|
|
309
|
+
# Run only specified test files
|
|
310
|
+
self.test_runner._run_specified_tests_step_by_step(
|
|
311
|
+
python_executable,
|
|
312
|
+
project_root,
|
|
313
|
+
env,
|
|
314
|
+
debug_mode,
|
|
315
|
+
no_cache,
|
|
316
|
+
quiet,
|
|
317
|
+
verbose,
|
|
318
|
+
test_files,
|
|
319
|
+
no_skip=no_skip,
|
|
320
|
+
evidence_path=evidence_path,
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
# Run all test files
|
|
324
|
+
self.test_runner._run_tests_step_by_step(
|
|
325
|
+
python_executable,
|
|
326
|
+
project_root,
|
|
327
|
+
env,
|
|
328
|
+
debug_mode,
|
|
329
|
+
no_cache,
|
|
330
|
+
quiet,
|
|
331
|
+
verbose,
|
|
332
|
+
no_skip=no_skip,
|
|
333
|
+
evidence_path=evidence_path,
|
|
334
|
+
)
|
|
335
|
+
else:
|
|
336
|
+
self.test_runner._run_tests_normal(
|
|
337
|
+
python_executable,
|
|
338
|
+
project_root,
|
|
339
|
+
env,
|
|
340
|
+
report_mode,
|
|
341
|
+
passed_args,
|
|
342
|
+
debug_mode,
|
|
343
|
+
todo_dir,
|
|
344
|
+
no_cache,
|
|
345
|
+
quiet,
|
|
346
|
+
verbose,
|
|
347
|
+
evidence_path=evidence_path,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def _run_coverage_analysis(
|
|
351
|
+
self,
|
|
352
|
+
python_executable: str,
|
|
353
|
+
step_by_step: bool = False,
|
|
354
|
+
fail_under: float | None = None,
|
|
355
|
+
no_cover_cache: bool = False,
|
|
356
|
+
cover_parallel_workers: int | None = None,
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Run coverage analysis and generate improvement suggestions.
|
|
359
|
+
|
|
360
|
+
Three-phase flow:
|
|
361
|
+
1. Cache check - collect source and test files, check for cached coverage
|
|
362
|
+
2. Run pytest - only if cache miss, generate coverage.json
|
|
363
|
+
3. Foreground analysis - per-file check (step-by-step) or overall stats (normal)
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
python_executable: Path to Python executable
|
|
367
|
+
step_by_step: Whether to run in step-by-step mode (stop at first failure)
|
|
368
|
+
fail_under: If specified, fail if coverage is below this percentage
|
|
369
|
+
no_cover_cache: Whether to disable coverage caching
|
|
370
|
+
cover_parallel_workers: Number of parallel workers for coverage tests
|
|
371
|
+
"""
|
|
372
|
+
# Root-anchored (#125): the test subsystem's own root policy, the same
|
|
373
|
+
# seam the non-coverage branch uses (`_run_tests` -> ``project_root()``),
|
|
374
|
+
# not the process CWD. The bare ``Path.cwd()`` this replaces fed both the
|
|
375
|
+
# analyzer and the ``.wl/coverage.json`` derivation below, so a run from
|
|
376
|
+
# a subdirectory read and wrote ``<subdir>/.wl``; and because
|
|
377
|
+
# ``CoverageAnalyzer.run_coverage`` mkdirs that parent, the stray ``.wl``
|
|
378
|
+
# *directory* then became a PROJECT marker and re-anchored the very sites
|
|
379
|
+
# this ticket root-anchored.
|
|
380
|
+
project_root = Path(self.progress_manager.project_root())
|
|
381
|
+
print("[Coverage] Running coverage analysis...")
|
|
382
|
+
|
|
383
|
+
analyzer = CoverageAnalyzer(project_root)
|
|
384
|
+
|
|
385
|
+
source_files = analyzer._find_source_files()
|
|
386
|
+
if not source_files:
|
|
387
|
+
print("[Coverage] No source files found to analyze")
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
# What the cached/stale coverage actually depended on: the whole test
|
|
391
|
+
# tree pytest executes plus conftest/pytest config -- not the at most
|
|
392
|
+
# one-to-one source->test mapping, which left every unmapped test file
|
|
393
|
+
# (e.g. tests/test_smoke.py) outside both the key and the freshness
|
|
394
|
+
# check (#192).
|
|
395
|
+
test_inputs = collect_coverage_inputs(project_root)
|
|
396
|
+
|
|
397
|
+
cache_mgr = CoverageCacheManager()
|
|
398
|
+
cache_enabled = (
|
|
399
|
+
self.config_provider.get_config("coverage.cache_enabled", True)
|
|
400
|
+
and not no_cover_cache
|
|
401
|
+
)
|
|
402
|
+
cache_key = cache_mgr.get_cache_key(source_files, test_inputs)
|
|
403
|
+
|
|
404
|
+
coverage_data = None
|
|
405
|
+
existing_result = ExistingCoverageResult()
|
|
406
|
+
|
|
407
|
+
if step_by_step:
|
|
408
|
+
existing_result = self._load_existing_coverage_json(
|
|
409
|
+
project_root,
|
|
410
|
+
source_files,
|
|
411
|
+
test_inputs,
|
|
412
|
+
)
|
|
413
|
+
coverage_data = existing_result.coverage_data
|
|
414
|
+
|
|
415
|
+
if not coverage_data and not existing_result.stale and cache_enabled:
|
|
416
|
+
coverage_data = cache_mgr.get_cached_coverage(cache_key)
|
|
417
|
+
if coverage_data:
|
|
418
|
+
print("[Cache] Using cached coverage data")
|
|
419
|
+
|
|
420
|
+
if not coverage_data:
|
|
421
|
+
if existing_result.stale:
|
|
422
|
+
print(
|
|
423
|
+
"[Coverage] Refreshing coverage data before step-by-step analysis..."
|
|
424
|
+
)
|
|
425
|
+
else:
|
|
426
|
+
print("[Coverage] Running pytest to generate coverage data...")
|
|
427
|
+
coverage_data = analyzer.run_coverage(
|
|
428
|
+
python_executable,
|
|
429
|
+
output_path=Root(project_root, RootKind.PROJECT).wl_dir()
|
|
430
|
+
/ "coverage.json",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
if coverage_data and cache_enabled:
|
|
434
|
+
# Only claim success when the entry was actually written. An
|
|
435
|
+
# unkeyed run (a file's stat failed) and a failed write both
|
|
436
|
+
# return False -- and the ledger already logs the write
|
|
437
|
+
# failure -- so a success line here would be a lie (#213
|
|
438
|
+
# review MEDIUM-1).
|
|
439
|
+
if cache_mgr.save_cached_coverage(cache_key, coverage_data):
|
|
440
|
+
print("[Cache] Coverage data saved to cache")
|
|
441
|
+
|
|
442
|
+
if not coverage_data:
|
|
443
|
+
print("[Coverage] Failed to collect coverage data")
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
self._analyze_coverage(
|
|
447
|
+
analyzer,
|
|
448
|
+
coverage_data,
|
|
449
|
+
source_files,
|
|
450
|
+
step_by_step,
|
|
451
|
+
fail_under,
|
|
452
|
+
project_root,
|
|
453
|
+
python_executable,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def _load_existing_coverage_json(
|
|
457
|
+
self,
|
|
458
|
+
project_root: Path,
|
|
459
|
+
source_files: list[Path],
|
|
460
|
+
test_inputs: list[Path],
|
|
461
|
+
) -> ExistingCoverageResult:
|
|
462
|
+
coverage_json_path = (
|
|
463
|
+
Root(project_root, RootKind.PROJECT).wl_dir() / "coverage.json"
|
|
464
|
+
)
|
|
465
|
+
if not coverage_json_path.exists():
|
|
466
|
+
return ExistingCoverageResult()
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
content = coverage_json_path.read_text()
|
|
470
|
+
if not content.strip():
|
|
471
|
+
print("[Coverage] Existing coverage data is empty, skipping")
|
|
472
|
+
return ExistingCoverageResult()
|
|
473
|
+
coverage_data = json.loads(content)
|
|
474
|
+
except (OSError, json.JSONDecodeError):
|
|
475
|
+
print("[Coverage] Existing coverage data is unreadable, skipping")
|
|
476
|
+
return ExistingCoverageResult()
|
|
477
|
+
|
|
478
|
+
files = coverage_data.get("files")
|
|
479
|
+
if not isinstance(files, dict):
|
|
480
|
+
print("[Coverage] Existing coverage data has invalid schema, skipping")
|
|
481
|
+
return ExistingCoverageResult()
|
|
482
|
+
|
|
483
|
+
source_keys = {str(source_file) for source_file in source_files}
|
|
484
|
+
source_keys.update(
|
|
485
|
+
str(source_file.relative_to(project_root)) for source_file in source_files
|
|
486
|
+
)
|
|
487
|
+
if not source_keys.intersection(files):
|
|
488
|
+
print(
|
|
489
|
+
"[Coverage] Existing coverage data does not match current sources, skipping"
|
|
490
|
+
)
|
|
491
|
+
return ExistingCoverageResult()
|
|
492
|
+
|
|
493
|
+
updated = coverage_json_path.stat().st_mtime
|
|
494
|
+
checked_files = [*source_files, *test_inputs]
|
|
495
|
+
if any(
|
|
496
|
+
path.exists() and path.stat().st_mtime > updated for path in checked_files
|
|
497
|
+
):
|
|
498
|
+
print(
|
|
499
|
+
"[Coverage] Existing coverage data is stale; source, test, "
|
|
500
|
+
"conftest or pytest config files changed after .wl/coverage.json."
|
|
501
|
+
)
|
|
502
|
+
return ExistingCoverageResult(stale=True)
|
|
503
|
+
|
|
504
|
+
updated_text = datetime.fromtimestamp(updated).strftime("%Y-%m-%d %H:%M:%S")
|
|
505
|
+
print(
|
|
506
|
+
"[Coverage] Using existing coverage data: .wl/coverage.json "
|
|
507
|
+
f"(updated {updated_text})"
|
|
508
|
+
)
|
|
509
|
+
return ExistingCoverageResult(coverage_data=coverage_data)
|
|
510
|
+
|
|
511
|
+
def _analyze_coverage(
|
|
512
|
+
self,
|
|
513
|
+
analyzer: CoverageAnalyzer,
|
|
514
|
+
coverage_data: dict[str, Any],
|
|
515
|
+
source_files: list[Path],
|
|
516
|
+
step_by_step: bool,
|
|
517
|
+
fail_under: float | None,
|
|
518
|
+
project_root: Path | None = None,
|
|
519
|
+
python_executable: str | None = None,
|
|
520
|
+
) -> None:
|
|
521
|
+
"""Analyze coverage data and generate report.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
analyzer: CoverageAnalyzer instance
|
|
525
|
+
coverage_data: Coverage data from pytest
|
|
526
|
+
source_files: List of source files to analyze
|
|
527
|
+
step_by_step: Whether to run in step-by-step mode
|
|
528
|
+
fail_under: Coverage threshold percentage
|
|
529
|
+
"""
|
|
530
|
+
analyzer.set_coverage_data(coverage_data)
|
|
531
|
+
|
|
532
|
+
if not step_by_step:
|
|
533
|
+
overall_stats = analyzer.get_overall_stats()
|
|
534
|
+
overall_percent = float(overall_stats.get("overall_percent", 0.0))
|
|
535
|
+
|
|
536
|
+
print(f"\n[Coverage] Total coverage: {overall_percent:.1f}%")
|
|
537
|
+
|
|
538
|
+
if fail_under is not None:
|
|
539
|
+
if overall_percent < fail_under:
|
|
540
|
+
print(
|
|
541
|
+
f"❌ [Coverage] FAILED: Overall coverage ({overall_percent:.1f}%) "
|
|
542
|
+
f"is below threshold ({fail_under}%)"
|
|
543
|
+
)
|
|
544
|
+
sys.exit(1)
|
|
545
|
+
else:
|
|
546
|
+
print(
|
|
547
|
+
f"✓ [Coverage] PASS: Coverage meets threshold ({fail_under}%)"
|
|
548
|
+
)
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
print(f"\n[Coverage] Checking coverage for {len(source_files)} files...")
|
|
552
|
+
if fail_under is not None:
|
|
553
|
+
print(f"[Coverage] Threshold: {fail_under}%")
|
|
554
|
+
print("=" * 60)
|
|
555
|
+
|
|
556
|
+
stats_list: list[CoverageStats | None] = []
|
|
557
|
+
all_suggestions = []
|
|
558
|
+
|
|
559
|
+
display_root = project_root or Path.cwd()
|
|
560
|
+
|
|
561
|
+
for i, file_path in enumerate(source_files, 1):
|
|
562
|
+
file_rel = file_path.relative_to(display_root)
|
|
563
|
+
|
|
564
|
+
test_file = analyzer._find_test_file(file_path)
|
|
565
|
+
if not test_file:
|
|
566
|
+
stats_list.append(None)
|
|
567
|
+
continue
|
|
568
|
+
|
|
569
|
+
stats, suggestions = analyzer.analyze_file(file_path)
|
|
570
|
+
stats_list.append(stats)
|
|
571
|
+
all_suggestions.extend(suggestions)
|
|
572
|
+
|
|
573
|
+
coverage_percent = stats.coverage_percent
|
|
574
|
+
|
|
575
|
+
if coverage_percent >= (fail_under or 0):
|
|
576
|
+
print(
|
|
577
|
+
f"\r[{i}/{len(source_files)}] ✓ {file_rel.name}", end="", flush=True
|
|
578
|
+
)
|
|
579
|
+
else:
|
|
580
|
+
if self._handle_step_coverage_failure(
|
|
581
|
+
i,
|
|
582
|
+
len(source_files),
|
|
583
|
+
file_path,
|
|
584
|
+
file_rel,
|
|
585
|
+
test_file,
|
|
586
|
+
suggestions,
|
|
587
|
+
fail_under or 0,
|
|
588
|
+
python_executable,
|
|
589
|
+
analyzer,
|
|
590
|
+
):
|
|
591
|
+
continue
|
|
592
|
+
|
|
593
|
+
print(f"\n\n[Coverage] All {len(source_files)} files passed ✓")
|
|
594
|
+
|
|
595
|
+
def _handle_step_coverage_failure(
|
|
596
|
+
self,
|
|
597
|
+
index: int,
|
|
598
|
+
total: int,
|
|
599
|
+
file_path: Path,
|
|
600
|
+
file_rel: Path,
|
|
601
|
+
test_file: Path,
|
|
602
|
+
suggestions: list[Any],
|
|
603
|
+
fail_under: float,
|
|
604
|
+
python_executable: str | None,
|
|
605
|
+
analyzer: CoverageAnalyzer | None,
|
|
606
|
+
) -> bool:
|
|
607
|
+
print()
|
|
608
|
+
print(f"[Coverage] {file_rel} is below threshold in current coverage data.")
|
|
609
|
+
recheck_passed = False
|
|
610
|
+
if analyzer is not None and python_executable is not None:
|
|
611
|
+
print(f"[Coverage] Rechecking with {test_file} before failing...")
|
|
612
|
+
# The per-file recheck parses its own temporary report through
|
|
613
|
+
# ``_read_coverage_json``, which overwrites the analyzer's shared
|
|
614
|
+
# ``coverage_data`` with a one-file report. Snapshot it here so the
|
|
615
|
+
# aggregate report keeps driving every later file's threshold
|
|
616
|
+
# decision; otherwise each remaining file reports a bogus 0% and
|
|
617
|
+
# triggers its own recheck (#182).
|
|
618
|
+
saved_coverage_data = analyzer.coverage_data
|
|
619
|
+
try:
|
|
620
|
+
recheck_passed = analyzer.run_coverage_for_file(
|
|
621
|
+
python_executable, file_path, fail_under=fail_under
|
|
622
|
+
)
|
|
623
|
+
finally:
|
|
624
|
+
analyzer.coverage_data = saved_coverage_data
|
|
625
|
+
|
|
626
|
+
if recheck_passed:
|
|
627
|
+
print(f"[Coverage] Recheck passed for {file_rel}; continuing analysis.")
|
|
628
|
+
return True
|
|
629
|
+
|
|
630
|
+
print(f"[Coverage] Recheck confirmed {file_rel} is below threshold.")
|
|
631
|
+
print(f"[{index}/{total}] ❌ FAIL: {file_rel}")
|
|
632
|
+
print("\n提高此文件测试覆盖率")
|
|
633
|
+
|
|
634
|
+
if suggestions:
|
|
635
|
+
print("\nSuggestions for improvement:")
|
|
636
|
+
severity_order = {"high": 0, "medium": 1, "low": 2}
|
|
637
|
+
sorted_suggestions = sorted(
|
|
638
|
+
suggestions, key=lambda x: severity_order.get(x.severity, 2)
|
|
639
|
+
)
|
|
640
|
+
for suggestion in sorted_suggestions:
|
|
641
|
+
print(f" [{suggestion.severity.upper()}] {suggestion.suggestion}")
|
|
642
|
+
|
|
643
|
+
print("\n[Coverage] Stopping at first failure as requested.")
|
|
644
|
+
sys.exit(1)
|
|
645
|
+
|
|
646
|
+
def _run_single_test_file(
|
|
647
|
+
self,
|
|
648
|
+
python_executable,
|
|
649
|
+
test_file,
|
|
650
|
+
project_root,
|
|
651
|
+
env,
|
|
652
|
+
debug_mode,
|
|
653
|
+
no_cache=False,
|
|
654
|
+
step_by_step=False,
|
|
655
|
+
concise=False,
|
|
656
|
+
):
|
|
657
|
+
"""Run single test file."""
|
|
658
|
+
return self.test_runner.single_test_executor.execute(
|
|
659
|
+
python_executable,
|
|
660
|
+
test_file,
|
|
661
|
+
project_root,
|
|
662
|
+
env,
|
|
663
|
+
debug_mode,
|
|
664
|
+
no_cache,
|
|
665
|
+
step_by_step,
|
|
666
|
+
concise,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
def _get_all_test_files(self, project_root):
|
|
670
|
+
"""Get all test files."""
|
|
671
|
+
return self.test_runner._get_all_test_files(project_root)
|
|
672
|
+
|
|
673
|
+
def _ensure_pytest_available(self, python_executable):
|
|
674
|
+
"""Ensure pytest is available."""
|
|
675
|
+
from .test_utils import _ensure_pytest_available
|
|
676
|
+
|
|
677
|
+
return _ensure_pytest_available(python_executable)
|
|
678
|
+
|
|
679
|
+
def _process_command_args(self, kwargs):
|
|
680
|
+
"""Process command arguments."""
|
|
681
|
+
from .test_utils import _process_command_args
|
|
682
|
+
|
|
683
|
+
return _process_command_args(self, kwargs)
|
|
684
|
+
|
|
685
|
+
def _build_test_command(
|
|
686
|
+
self, python_executable, test_file, debug_mode, step_by_step=False
|
|
687
|
+
):
|
|
688
|
+
"""Build test command."""
|
|
689
|
+
return self.test_runner.single_test_executor._build_test_command(
|
|
690
|
+
python_executable, test_file, debug_mode, step_by_step
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
@click.command(
|
|
695
|
+
# Everything wl doesn't define itself must reach pytest untouched (#192):
|
|
696
|
+
# ``wl test -k ...`` used to die with NoSuchOption instead of forwarding.
|
|
697
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True}
|
|
698
|
+
)
|
|
699
|
+
@click.option("--report", is_flag=True, help="Show detailed test report")
|
|
700
|
+
@click.option("--debug", is_flag=True, help="Run tests in debug mode")
|
|
701
|
+
@click.option("-s", "--step-by-step", is_flag=True, help="Run one test file at a time")
|
|
702
|
+
@click.option("--no-cache", is_flag=True, help="Skip cache and run all tests")
|
|
703
|
+
@click.option(
|
|
704
|
+
"--use-cache",
|
|
705
|
+
is_flag=True,
|
|
706
|
+
help=(
|
|
707
|
+
"Reuse cached test results (default in step-by-step mode; "
|
|
708
|
+
"plain 'wl test' defaults to --no-cache)"
|
|
709
|
+
),
|
|
710
|
+
)
|
|
711
|
+
@click.option("--clean-cache", is_flag=True, help="Clean all test cache before running")
|
|
712
|
+
@click.option("--cache-info", is_flag=True, help="Show cache information and exit")
|
|
713
|
+
@click.option("-q", "--quiet", is_flag=True, help="Suppress verbose output")
|
|
714
|
+
@click.option("-v", "--verbose", is_flag=True, help="Force verbose output")
|
|
715
|
+
@click.option("--cover", is_flag=True, help="Run coverage analysis")
|
|
716
|
+
@click.option(
|
|
717
|
+
"--fail-under", type=float, default=None, help="Override coverage threshold"
|
|
718
|
+
)
|
|
719
|
+
@click.option("--no-cover-cache", is_flag=True, help="Disable coverage caching")
|
|
720
|
+
@click.option(
|
|
721
|
+
"--cover-parallel-workers",
|
|
722
|
+
type=int,
|
|
723
|
+
default=None,
|
|
724
|
+
help="Number of parallel workers for coverage tests",
|
|
725
|
+
)
|
|
726
|
+
@click.option(
|
|
727
|
+
"--no-skip", is_flag=True, help="Don't skip slow test checks for known patterns"
|
|
728
|
+
)
|
|
729
|
+
@click.option(
|
|
730
|
+
"--evidence-json",
|
|
731
|
+
"evidence_json",
|
|
732
|
+
type=click.Path(dir_okay=False, path_type=str),
|
|
733
|
+
default=None,
|
|
734
|
+
metavar="PATH",
|
|
735
|
+
help=(
|
|
736
|
+
"Write structured evidence of this test run (executed files, cache "
|
|
737
|
+
"hits, exit code) to PATH, atomically"
|
|
738
|
+
),
|
|
739
|
+
)
|
|
740
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
741
|
+
def test_command(
|
|
742
|
+
report: bool,
|
|
743
|
+
debug: bool,
|
|
744
|
+
step_by_step: bool,
|
|
745
|
+
no_cache: bool,
|
|
746
|
+
use_cache: bool,
|
|
747
|
+
clean_cache: bool,
|
|
748
|
+
cache_info: bool,
|
|
749
|
+
quiet: bool,
|
|
750
|
+
verbose: bool,
|
|
751
|
+
cover: bool,
|
|
752
|
+
fail_under: float | None,
|
|
753
|
+
no_cover_cache: bool,
|
|
754
|
+
cover_parallel_workers: int | None,
|
|
755
|
+
evidence_json: str | None,
|
|
756
|
+
args: tuple[str, ...],
|
|
757
|
+
no_skip: bool = False,
|
|
758
|
+
) -> None:
|
|
759
|
+
"""Run this project's tests with pytest.
|
|
760
|
+
|
|
761
|
+
Plain "wl test" runs the whole suite as one pytest batch with the cache
|
|
762
|
+
off; --use-cache opts back in. Unrecognised options and positional
|
|
763
|
+
selectors are forwarded to pytest untouched. With -s / --step-by-step, wl
|
|
764
|
+
resolves the selectors to test files, runs them one file at a time, reuses
|
|
765
|
+
cached results, and stops at the first failure. -s, -q and -v are wl's own
|
|
766
|
+
switches and are never passed through to pytest.
|
|
767
|
+
"""
|
|
768
|
+
cmd = TestCommand()
|
|
769
|
+
cmd.execute(
|
|
770
|
+
report=report,
|
|
771
|
+
debug=debug,
|
|
772
|
+
step_by_step=step_by_step,
|
|
773
|
+
no_cache=no_cache,
|
|
774
|
+
use_cache=use_cache,
|
|
775
|
+
clean_cache=clean_cache,
|
|
776
|
+
cache_info=cache_info,
|
|
777
|
+
quiet=quiet,
|
|
778
|
+
verbose=verbose,
|
|
779
|
+
cover=cover,
|
|
780
|
+
fail_under=fail_under,
|
|
781
|
+
no_cover_cache=no_cover_cache,
|
|
782
|
+
cover_parallel_workers=cover_parallel_workers,
|
|
783
|
+
evidence_json=evidence_json,
|
|
784
|
+
no_skip=no_skip,
|
|
785
|
+
args=list(args),
|
|
786
|
+
)
|