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,1343 @@
|
|
|
1
|
+
"""Test runner module for test commands."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable, Iterator
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from xml.etree import ElementTree
|
|
13
|
+
|
|
14
|
+
from ...utils.logging import log_info_pair
|
|
15
|
+
from ...utils.output_context import is_concise_mode
|
|
16
|
+
from ...utils.run_evidence import TestRunEvidence
|
|
17
|
+
from .batch_processor import BatchProcessor
|
|
18
|
+
from .cache_manager import CacheManager
|
|
19
|
+
from .report_generator import ReportGenerator
|
|
20
|
+
from .single_test_executor import SingleTestExecutor
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Per-file attribution: which user passthrough args travel along (#183)
|
|
24
|
+
#
|
|
25
|
+
# The batch run produced its verdict *under these args*, so a per-file re-run
|
|
26
|
+
# that drops them can reach a different verdict for a file the user's selection
|
|
27
|
+
# had effectively passed: a failing test the user excluded with `-k` would
|
|
28
|
+
# still mark its file failed and drop it from the cache. Selection args
|
|
29
|
+
# therefore have to travel. Only the subset below does -- an allowlist, not
|
|
30
|
+
# "everything dash-shaped", because a half-forwarded option is worse than a
|
|
31
|
+
# dropped one: `-k` without its expression swallows the test path, pytest then
|
|
32
|
+
# collects nothing, exits 5, and `_process_test_result` reads that as success,
|
|
33
|
+
# caching a file that never ran.
|
|
34
|
+
#
|
|
35
|
+
# Two families are deliberately *out* of the allowlist (rv183):
|
|
36
|
+
# * warning filters (`-W` / `--pythonwarnings`): a single-file run does not
|
|
37
|
+
# reproduce the batch run's warning stack, so `-W error` here can fail a
|
|
38
|
+
# file the batch run passed -- attribution would drop a good file.
|
|
39
|
+
# * stateful selection (`--lf` / `--ff` / `--nf` / `--sw` and long forms):
|
|
40
|
+
# they read *and rewrite* `.pytest_cache` in the project root, so file N+1
|
|
41
|
+
# sees state file N left behind -- not the batch selection at all. It also
|
|
42
|
+
# cannot be forwarded completely (`--lfnf` is the missing half).
|
|
43
|
+
# The companion guard for the `rc == 5` case lives in
|
|
44
|
+
# `SingleTestExecutor.execute`: "no tests ran under a forwarded selection" is
|
|
45
|
+
# not evidence of a pass, so the file is left unattributed instead of cached.
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
#: Option spelling -> whether it consumes the following token as its value.
|
|
49
|
+
_ATTRIBUTION_SAFE_OPTIONS: dict[str, bool] = {
|
|
50
|
+
# Selection: the filter the batch verdict was produced under.
|
|
51
|
+
"-k": True,
|
|
52
|
+
"--keyword": True,
|
|
53
|
+
"-m": True,
|
|
54
|
+
"--markexpr": True,
|
|
55
|
+
"--deselect": True,
|
|
56
|
+
"--ignore": True,
|
|
57
|
+
"--ignore-glob": True,
|
|
58
|
+
# Failure / output switches. None of them can change a single file's
|
|
59
|
+
# pass/fail verdict: `-x` only stops earlier *within* a file that has
|
|
60
|
+
# already failed.
|
|
61
|
+
"-x": False,
|
|
62
|
+
"--exitfirst": False,
|
|
63
|
+
"--maxfail": True,
|
|
64
|
+
"-q": False,
|
|
65
|
+
"--quiet": False,
|
|
66
|
+
"-v": False,
|
|
67
|
+
"--verbose": False,
|
|
68
|
+
"-r": True,
|
|
69
|
+
"--tb": True,
|
|
70
|
+
"-l": False,
|
|
71
|
+
"--showlocals": False,
|
|
72
|
+
"--strict-markers": False,
|
|
73
|
+
"--strict-config": False,
|
|
74
|
+
# Configuration the batch run honoured.
|
|
75
|
+
"-o": True,
|
|
76
|
+
"--override-ini": True,
|
|
77
|
+
"-p": True,
|
|
78
|
+
"--plugin": True,
|
|
79
|
+
"-c": True,
|
|
80
|
+
"--config-file": True,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _attribution_args(passed_args: list[str]) -> list[str]:
|
|
85
|
+
"""Return the user passthrough args a per-file attribution run may reuse.
|
|
86
|
+
|
|
87
|
+
Forward only :data:`_ATTRIBUTION_SAFE_OPTIONS` and the values of the ones
|
|
88
|
+
that take a value. Deliberately *not* forwarded:
|
|
89
|
+
|
|
90
|
+
* positional selectors (``tests/subdir``, ``tests/x.py::TestA``): the
|
|
91
|
+
subprocess is already pinned to exactly one file, and re-adding a path
|
|
92
|
+
would run other files too -- their failures would be attributed to this
|
|
93
|
+
one.
|
|
94
|
+
* coverage flags (``--cov*``, ``--no-cov``): this pass exists to attribute
|
|
95
|
+
cache, not to collect coverage; the single-file executor already decides
|
|
96
|
+
``--no-cov`` for the interpreter that runs it.
|
|
97
|
+
* report artifacts (``--junitxml``, ``--html``, ``--cov-report``,
|
|
98
|
+
``--pastebin``): every file would overwrite the same artifact.
|
|
99
|
+
* warning filters (``-W``, ``--pythonwarnings``): the single-file command
|
|
100
|
+
does not reproduce the batch warning stack, so forwarding can fail a file
|
|
101
|
+
the batch run passed (rv183 M2).
|
|
102
|
+
* stateful selection (``--lf``, ``--ff``, ``--nf``, ``--sw``): their state
|
|
103
|
+
lives in ``.pytest_cache``, which each per-file run rewrites; the series
|
|
104
|
+
is also incomplete without ``--lfnf`` (rv183 M3).
|
|
105
|
+
* anything unrecognised: dropped whole, rather than half-forwarded.
|
|
106
|
+
"""
|
|
107
|
+
forwarded: list[str] = []
|
|
108
|
+
index = 0
|
|
109
|
+
while index < len(passed_args):
|
|
110
|
+
arg = passed_args[index]
|
|
111
|
+
index += 1
|
|
112
|
+
|
|
113
|
+
if not arg.startswith("-"):
|
|
114
|
+
continue # positional selector, see above
|
|
115
|
+
|
|
116
|
+
name, separator, _value = arg.partition("=")
|
|
117
|
+
takes_value = _ATTRIBUTION_SAFE_OPTIONS.get(name)
|
|
118
|
+
if takes_value is None:
|
|
119
|
+
# `-kexpr` / `-Werror`: a short option carrying its value inline.
|
|
120
|
+
if len(arg) > 2 and not arg.startswith("--"):
|
|
121
|
+
takes_value = _ATTRIBUTION_SAFE_OPTIONS.get(arg[:2])
|
|
122
|
+
if takes_value:
|
|
123
|
+
forwarded.append(arg)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if not takes_value or separator:
|
|
127
|
+
forwarded.append(arg)
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Never emit a value-taking option without its value: a dangling `-k`
|
|
131
|
+
# would consume the test path and leave nothing to run.
|
|
132
|
+
if index < len(passed_args) and not passed_args[index].startswith("-"):
|
|
133
|
+
forwarded.append(arg)
|
|
134
|
+
forwarded.append(passed_args[index])
|
|
135
|
+
index += 1
|
|
136
|
+
return forwarded
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Executed-file derivation (#191)
|
|
141
|
+
#
|
|
142
|
+
# A pytest process that exits 0 proves something about *the tests it ran*, and
|
|
143
|
+
# nothing about the rest of the tree. The cache used to record every file under
|
|
144
|
+
# tests/ after such a run, so `wl test --use-cache tests/test_a.py` marked a red
|
|
145
|
+
# tests/test_b.py as passing and the next `wl test -s` reported the suite green
|
|
146
|
+
# from the cache. The write-back set is now derived here -- and when it cannot
|
|
147
|
+
# be derived, nothing is written (fail-closed).
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
#: Directories a selector walk never descends into. A directory selector like
|
|
151
|
+
#: `.` must not turn a virtualenv's vendored `test_*.py` files into "tests this
|
|
152
|
+
#: run executed" (rv191 F3), and walking them is pure cost.
|
|
153
|
+
_SKIP_DIRECTORY_NAMES: frozenset[str] = frozenset(
|
|
154
|
+
{"__pycache__", "site-packages", "node_modules", "venv", "build", "dist"}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
#: Passthrough options a run may carry *and still write cache* -- an allowlist,
|
|
158
|
+
#: not a blocklist. Two rounds of blocklists taught the lesson (rv191: first
|
|
159
|
+
#: `--collect-only`, then `--setup-only`): every enumeration missed the next
|
|
160
|
+
#: pytest flag that changes what a green exit code means. Here the default is
|
|
161
|
+
#: "denied", so a new or unknown switch can only cost a cache write, never
|
|
162
|
+
#: produce a false green.
|
|
163
|
+
#:
|
|
164
|
+
#: Everything listed leaves a green exit code meaning what it says: the tests
|
|
165
|
+
#: the run selected did pass. Output/verbosity, fail-fast switches (a green `-x`
|
|
166
|
+
#: run still ran every selected test, and any failure would be non-green),
|
|
167
|
+
#: collection strictness (which can only turn a pass into an error), coverage
|
|
168
|
+
#: instrumentation, and parallelism (`-n`/`--dist` move where a test runs, not
|
|
169
|
+
#: whether it runs).
|
|
170
|
+
#: Deliberately absent: warning filters (`-W`/`--pythonwarnings`, see below),
|
|
171
|
+
#: selection filters (`-k`, `-m`, `--deselect`, `--ignore*`,
|
|
172
|
+
#: `--lf`/`--ff`/`--nf`/`--sw`), loading-chain switches (`--noconftest`, `-p`,
|
|
173
|
+
#: `-c`, `-o`, `--rootdir`, `--confcutdir`, `--import-mode`), non-executing
|
|
174
|
+
#: modes (`--collect-only`, `--setup-only`, `--setup-plan`, `--version`,
|
|
175
|
+
#: `--fixtures`, `--markers`, `--help`, `--trace-config`), report destinations
|
|
176
|
+
#: (`--junitxml`/`--junit-xml`, `--html`, `--pastebin`) and seed/order flags
|
|
177
|
+
#: (`--randomly-seed`).
|
|
178
|
+
#:
|
|
179
|
+
#: Warning filters are denied in both directions (#199 rv199). A filter can
|
|
180
|
+
#: *weaken* the verdict -- with `filterwarnings = error` in the project config,
|
|
181
|
+
#: `-W ignore` turns a red file green -- and the cache key cannot see a
|
|
182
|
+
#: command-line switch, so storing that green under the plain key let the next
|
|
183
|
+
#: unfiltered run answer "using cache" over a suite that is red. Storing only
|
|
184
|
+
#: under "stricter" filters was the earlier idea and it does not hold: only a
|
|
185
|
+
#: bare `error` is stricter than the project's own setting, and a filtered run
|
|
186
|
+
#: can never read cache anyway, so the write bought one hit for a plainer run --
|
|
187
|
+
#: exactly the unsound direction. `-W` stays in
|
|
188
|
+
#: :data:`_VALUE_TAKING_OPTIONS` so its value is not mistaken for a selector.
|
|
189
|
+
#:
|
|
190
|
+
#: Short-option spellings matter here too (#199): `-rA`, `-vv` and `-n4` are
|
|
191
|
+
#: the same switches as `-r A`, `-v -v` and `-n 4`, so
|
|
192
|
+
#: :func:`_short_option_name` reduces them to their two-character name before
|
|
193
|
+
#: this set is consulted. Keeping the inline spellings denied only meant that
|
|
194
|
+
#: `wl test -- -rA` and `wl test -- -vv` (ordinary habits) silently stopped
|
|
195
|
+
#: caching.
|
|
196
|
+
_CACHE_SAFE_OPTIONS: frozenset[str] = frozenset(
|
|
197
|
+
{
|
|
198
|
+
# Output and verbosity.
|
|
199
|
+
"-q",
|
|
200
|
+
"--quiet",
|
|
201
|
+
"-v",
|
|
202
|
+
"--verbose",
|
|
203
|
+
"--no-header",
|
|
204
|
+
"--no-summary",
|
|
205
|
+
"--disable-warnings",
|
|
206
|
+
"-r",
|
|
207
|
+
"--tb",
|
|
208
|
+
"-l",
|
|
209
|
+
"--showlocals",
|
|
210
|
+
"--color",
|
|
211
|
+
"--code-highlight",
|
|
212
|
+
"--durations",
|
|
213
|
+
"--durations-min",
|
|
214
|
+
"--log-level",
|
|
215
|
+
"--log-file",
|
|
216
|
+
"--log-format",
|
|
217
|
+
"--log-date-format",
|
|
218
|
+
"-s",
|
|
219
|
+
"--capture",
|
|
220
|
+
# Fail-fast: green still means every selected test ran and passed.
|
|
221
|
+
"-x",
|
|
222
|
+
"--exitfirst",
|
|
223
|
+
"--maxfail",
|
|
224
|
+
# Collection strictness: can only turn a pass into an error.
|
|
225
|
+
"--strict-markers",
|
|
226
|
+
"--strict-config",
|
|
227
|
+
# Parallelism: changes where a test runs, not whether it runs.
|
|
228
|
+
"-n",
|
|
229
|
+
"--numprocesses",
|
|
230
|
+
"--dist",
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
#: Prefix allowlist for the coverage family (`--cov`, `--cov-report=...`,
|
|
235
|
+
#: `--cov-fail-under=...`, `--no-cov`, `--no-cov-on-fail`). Coverage changes
|
|
236
|
+
#: instrumentation and exit status, not a test's verdict.
|
|
237
|
+
_CACHE_SAFE_OPTION_PREFIXES: tuple[str, ...] = ("--cov", "--no-cov")
|
|
238
|
+
|
|
239
|
+
#: Spellings of pytest's junit report destination. Both must be recognised: the
|
|
240
|
+
#: runner adds a report of its own to observe the run, and writing that over a
|
|
241
|
+
#: user-supplied path would silently discard their file (rv191 F2).
|
|
242
|
+
_JUNIT_REPORT_OPTIONS: frozenset[str] = frozenset({"--junitxml", "--junit-xml"})
|
|
243
|
+
|
|
244
|
+
#: Options that consume the following token as their value. Used only to keep
|
|
245
|
+
#: an option's value from being mistaken for a path selector; deliberately a
|
|
246
|
+
#: superset of the attribution allowlist, because an unrecognised value option
|
|
247
|
+
#: would otherwise turn its value into a selector that does not exist.
|
|
248
|
+
_VALUE_TAKING_OPTIONS: frozenset[str] = frozenset(
|
|
249
|
+
{
|
|
250
|
+
*(
|
|
251
|
+
name
|
|
252
|
+
for name, takes_value in _ATTRIBUTION_SAFE_OPTIONS.items()
|
|
253
|
+
if takes_value
|
|
254
|
+
),
|
|
255
|
+
"-W",
|
|
256
|
+
"--pythonwarnings",
|
|
257
|
+
"--durations",
|
|
258
|
+
"--durations-min",
|
|
259
|
+
"--rootdir",
|
|
260
|
+
"--confcutdir",
|
|
261
|
+
"--junitxml",
|
|
262
|
+
"--junit-xml",
|
|
263
|
+
"--junit-prefix",
|
|
264
|
+
"--color",
|
|
265
|
+
"--capture",
|
|
266
|
+
"-n",
|
|
267
|
+
"--numprocesses",
|
|
268
|
+
"--dist",
|
|
269
|
+
"--timeout",
|
|
270
|
+
"--basetemp",
|
|
271
|
+
"--import-mode",
|
|
272
|
+
"--log-level",
|
|
273
|
+
"--log-file",
|
|
274
|
+
"--log-format",
|
|
275
|
+
"--log-date-format",
|
|
276
|
+
"-a",
|
|
277
|
+
"--alluredir",
|
|
278
|
+
"--randomly-seed",
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _short_option_name(arg: str) -> str | None:
|
|
284
|
+
"""The two-character name of an inline short option, or ``None``.
|
|
285
|
+
|
|
286
|
+
``-rA``, ``-Werror`` and ``-n4`` carry their value in the same token;
|
|
287
|
+
``-vv``/``-qq`` repeat a flag. In every case pytest reads the option as
|
|
288
|
+
``arg[:2]`` plus a remainder that is a value or a repetition, so the
|
|
289
|
+
allowlist must be asked about the two-character name -- asking about the
|
|
290
|
+
raw token denied ordinary habits like ``-rA`` and ``-vv`` (#199). The
|
|
291
|
+
remainder never changes whether the run executes tests: for a value-taking
|
|
292
|
+
option it *is* the value, and for a flag it is the flag repeated.
|
|
293
|
+
"""
|
|
294
|
+
if len(arg) <= 2 or arg.startswith("--"):
|
|
295
|
+
return None
|
|
296
|
+
return arg[:2]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _split_passthrough_args(passed_args: list[str]) -> tuple[list[str], list[str]]:
|
|
300
|
+
"""Split the user's passthrough args into (option names, path selectors).
|
|
301
|
+
|
|
302
|
+
Option values are skipped using :data:`_VALUE_TAKING_OPTIONS`; an unknown
|
|
303
|
+
option's would-be value stays a selector, which then fails to resolve and
|
|
304
|
+
makes the whole selection "unknown" -- the safe direction.
|
|
305
|
+
|
|
306
|
+
Inline short options are reported by their canonical two-character name
|
|
307
|
+
(:func:`_short_option_name`), so ``-rA`` is vetted as ``-r``.
|
|
308
|
+
"""
|
|
309
|
+
option_names: list[str] = []
|
|
310
|
+
selectors: list[str] = []
|
|
311
|
+
index = 0
|
|
312
|
+
while index < len(passed_args):
|
|
313
|
+
arg = passed_args[index]
|
|
314
|
+
index += 1
|
|
315
|
+
if not arg.startswith("-") or arg == "-":
|
|
316
|
+
selectors.append(arg)
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
name, separator, _value = arg.partition("=")
|
|
320
|
+
if separator:
|
|
321
|
+
option_names.append(name)
|
|
322
|
+
continue
|
|
323
|
+
if name in _VALUE_TAKING_OPTIONS:
|
|
324
|
+
option_names.append(name)
|
|
325
|
+
if index < len(passed_args):
|
|
326
|
+
index += 1 # this token is the option's value, not a selector
|
|
327
|
+
continue
|
|
328
|
+
short = _short_option_name(arg)
|
|
329
|
+
option_names.append(short if short is not None else name)
|
|
330
|
+
return option_names, selectors
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _selector_spelling(path: Path, project_root: str) -> str:
|
|
334
|
+
"""A path as the user would spell it on the command line."""
|
|
335
|
+
try:
|
|
336
|
+
relative = path.relative_to(project_root)
|
|
337
|
+
except ValueError:
|
|
338
|
+
relative = path
|
|
339
|
+
return str(relative).replace("\\", "/")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _selector_path(selector: str, project_root: str) -> Path:
|
|
343
|
+
"""Resolve a selector the way pytest does: relative to the project root."""
|
|
344
|
+
path = Path(selector)
|
|
345
|
+
if not path.is_absolute():
|
|
346
|
+
path = Path(project_root) / path
|
|
347
|
+
return path
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _files_for_selector(
|
|
351
|
+
token: str, known: dict[str, Path], project_root: str
|
|
352
|
+
) -> list[Path] | None:
|
|
353
|
+
"""Resolve one already-normalised path selector to the files it names.
|
|
354
|
+
|
|
355
|
+
Returns ``None`` when the selector names nothing this run can vouch for;
|
|
356
|
+
the caller turns that into "the file set is unknown".
|
|
357
|
+
"""
|
|
358
|
+
match = known.get(token)
|
|
359
|
+
if match is not None:
|
|
360
|
+
return [match]
|
|
361
|
+
|
|
362
|
+
if any(spelling == token or spelling.startswith(f"{token}/") for spelling in known):
|
|
363
|
+
# A directory selector: every known file underneath it.
|
|
364
|
+
return [
|
|
365
|
+
test_file
|
|
366
|
+
for spelling, test_file in known.items()
|
|
367
|
+
if spelling.startswith(f"{token}/")
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
resolved = _selector_path(token, project_root)
|
|
371
|
+
if resolved.is_dir():
|
|
372
|
+
# Only files the project's own test tree knows about: a directory
|
|
373
|
+
# selector covers `.`, and `.` is where a virtualenv keeps thousands of
|
|
374
|
+
# vendored `test_*.py` files that were never this project's tests
|
|
375
|
+
# (rv191 F3).
|
|
376
|
+
chosen = [
|
|
377
|
+
known[spelling]
|
|
378
|
+
for discovered in _iter_python_files(resolved)
|
|
379
|
+
for spelling in (_selector_spelling(discovered, project_root),)
|
|
380
|
+
if spelling in known
|
|
381
|
+
]
|
|
382
|
+
return chosen or None
|
|
383
|
+
if resolved.is_file():
|
|
384
|
+
return [resolved]
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _iter_python_files(directory: Path) -> Iterator[Path]:
|
|
389
|
+
"""Yield ``*.py`` files under *directory*, skipping vendored trees.
|
|
390
|
+
|
|
391
|
+
Hidden directories (``.venv``, ``.git``, ...) and the usual dependency
|
|
392
|
+
directories are pruned rather than filtered afterwards, so a selector like
|
|
393
|
+
`.` costs one walk of the project's own sources.
|
|
394
|
+
"""
|
|
395
|
+
for current, dirnames, filenames in os.walk(directory):
|
|
396
|
+
dirnames[:] = sorted(
|
|
397
|
+
name
|
|
398
|
+
for name in dirnames
|
|
399
|
+
if not name.startswith(".") and name not in _SKIP_DIRECTORY_NAMES
|
|
400
|
+
)
|
|
401
|
+
for name in sorted(filenames):
|
|
402
|
+
if name.endswith(".py") and name != "__init__.py":
|
|
403
|
+
yield Path(current) / name
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _executed_test_files(
|
|
407
|
+
passed_args: list[str], project_root: str, all_test_files: list[Path]
|
|
408
|
+
) -> list[Path] | None:
|
|
409
|
+
"""The test files this run executed, or ``None`` when that is unknown.
|
|
410
|
+
|
|
411
|
+
``None`` means "cannot be determined" and the caller must not write cache:
|
|
412
|
+
a green exit code from a run whose file set is unknown is not evidence
|
|
413
|
+
about any particular file. Concretely this covers a filtered run
|
|
414
|
+
(``-k``/``-m``/``--deselect``/... -- the verdict is about a subset of each
|
|
415
|
+
file's tests), a run whose loading chain or execution mode was switched
|
|
416
|
+
(``--noconftest``, ``-p``, ``--collect-only``, ...), a nodeid selector
|
|
417
|
+
(``tests/test_a.py::TestA`` -- a slice of a file), and a selector that does
|
|
418
|
+
not resolve to anything.
|
|
419
|
+
|
|
420
|
+
With no positional selector the default selection is the whole ``tests/``
|
|
421
|
+
tree, which is exactly ``all_test_files``.
|
|
422
|
+
|
|
423
|
+
``PYTEST_ADDOPTS`` is not consulted for the selection: an addopts that
|
|
424
|
+
could narrow it (a filter or a positional selector) makes the whole run
|
|
425
|
+
uncacheable in :func:`_addopts_cache_blocker` (#199), and an addopts that
|
|
426
|
+
survives that gate contains only switches that cannot select.
|
|
427
|
+
"""
|
|
428
|
+
option_names, selectors = _split_passthrough_args(passed_args)
|
|
429
|
+
if _unsafe_option(option_names) is not None:
|
|
430
|
+
return None
|
|
431
|
+
if not selectors:
|
|
432
|
+
return list(all_test_files)
|
|
433
|
+
|
|
434
|
+
known = {
|
|
435
|
+
_selector_spelling(test_file, project_root): test_file
|
|
436
|
+
for test_file in all_test_files
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
executed: list[Path] = []
|
|
440
|
+
seen: set[Path] = set()
|
|
441
|
+
for selector in selectors:
|
|
442
|
+
if "::" in selector:
|
|
443
|
+
# A nodeid ran part of the file; a whole-file pass was never proven.
|
|
444
|
+
return None
|
|
445
|
+
token = selector.replace("\\", "/")
|
|
446
|
+
while token.startswith("./"):
|
|
447
|
+
token = token[2:]
|
|
448
|
+
token = token.rstrip("/")
|
|
449
|
+
|
|
450
|
+
chosen = _files_for_selector(token, known, project_root)
|
|
451
|
+
if chosen is None:
|
|
452
|
+
return None
|
|
453
|
+
for path in chosen:
|
|
454
|
+
if path not in seen:
|
|
455
|
+
seen.add(path)
|
|
456
|
+
executed.append(path)
|
|
457
|
+
return executed
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _unsafe_option(option_names: list[str]) -> str | None:
|
|
461
|
+
"""The first passthrough option that is not on the cache-safe allowlist.
|
|
462
|
+
|
|
463
|
+
``None`` means every option in the run is vetted. Anything else -- a
|
|
464
|
+
selection filter, a loading-chain switch, a collection-only mode, or a flag
|
|
465
|
+
nobody has thought about yet -- makes the run uncacheable.
|
|
466
|
+
"""
|
|
467
|
+
for name in option_names:
|
|
468
|
+
if name in _CACHE_SAFE_OPTIONS:
|
|
469
|
+
continue
|
|
470
|
+
if name.startswith(_CACHE_SAFE_OPTION_PREFIXES):
|
|
471
|
+
continue
|
|
472
|
+
return name
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _cache_write_blocker(option_names: list[str]) -> str | None:
|
|
477
|
+
"""Why this run may not write cache at all, or ``None`` when it may."""
|
|
478
|
+
unsafe = _unsafe_option(option_names)
|
|
479
|
+
if unsafe is None:
|
|
480
|
+
return None
|
|
481
|
+
return (
|
|
482
|
+
f"the run used {unsafe}, which is not on the cache-safe allowlist of "
|
|
483
|
+
"pytest switches"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _addopts_cache_blocker(env: dict[str, str] | None) -> str | None:
|
|
488
|
+
"""Why ``PYTEST_ADDOPTS`` makes this run uncacheable, or ``None``.
|
|
489
|
+
|
|
490
|
+
pytest prepends ``PYTEST_ADDOPTS`` to *every* command line, so it is a
|
|
491
|
+
second door through which a switch reaches the run -- and until #199 it was
|
|
492
|
+
the unwatched one: the cache key included the variable's value, but nothing
|
|
493
|
+
checked what the value *meant*. ``PYTEST_ADDOPTS=--collect-only`` therefore
|
|
494
|
+
exited 0 without executing a test while the allowlist, which only ever saw
|
|
495
|
+
the command line, raised no objection.
|
|
496
|
+
|
|
497
|
+
The value is split exactly as pytest splits it (``shlex.split``) and vetted
|
|
498
|
+
against the same allowlist. A positional token counts as unsafe here even
|
|
499
|
+
though the command line treats it as a selector: addopts selectors would
|
|
500
|
+
narrow the run behind the runner's back, so its file set is unknowable.
|
|
501
|
+
An unparsable value is denied outright.
|
|
502
|
+
"""
|
|
503
|
+
raw = (env or {}).get("PYTEST_ADDOPTS", "")
|
|
504
|
+
if not raw.strip():
|
|
505
|
+
return None
|
|
506
|
+
try:
|
|
507
|
+
tokens = shlex.split(raw)
|
|
508
|
+
except ValueError:
|
|
509
|
+
return (
|
|
510
|
+
"PYTEST_ADDOPTS cannot be parsed, so the run it produces cannot be vetted"
|
|
511
|
+
)
|
|
512
|
+
option_names, selectors = _split_passthrough_args(tokens)
|
|
513
|
+
unsafe = _unsafe_option(option_names)
|
|
514
|
+
if unsafe is not None:
|
|
515
|
+
return (
|
|
516
|
+
f"PYTEST_ADDOPTS injects {unsafe}, which is not on the cache-safe "
|
|
517
|
+
"allowlist of pytest switches"
|
|
518
|
+
)
|
|
519
|
+
if selectors:
|
|
520
|
+
return (
|
|
521
|
+
"PYTEST_ADDOPTS injects a positional selector, so this run's test "
|
|
522
|
+
"files cannot be determined"
|
|
523
|
+
)
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _execution_report(
|
|
528
|
+
passed_args: list[str], report_mode: bool
|
|
529
|
+
) -> tuple[Path | None, bool]:
|
|
530
|
+
"""Where to read this run's junit report, and whether we own the file.
|
|
531
|
+
|
|
532
|
+
``(None, False)`` means the run cannot be observed: the user asked for
|
|
533
|
+
their own junit report (``--junitxml`` or its ``--junit-xml`` alias), and
|
|
534
|
+
writing ours over their path would silently discard their file -- losing
|
|
535
|
+
the cache for one run is the cheaper mistake. In report mode the command
|
|
536
|
+
builder already writes ``todos/junit.xml``; otherwise the runner points
|
|
537
|
+
pytest at a fresh temporary file, so a leftover report can never be read as
|
|
538
|
+
this run's.
|
|
539
|
+
|
|
540
|
+
The report is the *positive signal* the cache write needs (rv191 F1): pytest
|
|
541
|
+
writes one test case per executed test, and writes none for
|
|
542
|
+
``--collect-only``, ``--version`` or ``--fixtures``.
|
|
543
|
+
"""
|
|
544
|
+
option_names, _selectors = _split_passthrough_args(passed_args)
|
|
545
|
+
if _JUNIT_REPORT_OPTIONS.intersection(option_names):
|
|
546
|
+
return None, False
|
|
547
|
+
if report_mode:
|
|
548
|
+
# TestCommandBuilder's report-mode target, relative to the process CWD.
|
|
549
|
+
return Path("todos") / "junit.xml", False
|
|
550
|
+
handle, name = tempfile.mkstemp(prefix="wl-test-report-", suffix=".xml")
|
|
551
|
+
os.close(handle)
|
|
552
|
+
return Path(name), True
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _case_spelling(case: ElementTree.Element, project_root: str) -> str | None:
|
|
556
|
+
"""The ``file`` attribute of a junit ``<testcase>``, normalised."""
|
|
557
|
+
file_attr = case.get("file")
|
|
558
|
+
if not file_attr:
|
|
559
|
+
return None
|
|
560
|
+
token = file_attr.replace("\\", "/")
|
|
561
|
+
while token.startswith("./"):
|
|
562
|
+
token = token[2:]
|
|
563
|
+
if Path(token).is_absolute():
|
|
564
|
+
return _selector_spelling(Path(token), project_root)
|
|
565
|
+
return token
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _match_classname(classname: str, by_module: dict[str, Path]) -> Path | None:
|
|
569
|
+
"""Match a junit ``classname`` to a known test file by module prefix.
|
|
570
|
+
|
|
571
|
+
pytest's junit report often omits ``file``; the ``classname`` is the module
|
|
572
|
+
path (``tests.test_a``), optionally suffixed by the test class. Longest
|
|
573
|
+
prefix wins, so ``pkg.tests.test_a`` is not shadowed by ``pkg.tests``.
|
|
574
|
+
"""
|
|
575
|
+
for prefix, test_file in by_module.items():
|
|
576
|
+
if classname == prefix or classname.startswith(f"{prefix}."):
|
|
577
|
+
return test_file
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _report_outcome_counts_agree(report_root: ElementTree.Element) -> bool:
|
|
582
|
+
"""Whether every ``<testcase>`` in the report carries a counted outcome.
|
|
583
|
+
|
|
584
|
+
pytest stamps each ``<testsuite>`` with ``tests``: the number of outcomes
|
|
585
|
+
the session recorded (passed + failed + skipped + error). It emits one
|
|
586
|
+
``<testcase>`` element per item it *touched*. For a run that actually
|
|
587
|
+
executes its tests the two numbers agree -- verified against ``-x``, xdist,
|
|
588
|
+
teardown errors, xfail and parametrisation -- but ``--setup-only`` touches
|
|
589
|
+
every item while recording an outcome only for the items it skips, so its
|
|
590
|
+
``tests`` count falls short of the testcase count.
|
|
591
|
+
|
|
592
|
+
That shortfall is the report's *own* proof that the run did not execute
|
|
593
|
+
what it lists. Checking it here makes the evidence defence independent of
|
|
594
|
+
the switch allowlist (#199): a ``--setup-only`` run is already denied
|
|
595
|
+
before it starts, but if a future whitelist change or a `PYTEST_ADDOPTS`
|
|
596
|
+
path ever let it through, this gate still refuses to read its report as
|
|
597
|
+
proof of execution. A report with no ``<testsuite>`` at all proves nothing.
|
|
598
|
+
"""
|
|
599
|
+
suites = list(report_root.iter("testsuite"))
|
|
600
|
+
if not suites:
|
|
601
|
+
return False
|
|
602
|
+
for suite in suites:
|
|
603
|
+
declared = suite.get("tests")
|
|
604
|
+
if declared is None:
|
|
605
|
+
# pytest always writes it; a report without it cannot be verified.
|
|
606
|
+
return False
|
|
607
|
+
try:
|
|
608
|
+
declared_count = int(declared)
|
|
609
|
+
except ValueError:
|
|
610
|
+
return False
|
|
611
|
+
if declared_count != len(suite.findall("testcase")):
|
|
612
|
+
return False
|
|
613
|
+
return True
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _executed_cases(report_root: ElementTree.Element) -> Iterator[ElementTree.Element]:
|
|
617
|
+
"""The report's testcases that carry a verdict.
|
|
618
|
+
|
|
619
|
+
Skipped cases are dropped here: pytest records a skip when it evaluated a
|
|
620
|
+
skip marker or a missing dependency, so the body never ran and the file was
|
|
621
|
+
never proven to pass (#199). The junit XML cannot distinguish a skip raised
|
|
622
|
+
inside the body from one raised at setup, so the conservative reading is
|
|
623
|
+
the only safe one -- a skip is a decision, not a verdict.
|
|
624
|
+
"""
|
|
625
|
+
return (
|
|
626
|
+
case for case in report_root.iter("testcase") if case.find("skipped") is None
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _executed_files_from_report(
|
|
631
|
+
report_path: Path | None,
|
|
632
|
+
project_root: str,
|
|
633
|
+
known: dict[str, Path],
|
|
634
|
+
started_at: float,
|
|
635
|
+
) -> list[Path]:
|
|
636
|
+
"""The known test files that pytest's own report proves it executed.
|
|
637
|
+
|
|
638
|
+
Returns ``[]`` -- "no proof" -- for a missing, stale, empty or unparsable
|
|
639
|
+
report, for a report whose ``tests`` counts do not match its testcase
|
|
640
|
+
elements (:func:`_report_outcome_counts_agree`), and for a report with zero
|
|
641
|
+
test cases. A file named in the report but outside the project's test tree
|
|
642
|
+
is ignored: only files this project knows as tests may ever be cached
|
|
643
|
+
(rv191 F3).
|
|
644
|
+
|
|
645
|
+
A skipped testcase is not evidence either (#199): pytest records a skip
|
|
646
|
+
when it evaluated a skip marker or a missing dependency -- the test body
|
|
647
|
+
never ran -- so an all-skipped file would otherwise be cached as passing,
|
|
648
|
+
and would keep reading "passed" after the skip condition changed. The
|
|
649
|
+
junit XML does not distinguish a skip raised inside the body from one
|
|
650
|
+
raised at setup, so the conservative reading is the only safe one: a skip
|
|
651
|
+
is a decision, not a verdict.
|
|
652
|
+
"""
|
|
653
|
+
if report_path is None:
|
|
654
|
+
return []
|
|
655
|
+
try:
|
|
656
|
+
stat = report_path.stat()
|
|
657
|
+
except OSError:
|
|
658
|
+
return []
|
|
659
|
+
if stat.st_size == 0 or stat.st_mtime < started_at - 1.0:
|
|
660
|
+
return []
|
|
661
|
+
try:
|
|
662
|
+
report_root = ElementTree.parse(report_path).getroot()
|
|
663
|
+
except (ElementTree.ParseError, OSError):
|
|
664
|
+
return []
|
|
665
|
+
if not _report_outcome_counts_agree(report_root):
|
|
666
|
+
return []
|
|
667
|
+
|
|
668
|
+
by_resolved: dict[Path, Path] = {}
|
|
669
|
+
for test_file in known.values():
|
|
670
|
+
try:
|
|
671
|
+
by_resolved[test_file.resolve()] = test_file
|
|
672
|
+
except OSError: # pragma: no cover - unresolvable path, never matched
|
|
673
|
+
continue
|
|
674
|
+
|
|
675
|
+
by_module = {
|
|
676
|
+
spelling[: -len(".py")].replace("/", "."): test_file
|
|
677
|
+
for spelling, test_file in sorted(
|
|
678
|
+
known.items(), key=lambda item: len(item[0]), reverse=True
|
|
679
|
+
)
|
|
680
|
+
if spelling.endswith(".py")
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
executed: list[Path] = []
|
|
684
|
+
seen: set[Path] = set()
|
|
685
|
+
for case in _executed_cases(report_root):
|
|
686
|
+
spelling = _case_spelling(case, project_root)
|
|
687
|
+
matched: Path | None = None
|
|
688
|
+
if spelling is not None:
|
|
689
|
+
matched = known.get(spelling)
|
|
690
|
+
if matched is None:
|
|
691
|
+
try:
|
|
692
|
+
matched = by_resolved.get(
|
|
693
|
+
_selector_path(spelling, project_root).resolve()
|
|
694
|
+
)
|
|
695
|
+
except OSError: # pragma: no cover - unresolvable path
|
|
696
|
+
matched = None
|
|
697
|
+
if matched is None:
|
|
698
|
+
matched = _match_classname(case.get("classname") or "", by_module)
|
|
699
|
+
if matched is None or matched in seen:
|
|
700
|
+
continue
|
|
701
|
+
seen.add(matched)
|
|
702
|
+
executed.append(matched)
|
|
703
|
+
return executed
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _write_run_evidence(
|
|
707
|
+
path: Path | None,
|
|
708
|
+
*,
|
|
709
|
+
project_root: str,
|
|
710
|
+
started_at: float,
|
|
711
|
+
executed_files: list[Path],
|
|
712
|
+
cache_hits: int,
|
|
713
|
+
cache_total: int,
|
|
714
|
+
no_cache_requested: bool,
|
|
715
|
+
cacheable: bool,
|
|
716
|
+
cache_blocked_reason: str | None,
|
|
717
|
+
exit_code: int,
|
|
718
|
+
) -> None:
|
|
719
|
+
"""Write this run's structured evidence without disturbing the run.
|
|
720
|
+
|
|
721
|
+
``--evidence-json`` is an observation channel, not part of ``wl test``'s
|
|
722
|
+
exit-code contract: a write failure is reported on stderr and otherwise
|
|
723
|
+
ignored, exactly as a human-readable line would be. The gate reads the
|
|
724
|
+
absence as red -- the fail-closed direction, since a run nobody could
|
|
725
|
+
observe is not a run the gate can vouch for (ADR-0010).
|
|
726
|
+
|
|
727
|
+
``executed_files`` are rendered relative to *project_root* (the same
|
|
728
|
+
spelling the user sees), and ``cache_total`` is the denominator of the
|
|
729
|
+
"cache usage" fact: the files this run considered for cache reuse.
|
|
730
|
+
"""
|
|
731
|
+
if path is None:
|
|
732
|
+
return
|
|
733
|
+
evidence = TestRunEvidence(
|
|
734
|
+
executed_files=tuple(
|
|
735
|
+
_selector_spelling(test_file, project_root) for test_file in executed_files
|
|
736
|
+
),
|
|
737
|
+
cache_hits=cache_hits,
|
|
738
|
+
cache_total=cache_total,
|
|
739
|
+
no_cache_requested=no_cache_requested,
|
|
740
|
+
cacheable=cacheable,
|
|
741
|
+
cache_blocked_reason=cache_blocked_reason,
|
|
742
|
+
exit_code=exit_code,
|
|
743
|
+
started_at=started_at,
|
|
744
|
+
finished_at=time.time(),
|
|
745
|
+
)
|
|
746
|
+
try:
|
|
747
|
+
evidence.write(path)
|
|
748
|
+
except OSError as error:
|
|
749
|
+
print(
|
|
750
|
+
f"[Evidence] Failed to write run evidence to {path}: {error}",
|
|
751
|
+
file=sys.stderr,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
class TestRunner:
|
|
756
|
+
"""Test runner for executing tests."""
|
|
757
|
+
|
|
758
|
+
def __init__(self, cache_manager: CacheManager | None = None):
|
|
759
|
+
"""Initialize TestRunner with dependencies.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
cache_manager: Optional shared CacheManager. If omitted, creates a new one.
|
|
763
|
+
"""
|
|
764
|
+
self.cache_manager = cache_manager or CacheManager()
|
|
765
|
+
self.batch_processor = BatchProcessor(self.cache_manager)
|
|
766
|
+
self.single_test_executor = SingleTestExecutor(self.cache_manager)
|
|
767
|
+
self.report_generator = ReportGenerator()
|
|
768
|
+
|
|
769
|
+
def _run_all_tests(
|
|
770
|
+
self,
|
|
771
|
+
python_executable: str,
|
|
772
|
+
project_root: str,
|
|
773
|
+
env: dict[str, str],
|
|
774
|
+
report_mode: bool,
|
|
775
|
+
passed_args: list[str],
|
|
776
|
+
debug_mode: bool,
|
|
777
|
+
cache_report: Path | None = None,
|
|
778
|
+
) -> subprocess.CompletedProcess:
|
|
779
|
+
"""Run all tests and return the result.
|
|
780
|
+
|
|
781
|
+
``cache_report`` is the path pytest should write its junit XML to so the
|
|
782
|
+
runner can tell *which tests executed* -- the positive signal a cache
|
|
783
|
+
write requires (rv191). It is ``None`` in report mode (the command
|
|
784
|
+
builder already writes ``todos/junit.xml``) and when the user owns the
|
|
785
|
+
``--junitxml`` destination.
|
|
786
|
+
"""
|
|
787
|
+
from .python_env_detector import PythonEnvDetector
|
|
788
|
+
from .test_command_builder import TestCommandBuilder
|
|
789
|
+
|
|
790
|
+
python_detector = PythonEnvDetector()
|
|
791
|
+
command_builder = TestCommandBuilder(python_detector)
|
|
792
|
+
|
|
793
|
+
cmd_args = command_builder.build_command_args(
|
|
794
|
+
report_mode,
|
|
795
|
+
passed_args,
|
|
796
|
+
debug_mode,
|
|
797
|
+
python_executable=python_executable,
|
|
798
|
+
env=env,
|
|
799
|
+
# 复用调用方已解析的 root,而不是让 builder 再解析一次(rv192b NIT):
|
|
800
|
+
# 同一个值既是 pytest 的 cwd,也是 `--cov` 的目标,两者不可能指向
|
|
801
|
+
# 不同的项目。
|
|
802
|
+
project_root=Path(project_root),
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
# 解释器与执行用 env 在 build_command_args 内部就已确定:pytest-cov 探针
|
|
806
|
+
# 问的是同一个 `python_executable`、拿的是同一份 `env`,此处不再事后改写
|
|
807
|
+
# `cmd_args[0]`——那正是「判定者与执行者不是同一个解释器」的来路。
|
|
808
|
+
|
|
809
|
+
if cache_report is not None:
|
|
810
|
+
# 核心 pytest 自带的 junit XML:每个真正执行的用例写一条
|
|
811
|
+
# <testcase>,`--collect-only` / `--version` / `--fixtures` 则一条
|
|
812
|
+
# 也不写。缓存写回只认这份「确有执行」的正向信号(rv191 F1)。
|
|
813
|
+
cmd_args.append(f"--junitxml={cache_report}")
|
|
814
|
+
|
|
815
|
+
# In all modes, pass through stdout/stderr to preserve colors and formatting
|
|
816
|
+
result = subprocess.run(
|
|
817
|
+
cmd_args,
|
|
818
|
+
cwd=project_root,
|
|
819
|
+
check=False,
|
|
820
|
+
env=env,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
return result
|
|
824
|
+
|
|
825
|
+
def _cache_write_scope(
|
|
826
|
+
self,
|
|
827
|
+
blocker: str | None,
|
|
828
|
+
selected_files: list[Path] | None,
|
|
829
|
+
executed: list[Path],
|
|
830
|
+
) -> list[Path] | None:
|
|
831
|
+
"""The files this run may record as passed, or ``None`` for "record none".
|
|
832
|
+
|
|
833
|
+
Three independent gates, any of which closes the write:
|
|
834
|
+
|
|
835
|
+
1. the run must be cacheable at all -- no filter, no loading-chain
|
|
836
|
+
switch, no collection-only mode, nothing injected via
|
|
837
|
+
``PYTEST_ADDOPTS``;
|
|
838
|
+
2. its selected file set must be derivable from the passthrough args;
|
|
839
|
+
3. pytest's own report must be internally consistent (outcome count vs
|
|
840
|
+
testcase count) and name at least one executed, non-skipped test --
|
|
841
|
+
*executed* is the caller's already-derived reading of that report.
|
|
842
|
+
|
|
843
|
+
The result is the *intersection* of the selection and the evidence, so
|
|
844
|
+
neither a stale report nor an over-broad selector (`.` reaching into
|
|
845
|
+
`.venv`) can smuggle a file into the cache (rv191 F1/F3).
|
|
846
|
+
"""
|
|
847
|
+
if blocker is not None:
|
|
848
|
+
print(f"[Cache] Not saving results: {blocker}.")
|
|
849
|
+
return None
|
|
850
|
+
if selected_files is None:
|
|
851
|
+
print(
|
|
852
|
+
"[Cache] Not saving results: this run's test files cannot be "
|
|
853
|
+
"determined."
|
|
854
|
+
)
|
|
855
|
+
return None
|
|
856
|
+
if not executed:
|
|
857
|
+
print(
|
|
858
|
+
"[Cache] Not saving results: the pytest report shows no executed test."
|
|
859
|
+
)
|
|
860
|
+
return None
|
|
861
|
+
executed_set = set(executed)
|
|
862
|
+
scope = [test_file for test_file in selected_files if test_file in executed_set]
|
|
863
|
+
if not scope:
|
|
864
|
+
print(
|
|
865
|
+
"[Cache] Not saving results: no selected file appears in the "
|
|
866
|
+
"executed set."
|
|
867
|
+
)
|
|
868
|
+
return None
|
|
869
|
+
return scope
|
|
870
|
+
|
|
871
|
+
def _get_all_test_files(self, project_root: str) -> list[Path]:
|
|
872
|
+
"""Get all test files in the project."""
|
|
873
|
+
test_files: list[Path] = []
|
|
874
|
+
tests_dir = Path(project_root) / "tests"
|
|
875
|
+
|
|
876
|
+
if tests_dir.exists() and tests_dir.is_dir():
|
|
877
|
+
# Recursively find all Python files in tests directory
|
|
878
|
+
for file_path in tests_dir.rglob("*.py"):
|
|
879
|
+
# Skip __init__.py files
|
|
880
|
+
if file_path.name != "__init__.py":
|
|
881
|
+
test_files.append(file_path)
|
|
882
|
+
|
|
883
|
+
return test_files
|
|
884
|
+
|
|
885
|
+
@staticmethod
|
|
886
|
+
def _display_test_path(test_file: Path, project_root: str) -> str:
|
|
887
|
+
"""Path as shown on an attribution progress line.
|
|
888
|
+
|
|
889
|
+
Relative to the project root when the file lives under it (the usual
|
|
890
|
+
case), so ``[3/87] tests/test_foo.py`` names the file the way the user
|
|
891
|
+
does rather than echoing an absolute temporary path.
|
|
892
|
+
"""
|
|
893
|
+
try:
|
|
894
|
+
return str(test_file.relative_to(project_root))
|
|
895
|
+
except ValueError:
|
|
896
|
+
return str(test_file)
|
|
897
|
+
|
|
898
|
+
@staticmethod
|
|
899
|
+
def _step_cache_disabled(no_cache: bool, env: dict[str, str] | None) -> bool:
|
|
900
|
+
"""Whether this step-by-step run must bypass the cache entirely.
|
|
901
|
+
|
|
902
|
+
Step mode walks the files itself and runs each one through
|
|
903
|
+
:class:`SingleTestExecutor`, so it inherited none of the normal path's
|
|
904
|
+
vetting: a ``PYTEST_ADDOPTS=--collect-only`` run executed nothing,
|
|
905
|
+
exited 0, and still recorded every file as passed (#199 rv199). The
|
|
906
|
+
same :func:`_addopts_cache_blocker` gate now covers this mode, and a
|
|
907
|
+
blocked value closes *both* directions of the cache -- no read, no
|
|
908
|
+
write -- exactly as it does in :meth:`_run_tests_normal`.
|
|
909
|
+
"""
|
|
910
|
+
return no_cache or _addopts_cache_blocker(env) is not None
|
|
911
|
+
|
|
912
|
+
def _run_specified_tests_step_by_step(
|
|
913
|
+
self,
|
|
914
|
+
python_executable: str,
|
|
915
|
+
project_root: str,
|
|
916
|
+
env: dict[str, str],
|
|
917
|
+
debug_mode: bool,
|
|
918
|
+
no_cache: bool,
|
|
919
|
+
quiet: bool,
|
|
920
|
+
verbose: bool,
|
|
921
|
+
test_files: list[Path],
|
|
922
|
+
no_skip: bool = False,
|
|
923
|
+
evidence_path: Path | None = None,
|
|
924
|
+
):
|
|
925
|
+
"""Run specified test files in step-by-step mode."""
|
|
926
|
+
cache_disabled = self._step_cache_disabled(no_cache, env)
|
|
927
|
+
concise = is_concise_mode(override_quiet=quiet, override_verbose=verbose)
|
|
928
|
+
|
|
929
|
+
def run_one(test_file: Path) -> tuple[bool, str]:
|
|
930
|
+
return self.single_test_executor.execute(
|
|
931
|
+
python_executable,
|
|
932
|
+
test_file,
|
|
933
|
+
project_root,
|
|
934
|
+
env,
|
|
935
|
+
debug_mode,
|
|
936
|
+
cache_disabled,
|
|
937
|
+
step_by_step=True,
|
|
938
|
+
concise=concise,
|
|
939
|
+
no_skip=no_skip,
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
def run_batch(execute_single: Callable[[Path], tuple[bool, str]]) -> None:
|
|
943
|
+
self.batch_processor.process_specified_files(
|
|
944
|
+
quiet,
|
|
945
|
+
verbose,
|
|
946
|
+
test_files,
|
|
947
|
+
python_executable,
|
|
948
|
+
project_root,
|
|
949
|
+
env,
|
|
950
|
+
debug_mode,
|
|
951
|
+
cache_disabled,
|
|
952
|
+
execute_single,
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
self._run_step_batch_with_evidence(
|
|
956
|
+
run_one,
|
|
957
|
+
run_batch,
|
|
958
|
+
test_files=test_files,
|
|
959
|
+
project_root=project_root,
|
|
960
|
+
env=env,
|
|
961
|
+
no_cache=no_cache,
|
|
962
|
+
evidence_path=evidence_path,
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
def _run_tests_step_by_step(
|
|
966
|
+
self,
|
|
967
|
+
python_executable: str,
|
|
968
|
+
project_root: str,
|
|
969
|
+
env: dict[str, str],
|
|
970
|
+
debug_mode: bool,
|
|
971
|
+
no_cache: bool,
|
|
972
|
+
quiet: bool,
|
|
973
|
+
verbose: bool,
|
|
974
|
+
no_skip: bool = False,
|
|
975
|
+
evidence_path: Path | None = None,
|
|
976
|
+
):
|
|
977
|
+
"""Run tests in step-by-step mode, stopping at first error."""
|
|
978
|
+
test_files = self._get_all_test_files(project_root)
|
|
979
|
+
if not test_files:
|
|
980
|
+
# Fail closed (#191): "nothing to run" is not "everything passed".
|
|
981
|
+
# The quality gate drives this path; returning normally here made a
|
|
982
|
+
# project whose tests were never discovered exit 0 and look green.
|
|
983
|
+
print("No test files found.")
|
|
984
|
+
# The evidence still records what happened: no file ran, and the
|
|
985
|
+
# run terminates non-zero. A gate that requested evidence must not
|
|
986
|
+
# have to guess why the file is absent (#212).
|
|
987
|
+
blocker = _addopts_cache_blocker(env)
|
|
988
|
+
_write_run_evidence(
|
|
989
|
+
evidence_path,
|
|
990
|
+
project_root=project_root,
|
|
991
|
+
started_at=time.time(),
|
|
992
|
+
executed_files=[],
|
|
993
|
+
cache_hits=0,
|
|
994
|
+
cache_total=0,
|
|
995
|
+
no_cache_requested=no_cache,
|
|
996
|
+
cacheable=blocker is None,
|
|
997
|
+
cache_blocked_reason=blocker,
|
|
998
|
+
exit_code=1,
|
|
999
|
+
)
|
|
1000
|
+
sys.exit(1)
|
|
1001
|
+
|
|
1002
|
+
cache_disabled = self._step_cache_disabled(no_cache, env)
|
|
1003
|
+
concise = is_concise_mode(override_quiet=quiet, override_verbose=verbose)
|
|
1004
|
+
|
|
1005
|
+
def run_one(test_file: Path) -> tuple[bool, str]:
|
|
1006
|
+
return self.single_test_executor.execute(
|
|
1007
|
+
python_executable,
|
|
1008
|
+
test_file,
|
|
1009
|
+
project_root,
|
|
1010
|
+
env,
|
|
1011
|
+
debug_mode,
|
|
1012
|
+
cache_disabled,
|
|
1013
|
+
step_by_step=True,
|
|
1014
|
+
concise=concise,
|
|
1015
|
+
no_skip=no_skip,
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
def run_batch(execute_single: Callable[[Path], tuple[bool, str]]) -> None:
|
|
1019
|
+
self.batch_processor.process_all_files(
|
|
1020
|
+
quiet,
|
|
1021
|
+
verbose,
|
|
1022
|
+
test_files,
|
|
1023
|
+
python_executable,
|
|
1024
|
+
project_root,
|
|
1025
|
+
env,
|
|
1026
|
+
debug_mode,
|
|
1027
|
+
cache_disabled,
|
|
1028
|
+
execute_single,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
self._run_step_batch_with_evidence(
|
|
1032
|
+
run_one,
|
|
1033
|
+
run_batch,
|
|
1034
|
+
test_files=test_files,
|
|
1035
|
+
project_root=project_root,
|
|
1036
|
+
env=env,
|
|
1037
|
+
no_cache=no_cache,
|
|
1038
|
+
evidence_path=evidence_path,
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
def _run_step_batch_with_evidence(
|
|
1042
|
+
self,
|
|
1043
|
+
run_one: Callable[[Path], tuple[bool, str]],
|
|
1044
|
+
run_batch: Callable[[Callable[[Path], tuple[bool, str]]], None],
|
|
1045
|
+
*,
|
|
1046
|
+
test_files: list[Path],
|
|
1047
|
+
project_root: str,
|
|
1048
|
+
env: dict[str, str],
|
|
1049
|
+
no_cache: bool,
|
|
1050
|
+
evidence_path: Path | None,
|
|
1051
|
+
) -> None:
|
|
1052
|
+
"""Run one step-mode batch and write its single aggregated evidence.
|
|
1053
|
+
|
|
1054
|
+
Step mode calls ``sys.exit(1)`` on the first failing file, so a
|
|
1055
|
+
terminating path arrives here as ``SystemExit``. The exit code is
|
|
1056
|
+
captured and the record written in ``finally``: one file per
|
|
1057
|
+
invocation, aggregating every file the batch processed. Files served
|
|
1058
|
+
from the cache are *not* "executed" -- the record lists exactly the
|
|
1059
|
+
files the executor actually ran, derived from the run's own hit set.
|
|
1060
|
+
"""
|
|
1061
|
+
started_at = time.time()
|
|
1062
|
+
attempted: list[Path] = []
|
|
1063
|
+
attempted_lock = threading.Lock()
|
|
1064
|
+
|
|
1065
|
+
def execute(test_file: Path) -> tuple[bool, str]:
|
|
1066
|
+
# Recording before the call: the file was handed to the executor,
|
|
1067
|
+
# so it is at worst a near-miss, and a cache hit is filtered out
|
|
1068
|
+
# below from the run's own hit set.
|
|
1069
|
+
with attempted_lock:
|
|
1070
|
+
attempted.append(test_file)
|
|
1071
|
+
return run_one(test_file)
|
|
1072
|
+
|
|
1073
|
+
blocker = _addopts_cache_blocker(env)
|
|
1074
|
+
exit_code = 0
|
|
1075
|
+
try:
|
|
1076
|
+
run_batch(execute)
|
|
1077
|
+
except SystemExit as error:
|
|
1078
|
+
exit_code = error.code if isinstance(error.code, int) else 1
|
|
1079
|
+
raise
|
|
1080
|
+
except BaseException:
|
|
1081
|
+
exit_code = 1
|
|
1082
|
+
raise
|
|
1083
|
+
finally:
|
|
1084
|
+
cache_hits = self.cache_manager.cache_hit_files
|
|
1085
|
+
_write_run_evidence(
|
|
1086
|
+
evidence_path,
|
|
1087
|
+
project_root=project_root,
|
|
1088
|
+
started_at=started_at,
|
|
1089
|
+
executed_files=[
|
|
1090
|
+
test_file for test_file in attempted if test_file not in cache_hits
|
|
1091
|
+
],
|
|
1092
|
+
cache_hits=len(cache_hits),
|
|
1093
|
+
cache_total=len(test_files),
|
|
1094
|
+
no_cache_requested=no_cache,
|
|
1095
|
+
cacheable=blocker is None,
|
|
1096
|
+
cache_blocked_reason=blocker,
|
|
1097
|
+
exit_code=exit_code,
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
def _record_cache_for_result(
|
|
1101
|
+
self,
|
|
1102
|
+
result: subprocess.CompletedProcess,
|
|
1103
|
+
executed_files: list[Path] | None,
|
|
1104
|
+
*,
|
|
1105
|
+
passed_args: list[str],
|
|
1106
|
+
python_executable: str,
|
|
1107
|
+
project_root: str,
|
|
1108
|
+
env: dict[str, str],
|
|
1109
|
+
concise: bool,
|
|
1110
|
+
) -> None:
|
|
1111
|
+
"""Update cache from a batch result, for the files execution was proven.
|
|
1112
|
+
|
|
1113
|
+
A fully-passing suite records those files in bulk; a failing suite is
|
|
1114
|
+
attributed per file (each file re-run through the single-file executor
|
|
1115
|
+
so passing files get cached and failing ones are dropped). A run with
|
|
1116
|
+
no proof records nothing at all (fail-closed, #191).
|
|
1117
|
+
"""
|
|
1118
|
+
if result.returncode == 0:
|
|
1119
|
+
if not executed_files:
|
|
1120
|
+
print(
|
|
1121
|
+
"[Cache] Not saving results: no executed test was proven "
|
|
1122
|
+
"for this run."
|
|
1123
|
+
)
|
|
1124
|
+
else:
|
|
1125
|
+
self.cache_manager.record_passed_batch(
|
|
1126
|
+
executed_files,
|
|
1127
|
+
concise,
|
|
1128
|
+
python_executable=python_executable,
|
|
1129
|
+
)
|
|
1130
|
+
else:
|
|
1131
|
+
print("Tests failed, updating cache for individual test files...")
|
|
1132
|
+
# The same selection/behavior switches the batch run used, so a
|
|
1133
|
+
# file's verdict is attributed under the filter it was produced
|
|
1134
|
+
# under (a `-k`-excluded failing test must not drop this file).
|
|
1135
|
+
extra_args = _attribution_args(passed_args)
|
|
1136
|
+
scope = executed_files or []
|
|
1137
|
+
if not scope:
|
|
1138
|
+
print(
|
|
1139
|
+
"[Cache] Skipping per-file attribution: no executed test "
|
|
1140
|
+
"was proven for this run."
|
|
1141
|
+
)
|
|
1142
|
+
total = len(scope)
|
|
1143
|
+
for index, test_file in enumerate(scope, start=1):
|
|
1144
|
+
# One line per file: this pass is serial and can dwarf the
|
|
1145
|
+
# batch run on a cold cache, so silence is not an option.
|
|
1146
|
+
progress = (
|
|
1147
|
+
f"[{index}/{total}] "
|
|
1148
|
+
f"{self._display_test_path(test_file, project_root)}"
|
|
1149
|
+
)
|
|
1150
|
+
log_info_pair(progress, progress)
|
|
1151
|
+
self.single_test_executor.execute(
|
|
1152
|
+
python_executable,
|
|
1153
|
+
test_file,
|
|
1154
|
+
project_root,
|
|
1155
|
+
env,
|
|
1156
|
+
debug_mode=False,
|
|
1157
|
+
no_cache=False,
|
|
1158
|
+
step_by_step=False,
|
|
1159
|
+
concise=concise,
|
|
1160
|
+
extra_args=extra_args,
|
|
1161
|
+
)
|
|
1162
|
+
# Show cache statistics after test run
|
|
1163
|
+
if not concise:
|
|
1164
|
+
self.cache_manager.report_detail()
|
|
1165
|
+
# Cleanup expired cache files (runs in background)
|
|
1166
|
+
try:
|
|
1167
|
+
self.cache_manager.purge_expired()
|
|
1168
|
+
except Exception:
|
|
1169
|
+
# Ignore cleanup errors
|
|
1170
|
+
pass
|
|
1171
|
+
|
|
1172
|
+
def _run_tests_normal(
|
|
1173
|
+
self,
|
|
1174
|
+
python_executable: str,
|
|
1175
|
+
project_root: str,
|
|
1176
|
+
env: dict[str, str],
|
|
1177
|
+
report_mode: bool,
|
|
1178
|
+
passed_args: list[str],
|
|
1179
|
+
debug_mode: bool,
|
|
1180
|
+
todo_dir: Path,
|
|
1181
|
+
no_cache: bool = False,
|
|
1182
|
+
quiet: bool = False,
|
|
1183
|
+
verbose: bool = False,
|
|
1184
|
+
evidence_path: Path | None = None,
|
|
1185
|
+
) -> None:
|
|
1186
|
+
"""Run tests in normal mode with caching.
|
|
1187
|
+
|
|
1188
|
+
When *evidence_path* is given, one structured record is written on
|
|
1189
|
+
**every** terminating path -- the all-cached short circuit above pytest,
|
|
1190
|
+
a passing or failing suite, and an exception -- so a consumer never has
|
|
1191
|
+
to infer "did this run execute anything?" from stdout (#212).
|
|
1192
|
+
"""
|
|
1193
|
+
from ...utils.output_context import is_concise_mode
|
|
1194
|
+
|
|
1195
|
+
evidence_started_at = time.time()
|
|
1196
|
+
|
|
1197
|
+
# Start the manager's per-run hit tracking here, as step mode does, so
|
|
1198
|
+
# the evidence's ``cache_hits`` can only ever describe this run (#212
|
|
1199
|
+
# review L1). Normal mode runs one pytest batch and never consults the
|
|
1200
|
+
# cache per file, so this set stays empty today; the record still reads
|
|
1201
|
+
# it instead of writing a literal 0, so a future partial-reuse path
|
|
1202
|
+
# cannot silently report "0 hits" over reused results.
|
|
1203
|
+
self.cache_manager.start_run()
|
|
1204
|
+
|
|
1205
|
+
# Known test files, the subset this run selects, and -- because a green
|
|
1206
|
+
# exit code alone proves nothing (rv191 F1) -- the evidence pytest
|
|
1207
|
+
# writes about what it actually executed.
|
|
1208
|
+
test_files = self._get_all_test_files(project_root)
|
|
1209
|
+
known = {
|
|
1210
|
+
_selector_spelling(test_file, project_root): test_file
|
|
1211
|
+
for test_file in test_files
|
|
1212
|
+
}
|
|
1213
|
+
option_names, _selectors = _split_passthrough_args(passed_args)
|
|
1214
|
+
# Two doors carry pytest switches into the run: the command line and
|
|
1215
|
+
# `PYTEST_ADDOPTS`, which pytest prepends to every command line. Both
|
|
1216
|
+
# are vetted against the same allowlist (#199).
|
|
1217
|
+
cache_blocker = _cache_write_blocker(option_names) or _addopts_cache_blocker(
|
|
1218
|
+
env
|
|
1219
|
+
)
|
|
1220
|
+
selected_files = _executed_test_files(passed_args, project_root, test_files)
|
|
1221
|
+
# The denominator of the "cache usage" fact: the files this run would
|
|
1222
|
+
# consider. An unknown selection still has a known suite size, so the
|
|
1223
|
+
# evidence says "0 of N", not "0 of 0" (#212).
|
|
1224
|
+
cache_total = (
|
|
1225
|
+
len(selected_files) if selected_files is not None else len(test_files)
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
concise = is_concise_mode(override_quiet=quiet, override_verbose=verbose)
|
|
1229
|
+
|
|
1230
|
+
# Check if we can use cached results for every file this run would run.
|
|
1231
|
+
# With an unknown selection (or a run the cache cannot speak about) the
|
|
1232
|
+
# only safe answer is "run, don't cache".
|
|
1233
|
+
all_cached = (
|
|
1234
|
+
not no_cache
|
|
1235
|
+
and cache_blocker is None
|
|
1236
|
+
and selected_files is not None
|
|
1237
|
+
and self.cache_manager.all_cached(
|
|
1238
|
+
selected_files, concise, python_executable=python_executable
|
|
1239
|
+
)
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
if all_cached:
|
|
1243
|
+
# All tests passed in cache, no need to run
|
|
1244
|
+
print("All tests passed successfully! (using cache)")
|
|
1245
|
+
# Show cache statistics
|
|
1246
|
+
self.cache_manager.report_detail()
|
|
1247
|
+
# The short circuit is the very state the gate must judge red, so
|
|
1248
|
+
# its evidence is written explicitly: nothing executed, every file
|
|
1249
|
+
# served from the cache (#212).
|
|
1250
|
+
_write_run_evidence(
|
|
1251
|
+
evidence_path,
|
|
1252
|
+
project_root=project_root,
|
|
1253
|
+
started_at=evidence_started_at,
|
|
1254
|
+
executed_files=[],
|
|
1255
|
+
cache_hits=cache_total,
|
|
1256
|
+
cache_total=cache_total,
|
|
1257
|
+
no_cache_requested=no_cache,
|
|
1258
|
+
cacheable=cache_blocker is None,
|
|
1259
|
+
cache_blocked_reason=cache_blocker,
|
|
1260
|
+
exit_code=0,
|
|
1261
|
+
)
|
|
1262
|
+
return
|
|
1263
|
+
|
|
1264
|
+
# Need to run tests
|
|
1265
|
+
report_path, owns_report = _execution_report(passed_args, report_mode)
|
|
1266
|
+
started_at = time.time()
|
|
1267
|
+
exit_code = 1
|
|
1268
|
+
executed_files: list[Path] | None = None
|
|
1269
|
+
executed_from_report: list[Path] = []
|
|
1270
|
+
try:
|
|
1271
|
+
result = self._run_all_tests(
|
|
1272
|
+
python_executable,
|
|
1273
|
+
project_root,
|
|
1274
|
+
env,
|
|
1275
|
+
report_mode,
|
|
1276
|
+
passed_args,
|
|
1277
|
+
debug_mode,
|
|
1278
|
+
cache_report=report_path,
|
|
1279
|
+
)
|
|
1280
|
+
# pytest's own report is the positive signal behind both the cache
|
|
1281
|
+
# write and the evidence's ``executed_files`` -- derived once, so
|
|
1282
|
+
# the record and the cache scope can never disagree.
|
|
1283
|
+
executed_from_report = _executed_files_from_report(
|
|
1284
|
+
report_path, project_root, known, started_at
|
|
1285
|
+
)
|
|
1286
|
+
executed_files = (
|
|
1287
|
+
None
|
|
1288
|
+
if no_cache
|
|
1289
|
+
else self._cache_write_scope(
|
|
1290
|
+
cache_blocker, selected_files, executed_from_report
|
|
1291
|
+
)
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
from .test_result_handler import TestResultHandler
|
|
1295
|
+
|
|
1296
|
+
result_handler = TestResultHandler()
|
|
1297
|
+
exit_code = result_handler.handle_test_result(result, report_mode)
|
|
1298
|
+
|
|
1299
|
+
if report_mode:
|
|
1300
|
+
self.report_generator.generate(todo_dir, project_root, verbose)
|
|
1301
|
+
|
|
1302
|
+
if not no_cache:
|
|
1303
|
+
self._record_cache_for_result(
|
|
1304
|
+
result,
|
|
1305
|
+
executed_files,
|
|
1306
|
+
passed_args=passed_args,
|
|
1307
|
+
python_executable=python_executable,
|
|
1308
|
+
project_root=project_root,
|
|
1309
|
+
env=env,
|
|
1310
|
+
concise=concise,
|
|
1311
|
+
)
|
|
1312
|
+
except SystemExit as error:
|
|
1313
|
+
exit_code = error.code if isinstance(error.code, int) else 1
|
|
1314
|
+
raise
|
|
1315
|
+
except BaseException:
|
|
1316
|
+
if exit_code == 0: # pragma: no cover - defensive, exit_code starts 1
|
|
1317
|
+
exit_code = 1
|
|
1318
|
+
raise
|
|
1319
|
+
finally:
|
|
1320
|
+
if owns_report and report_path is not None:
|
|
1321
|
+
try:
|
|
1322
|
+
report_path.unlink()
|
|
1323
|
+
except OSError:
|
|
1324
|
+
pass
|
|
1325
|
+
_write_run_evidence(
|
|
1326
|
+
evidence_path,
|
|
1327
|
+
project_root=project_root,
|
|
1328
|
+
started_at=evidence_started_at,
|
|
1329
|
+
executed_files=executed_from_report,
|
|
1330
|
+
# The same fact step mode reads, from the manager's own per-run
|
|
1331
|
+
# hit set -- never a literal 0 (#212 review L1).
|
|
1332
|
+
cache_hits=len(self.cache_manager.cache_hit_files),
|
|
1333
|
+
cache_total=cache_total,
|
|
1334
|
+
no_cache_requested=no_cache,
|
|
1335
|
+
cacheable=cache_blocker is None,
|
|
1336
|
+
cache_blocked_reason=cache_blocker,
|
|
1337
|
+
exit_code=exit_code,
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
# Exit only after report generation and cache attribution have run,
|
|
1341
|
+
# so a failing suite still produces its reports and cache state.
|
|
1342
|
+
if exit_code:
|
|
1343
|
+
sys.exit(exit_code)
|