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,1156 @@
|
|
|
1
|
+
"""Cache management module for test commands (the test-result cache adapter).
|
|
2
|
+
|
|
3
|
+
This module is one of the three adapters over the single cache seam
|
|
4
|
+
:class:`~wl_cli.utils.cache_ledger.CacheLedger` (#213, ADR-0011). It owns what
|
|
5
|
+
this cache decides for itself -- the key inputs, the user-level location and the
|
|
6
|
+
24-hour TTL -- and delegates key spelling, storage, freshness and the error
|
|
7
|
+
policy to the ledger.
|
|
8
|
+
|
|
9
|
+
Classes in this module:
|
|
10
|
+
- CacheStats: Cache statistics data class
|
|
11
|
+
- CacheMaintenance: Cache purge and statistics over a KeyedStore
|
|
12
|
+
- DependencyAnalyzer: Concrete dependency analyzer with public API
|
|
13
|
+
- CacheKeyGenerator: Generates the cache inputs (and their key) from file
|
|
14
|
+
content, dependencies and the environment of the verdict
|
|
15
|
+
- CacheManager: Facade that delegates to the specialized components above
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import random
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from ...utils.cache_ledger import CacheInputs, CacheLedger, format_key
|
|
31
|
+
from ...utils.cache_store import KeyedStore, is_fresh
|
|
32
|
+
from ...utils.config import get_config
|
|
33
|
+
from .utils.project_root import project_root_for
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CacheKeyUnavailableError(RuntimeError):
|
|
37
|
+
"""The cache key cannot be derived truthfully, so nothing may be cached.
|
|
38
|
+
|
|
39
|
+
Raised instead of falling back to a weaker key: an entry written under a
|
|
40
|
+
key that ignored the pytest configuration (or the interpreter) would be
|
|
41
|
+
served to a later run that is *not* the same run, which is exactly the
|
|
42
|
+
false green #191 removes. Callers treat it as "skip the cache".
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _log_safe_key(key: str) -> str:
|
|
47
|
+
"""Render a key for a log line, escaping characters the stream cannot encode.
|
|
48
|
+
|
|
49
|
+
A lone surrogate is a legal ``str`` but cannot be written to a UTF-8 stdout,
|
|
50
|
+
so interpolating one raw into a ``print`` raises ``UnicodeEncodeError`` --
|
|
51
|
+
which in :meth:`CacheManager.delete` would escape as a crash. Every other
|
|
52
|
+
key, including the empty string and control characters, is returned
|
|
53
|
+
unchanged, so existing log lines are byte-for-byte unaffected.
|
|
54
|
+
"""
|
|
55
|
+
return key.encode("utf-8", "backslashreplace").decode("utf-8")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Cache location
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def default_test_cache_dir() -> Path:
|
|
64
|
+
"""Resolve the directory the test-result cache lives in.
|
|
65
|
+
|
|
66
|
+
User-level on purpose (``test_cache.cache_dir`` from configuration, falling
|
|
67
|
+
back to ``~/.cache/wl``, plus ``test-cache``): worktrees and the main
|
|
68
|
+
checkout share it, and the key carries the file path, so the shared store
|
|
69
|
+
does not collide. The location is this adapter's policy, not a technical
|
|
70
|
+
debt -- see ADR-0011.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Path to the test-cache directory.
|
|
74
|
+
"""
|
|
75
|
+
cache_root = get_config("test_cache.cache_dir", None)
|
|
76
|
+
if cache_root and isinstance(cache_root, str):
|
|
77
|
+
return Path(cache_root).expanduser() / "test-cache"
|
|
78
|
+
|
|
79
|
+
return Path.home() / ".cache" / "wl" / "test-cache"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# CacheStats & CacheMaintenance
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class CacheStats:
|
|
89
|
+
"""Cache statistics for display.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
total_entries: Number of cached test results
|
|
93
|
+
total_size_bytes: Total size of all cache files in bytes
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
total_entries: int
|
|
97
|
+
total_size_bytes: int
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CacheMaintenance:
|
|
101
|
+
"""Cache purge and statistics operations over a :class:`KeyedStore`.
|
|
102
|
+
|
|
103
|
+
Expiry is judged from each entry's own ``timestamp`` field through
|
|
104
|
+
:func:`is_fresh` -- never from file mtime -- so the test-cache stack has a
|
|
105
|
+
single freshness clock.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, store: KeyedStore):
|
|
109
|
+
"""Initialize with the store to maintain.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
store: KeyedStore holding the test-cache entries.
|
|
113
|
+
"""
|
|
114
|
+
self._store = store
|
|
115
|
+
|
|
116
|
+
def purge_expired(self, max_age_seconds: float) -> int:
|
|
117
|
+
"""Remove entries whose payload timestamp is older than the limit.
|
|
118
|
+
|
|
119
|
+
An entry with no timestamp (or a non-numeric one) counts as expired, and
|
|
120
|
+
an entry that cannot be read or is not a JSON object is removed as
|
|
121
|
+
well: those files can never become hits, so a purge reclaims them.
|
|
122
|
+
Leftover temporary files from interrupted writes are swept too.
|
|
123
|
+
|
|
124
|
+
A file whose name is not a legal key (``.json`` yields the empty key,
|
|
125
|
+
``a<TAB>b.json`` a control-character key) is reported by
|
|
126
|
+
``iter_entry_files`` but refused by ``invalidate``, so the deletion
|
|
127
|
+
falls back to ``invalidate_file`` -- otherwise statistics counted a
|
|
128
|
+
file the purge could never reclaim (#172 wave 9).
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
max_age_seconds: Maximum age of a fresh entry, in seconds.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Number of deleted cache files, temporary files included
|
|
135
|
+
"""
|
|
136
|
+
deleted = 0
|
|
137
|
+
for key, entry in self._store.iter_entry_files():
|
|
138
|
+
if entry is not None and is_fresh(entry, max_age_seconds):
|
|
139
|
+
continue
|
|
140
|
+
if self._store.invalidate(key) or self._store.invalidate_file(key):
|
|
141
|
+
deleted += 1
|
|
142
|
+
return deleted + self._store.remove_temp_files()
|
|
143
|
+
|
|
144
|
+
def clear_all(self) -> int:
|
|
145
|
+
"""Clear all cache entries.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Number of deleted cache files
|
|
149
|
+
"""
|
|
150
|
+
return self._store.clear()
|
|
151
|
+
|
|
152
|
+
def get_stats(self) -> CacheStats:
|
|
153
|
+
"""Get current cache statistics.
|
|
154
|
+
|
|
155
|
+
``total_entries`` and ``total_size_bytes`` cover the same set -- every
|
|
156
|
+
entry file in the store, unusable ones included -- so the two numbers
|
|
157
|
+
can be reconciled. A purge is what reclaims those unusable files.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
CacheStats object with current statistics
|
|
161
|
+
"""
|
|
162
|
+
return CacheStats(
|
|
163
|
+
total_entries=sum(1 for _ in self._store.iter_entry_files()),
|
|
164
|
+
total_size_bytes=self._store.total_size_bytes(),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def print_stats(self, output: bool = True) -> str:
|
|
168
|
+
"""Format cache statistics as a human-readable string.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
output: Whether to print directly to stdout
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Formatted string with statistics
|
|
175
|
+
"""
|
|
176
|
+
stats = self.get_stats()
|
|
177
|
+
|
|
178
|
+
def format_size(bytes_value: int) -> str:
|
|
179
|
+
"""Convert bytes to human-readable format."""
|
|
180
|
+
if bytes_value < 1024:
|
|
181
|
+
return f"{bytes_value} B"
|
|
182
|
+
elif bytes_value < 1024 * 1024:
|
|
183
|
+
return f"{bytes_value / 1024:.1f} KB"
|
|
184
|
+
elif bytes_value < 1024 * 1024 * 1024:
|
|
185
|
+
return f"{bytes_value / (1024 * 1024):.1f} MB"
|
|
186
|
+
else:
|
|
187
|
+
return f"{bytes_value / (1024 * 1024 * 1024):.1f} GB"
|
|
188
|
+
|
|
189
|
+
lines = [
|
|
190
|
+
"Cache Statistics:",
|
|
191
|
+
f" * Entries: {stats.total_entries}",
|
|
192
|
+
f" * Total Size: {format_size(stats.total_size_bytes)}",
|
|
193
|
+
]
|
|
194
|
+
result = "\n".join(lines)
|
|
195
|
+
|
|
196
|
+
if output:
|
|
197
|
+
print(result)
|
|
198
|
+
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# DependencyAnalyzer (thin wrapper around BaseDependencyAnalyzer)
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
from .base_dependency_analyzer import BaseDependencyAnalyzer # noqa: E402
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class DependencyAnalyzer(BaseDependencyAnalyzer):
|
|
211
|
+
"""Analyze Python file dependencies using AST parsing.
|
|
212
|
+
|
|
213
|
+
This class provides a public API for analyzing all dependencies
|
|
214
|
+
of a test file, including indirect dependencies through imports.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def __init__(
|
|
218
|
+
self, module_file_cache: dict[tuple[str, str], Path | None] | None = None
|
|
219
|
+
):
|
|
220
|
+
"""Initialize with optional module file cache.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
module_file_cache: Optional cache for found module files
|
|
224
|
+
Caches (module_name, src_dir_str) -> Path found result
|
|
225
|
+
"""
|
|
226
|
+
super().__init__(module_file_cache)
|
|
227
|
+
|
|
228
|
+
def analyze(self, test_file: Path) -> list[Path]:
|
|
229
|
+
"""Analyze a test file and recursively find all dependencies.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
test_file: Path to the test file to analyze
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
List of all found dependency file paths
|
|
236
|
+
"""
|
|
237
|
+
return self._get_test_dependencies(test_file)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# CacheKeyGenerator
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
#: pytest config files, in the order pytest itself tries them in a directory.
|
|
246
|
+
_PYTEST_CONFIG_FILENAMES = ("pytest.ini", "pyproject.toml", "tox.ini", "setup.cfg")
|
|
247
|
+
|
|
248
|
+
#: Environment variables that change what the child pytest process does.
|
|
249
|
+
#:
|
|
250
|
+
#: The rule for membership is "a different value can change the verdict", not
|
|
251
|
+
#: "the value looks important": a variable that only changes how an identical
|
|
252
|
+
#: run is *reported* stays out, because keying it would turn a legitimate hit
|
|
253
|
+
#: into a re-run.
|
|
254
|
+
_KEY_ENVIRONMENT_VARIABLES = (
|
|
255
|
+
"PYTHONPATH",
|
|
256
|
+
"WL_ENVIRONMENT",
|
|
257
|
+
"CI",
|
|
258
|
+
"PYTHON_VERSION",
|
|
259
|
+
# `PYTEST_ADDOPTS` is appended to every pytest command line: it can select,
|
|
260
|
+
# filter and reconfigure the run, so it belongs in the key (#191).
|
|
261
|
+
"PYTEST_ADDOPTS",
|
|
262
|
+
"PYTEST_PLUGINS",
|
|
263
|
+
"PYTEST_DISABLE_PLUGIN_AUTOLOAD",
|
|
264
|
+
# `PYTHONWARNINGS` is the child interpreter's warning filter, and `error`
|
|
265
|
+
# turns a warning into a raised exception -- a different verdict, not a
|
|
266
|
+
# differently-formatted report. wl appends its own `-W` switches to every
|
|
267
|
+
# run, so this variable is the only per-run warning input the key could not
|
|
268
|
+
# see; wl's `-W ignore::UserWarning` is why the reproducing test has to
|
|
269
|
+
# emit a `FutureWarning` (#209).
|
|
270
|
+
"PYTHONWARNINGS",
|
|
271
|
+
# `PYTHONOPTIMIZE` (`-O`/`-OO`) strips `assert` statements before the test
|
|
272
|
+
# runs, so a test whose only check is an assert can pass under it and fail
|
|
273
|
+
# without it. Verdict-level, and previously unseen (#209 audit).
|
|
274
|
+
"PYTHONOPTIMIZE",
|
|
275
|
+
# `PYTHONDEVMODE` (`-X dev`) widens the startup warning filters and turns
|
|
276
|
+
# asyncio debug mode on, which emits warnings a test can observe (#209
|
|
277
|
+
# audit).
|
|
278
|
+
"PYTHONDEVMODE",
|
|
279
|
+
# `PYTHONHASHSEED` pins str/bytes hashing. Unset -- and, equivalently, the
|
|
280
|
+
# explicit value `random` -- means a fresh random seed per process, so no
|
|
281
|
+
# cache can be sound against *that*; an integer seed can order a set
|
|
282
|
+
# differently from the run that wrote the entry, so a set value must move
|
|
283
|
+
# the key (#209 audit).
|
|
284
|
+
"PYTHONHASHSEED",
|
|
285
|
+
# `PYTHONUTF8` / `PYTHONIOENCODING` change the child's default text encoding
|
|
286
|
+
# and stdio encoding, which tests that read, write or compare text observe
|
|
287
|
+
# (#209 audit).
|
|
288
|
+
"PYTHONUTF8",
|
|
289
|
+
"PYTHONIOENCODING",
|
|
290
|
+
# `PYTEST_TIMEOUT` (pytest-timeout) fails a test that overruns it: a pass
|
|
291
|
+
# recorded without a limit is not a pass under one (#209 audit).
|
|
292
|
+
"PYTEST_TIMEOUT",
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
#: Runs inside the *test* interpreter, never in the wl process: the fingerprint
|
|
296
|
+
#: must describe the interpreter that will run pytest -- wl itself may live in
|
|
297
|
+
#: a different environment (uv tool env vs project .venv).
|
|
298
|
+
_FINGERPRINT_CODE = """
|
|
299
|
+
import json
|
|
300
|
+
import sys
|
|
301
|
+
import importlib.metadata as md
|
|
302
|
+
|
|
303
|
+
plugins = {}
|
|
304
|
+
try:
|
|
305
|
+
entry_points = md.entry_points(group="pytest11")
|
|
306
|
+
except Exception:
|
|
307
|
+
entry_points = ()
|
|
308
|
+
for entry_point in entry_points:
|
|
309
|
+
dist = getattr(entry_point, "dist", None)
|
|
310
|
+
try:
|
|
311
|
+
if dist is None:
|
|
312
|
+
plugins[entry_point.name] = "unknown"
|
|
313
|
+
else:
|
|
314
|
+
plugins[dist.metadata["Name"] or entry_point.name] = dist.version
|
|
315
|
+
except Exception:
|
|
316
|
+
plugins[entry_point.name] = "unknown"
|
|
317
|
+
try:
|
|
318
|
+
pytest_version = md.version("pytest")
|
|
319
|
+
except Exception:
|
|
320
|
+
pytest_version = None
|
|
321
|
+
print(
|
|
322
|
+
json.dumps(
|
|
323
|
+
{
|
|
324
|
+
"implementation": sys.implementation.name,
|
|
325
|
+
"version": sys.version,
|
|
326
|
+
"executable": sys.executable,
|
|
327
|
+
"prefix": sys.prefix,
|
|
328
|
+
"base_prefix": getattr(sys, "base_prefix", sys.prefix),
|
|
329
|
+
"pytest": pytest_version,
|
|
330
|
+
"plugins": plugins,
|
|
331
|
+
},
|
|
332
|
+
sort_keys=True,
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _interpreter_fingerprint(python_executable: str) -> str:
|
|
339
|
+
"""SHA-256 of what the pytest-running interpreter *is*.
|
|
340
|
+
|
|
341
|
+
Covers the interpreter identity (implementation, version, executable,
|
|
342
|
+
prefix/base prefix) and the pytest/plugin versions importable **by that
|
|
343
|
+
interpreter**. The old key recorded the pytest version of the wl process,
|
|
344
|
+
which says nothing when the tests run in a project virtualenv.
|
|
345
|
+
|
|
346
|
+
Deliberately unmemoised: the probe costs a subprocess, and callers memoise
|
|
347
|
+
per run (see :class:`CacheKeyGenerator`). Keeping the memo out of module
|
|
348
|
+
state means one mis-answered probe cannot leak into an unrelated run.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
CacheKeyUnavailableError: the interpreter cannot be fingerprinted (missing
|
|
352
|
+
binary, nonzero exit, unparsable output, no pytest importable).
|
|
353
|
+
A key without this fingerprint would let a cache entry cross
|
|
354
|
+
interpreters, so the caller must not cache at all.
|
|
355
|
+
"""
|
|
356
|
+
try:
|
|
357
|
+
completed = subprocess.run(
|
|
358
|
+
[python_executable, "-c", _FINGERPRINT_CODE],
|
|
359
|
+
capture_output=True,
|
|
360
|
+
text=True,
|
|
361
|
+
timeout=30,
|
|
362
|
+
check=False,
|
|
363
|
+
)
|
|
364
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
365
|
+
raise CacheKeyUnavailableError(
|
|
366
|
+
f"cannot fingerprint interpreter {python_executable!r}: {error}"
|
|
367
|
+
) from error
|
|
368
|
+
|
|
369
|
+
if completed.returncode != 0:
|
|
370
|
+
raise CacheKeyUnavailableError(
|
|
371
|
+
f"cannot fingerprint interpreter {python_executable!r}: "
|
|
372
|
+
f"exit {completed.returncode}: {completed.stderr.strip()[:200]}"
|
|
373
|
+
)
|
|
374
|
+
try:
|
|
375
|
+
payload = json.loads(completed.stdout)
|
|
376
|
+
except ValueError as error:
|
|
377
|
+
raise CacheKeyUnavailableError(
|
|
378
|
+
f"cannot fingerprint interpreter {python_executable!r}: "
|
|
379
|
+
f"unparsable output: {error}"
|
|
380
|
+
) from error
|
|
381
|
+
if not isinstance(payload, dict) or not payload.get("pytest"):
|
|
382
|
+
raise CacheKeyUnavailableError(
|
|
383
|
+
f"cannot fingerprint interpreter {python_executable!r}: "
|
|
384
|
+
"pytest is not importable by that interpreter"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
return hashlib.sha256(
|
|
388
|
+
json.dumps(payload, sort_keys=True).encode("utf-8")
|
|
389
|
+
).hexdigest()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _nearest_pytest_config_root(start: Path) -> Path | None:
|
|
393
|
+
"""Nearest ancestor of *start* (inclusive) holding a pytest config file.
|
|
394
|
+
|
|
395
|
+
This is pytest's own rootdir rule in miniature (nearest config wins), and
|
|
396
|
+
it is deliberately independent of the process working directory: the cache
|
|
397
|
+
key must describe *the file's* environment, not wherever wl was launched.
|
|
398
|
+
"""
|
|
399
|
+
for directory in (start, *start.parents):
|
|
400
|
+
for filename in _PYTEST_CONFIG_FILENAMES:
|
|
401
|
+
if (directory / filename).is_file():
|
|
402
|
+
return directory
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _pytest_context_files(test_file: Path) -> list[Path]:
|
|
407
|
+
"""conftest.py files and pytest config files that shape *test_file*'s run.
|
|
408
|
+
|
|
409
|
+
pytest builds a verdict out of more than the file and its imports: every
|
|
410
|
+
``conftest.py`` between the rootdir and the test's directory is executed
|
|
411
|
+
before the test, and one config file supplies ``addopts``, ``testpaths``
|
|
412
|
+
and the plugin set. The AST import graph used to miss all of it, so editing
|
|
413
|
+
a conftest or ``pytest.ini`` left the cache key byte-for-byte unchanged
|
|
414
|
+
(#191).
|
|
415
|
+
|
|
416
|
+
The walk starts at the test file's own directory and stops at the config
|
|
417
|
+
root (or, when no config file exists, at the subsystem's project root when
|
|
418
|
+
that is an ancestor, else the filesystem root) -- the same boundary pytest
|
|
419
|
+
uses when it decides which conftest files apply.
|
|
420
|
+
"""
|
|
421
|
+
directory = test_file.parent
|
|
422
|
+
root = _nearest_pytest_config_root(directory)
|
|
423
|
+
if root is None:
|
|
424
|
+
fallback = Path(project_root_for())
|
|
425
|
+
try:
|
|
426
|
+
fallback = fallback.resolve()
|
|
427
|
+
except OSError:
|
|
428
|
+
pass
|
|
429
|
+
if fallback in (directory, *directory.parents):
|
|
430
|
+
root = fallback
|
|
431
|
+
|
|
432
|
+
chain: list[Path] = []
|
|
433
|
+
current = directory
|
|
434
|
+
while True:
|
|
435
|
+
chain.append(current)
|
|
436
|
+
if current == root or current.parent == current:
|
|
437
|
+
break
|
|
438
|
+
current = current.parent
|
|
439
|
+
chain.reverse()
|
|
440
|
+
|
|
441
|
+
files: list[Path] = []
|
|
442
|
+
for item in chain:
|
|
443
|
+
conftest = item / "conftest.py"
|
|
444
|
+
if conftest.is_file():
|
|
445
|
+
files.append(conftest)
|
|
446
|
+
for filename in _PYTEST_CONFIG_FILENAMES:
|
|
447
|
+
candidate = item / filename
|
|
448
|
+
if candidate.is_file():
|
|
449
|
+
files.append(candidate)
|
|
450
|
+
return files
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
import pytest # noqa: E402
|
|
455
|
+
|
|
456
|
+
HAS_PYTEST = True
|
|
457
|
+
except ImportError:
|
|
458
|
+
HAS_PYTEST = False
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class CacheKeyGenerator(BaseDependencyAnalyzer):
|
|
462
|
+
"""Derive the test-result cache inputs for a test file.
|
|
463
|
+
|
|
464
|
+
This generator is the test adapter's answer to "what enters the key"
|
|
465
|
+
(#213, ADR-0011): an ordered list of normalized fragments that the ledger
|
|
466
|
+
folds into ``test_<sha256>``. The fragments are:
|
|
467
|
+
|
|
468
|
+
- Test file path and modification/change times
|
|
469
|
+
- Test file content
|
|
470
|
+
- All dependency file paths, times, and content
|
|
471
|
+
- Every ``conftest.py`` and pytest config file that applies to the file
|
|
472
|
+
(rootdir down to the file's directory) (#191)
|
|
473
|
+
- Relevant environment variables: the pytest switches (``PYTEST_ADDOPTS``,
|
|
474
|
+
``PYTEST_PLUGINS``, ``PYTEST_DISABLE_PLUGIN_AUTOLOAD``, and
|
|
475
|
+
``PYTEST_TIMEOUT``, which the pytest-timeout plugin reads rather than the
|
|
476
|
+
interpreter startup), the child interpreter's startup inputs
|
|
477
|
+
(``PYTHONWARNINGS``, ``PYTHONOPTIMIZE``, ``PYTHONDEVMODE``,
|
|
478
|
+
``PYTHONHASHSEED``, ``PYTHONUTF8``, ``PYTHONIOENCODING``), and the
|
|
479
|
+
ambient identity variables (``PYTHONPATH``, ``PYTHON_VERSION``, ``CI``,
|
|
480
|
+
``WL_ENVIRONMENT``) (#191, #209)
|
|
481
|
+
- The wl process's pytest version (if available)
|
|
482
|
+
- A fingerprint of the interpreter that will run pytest (version, prefix,
|
|
483
|
+
and the pytest/plugin versions it can import) (#191)
|
|
484
|
+
|
|
485
|
+
When a part cannot be read the generator raises
|
|
486
|
+
:class:`CacheKeyUnavailableError` instead of returning a weaker input set.
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
#: The cache this adapter feeds. Becomes the key's prefix.
|
|
490
|
+
NAMESPACE = "test"
|
|
491
|
+
|
|
492
|
+
def __init__(
|
|
493
|
+
self, dependency_cache: dict[str, tuple[list[Path], float]] | None = None
|
|
494
|
+
):
|
|
495
|
+
"""Initialize with optional dependency cache.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
dependency_cache: Optional shared cache for dependency lookups
|
|
499
|
+
Stores (dependencies list, mtime) keyed by file path string
|
|
500
|
+
"""
|
|
501
|
+
# Initialize base class with empty module file cache
|
|
502
|
+
super().__init__()
|
|
503
|
+
self._dependency_cache = (
|
|
504
|
+
dependency_cache if dependency_cache is not None else {}
|
|
505
|
+
)
|
|
506
|
+
self._content_read_warnings: list[str] = []
|
|
507
|
+
# Interpreter fingerprints, memoised for this generator (= one test
|
|
508
|
+
# run): a probe costs a subprocess and the key is asked for once per
|
|
509
|
+
# test file. Failures are memoised too, so an interpreter that cannot
|
|
510
|
+
# answer is not re-probed for every file in the suite.
|
|
511
|
+
self._fingerprints: dict[str, str] = {}
|
|
512
|
+
self._fingerprint_failures: dict[str, str] = {}
|
|
513
|
+
|
|
514
|
+
def generate(self, test_file: Path, python_executable: str | None = None) -> str:
|
|
515
|
+
"""Generate the cache key for a test file from its cache inputs.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
test_file: Path to the test file
|
|
519
|
+
python_executable: Interpreter that will run pytest for this file.
|
|
520
|
+
Defaults to the interpreter running wl, which is only correct
|
|
521
|
+
when the two are the same; every production caller passes the
|
|
522
|
+
real one.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
The ledger's key: ``test_`` followed by the full SHA-256 digest of
|
|
526
|
+
the normalized parts.
|
|
527
|
+
|
|
528
|
+
Raises:
|
|
529
|
+
CacheKeyUnavailableError: a part of the key could not be derived
|
|
530
|
+
truthfully (an unreadable conftest/config file, or an
|
|
531
|
+
interpreter that cannot be fingerprinted). The caller must then
|
|
532
|
+
treat the file as uncacheable.
|
|
533
|
+
"""
|
|
534
|
+
return format_key(self.cache_inputs(test_file, python_executable))
|
|
535
|
+
|
|
536
|
+
def cache_inputs(
|
|
537
|
+
self, test_file: Path, python_executable: str | None = None
|
|
538
|
+
) -> CacheInputs:
|
|
539
|
+
"""Build the ordered, normalized fragments that decide this file's key.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
test_file: Path to the test file
|
|
543
|
+
python_executable: Interpreter that will run pytest for this file.
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
The test adapter's :class:`CacheInputs`.
|
|
547
|
+
|
|
548
|
+
Raises:
|
|
549
|
+
CacheKeyUnavailableError: a part of the key could not be derived
|
|
550
|
+
truthfully (an unreadable conftest/config file, or an
|
|
551
|
+
interpreter that cannot be fingerprinted).
|
|
552
|
+
"""
|
|
553
|
+
parts: list[str] = []
|
|
554
|
+
|
|
555
|
+
# Test file path and modification/change times. A path with normalized
|
|
556
|
+
# separators, so the same file spells the same key on every platform.
|
|
557
|
+
file_path_str = str(test_file).replace("\\", "/")
|
|
558
|
+
try:
|
|
559
|
+
mtime = os.path.getmtime(test_file)
|
|
560
|
+
ctime = os.path.getctime(test_file)
|
|
561
|
+
except OSError:
|
|
562
|
+
mtime = 0.0
|
|
563
|
+
ctime = 0.0
|
|
564
|
+
parts.extend((file_path_str, str(mtime), str(ctime)))
|
|
565
|
+
|
|
566
|
+
# Test file content. An unreadable file contributes a per-run random
|
|
567
|
+
# value instead, so its entry can never be mistaken for a real one.
|
|
568
|
+
try:
|
|
569
|
+
content = test_file.read_text(encoding="utf-8")
|
|
570
|
+
if not isinstance(content, str):
|
|
571
|
+
content = str(content)
|
|
572
|
+
parts.append(content)
|
|
573
|
+
except (OSError, UnicodeDecodeError):
|
|
574
|
+
parts.append(f"{random.random()}-{time.time()}")
|
|
575
|
+
if file_path_str not in self._content_read_warnings:
|
|
576
|
+
self._content_read_warnings.append(file_path_str)
|
|
577
|
+
|
|
578
|
+
# Dependency files, in sorted path order.
|
|
579
|
+
dependencies = self._get_test_dependencies(test_file)
|
|
580
|
+
sorted_dependencies = sorted(dependencies, key=lambda x: str(x))
|
|
581
|
+
for dep_file in sorted_dependencies:
|
|
582
|
+
dep_path_str = str(dep_file).replace("\\", "/")
|
|
583
|
+
parts.append(dep_path_str)
|
|
584
|
+
try:
|
|
585
|
+
parts.append(str(os.path.getmtime(dep_file)))
|
|
586
|
+
parts.append(str(os.path.getctime(dep_file)))
|
|
587
|
+
parts.append(dep_file.read_text(encoding="utf-8"))
|
|
588
|
+
except (OSError, UnicodeDecodeError):
|
|
589
|
+
parts.append(str(time.time()))
|
|
590
|
+
|
|
591
|
+
# conftest.py chain + pytest configuration: what pytest loads *around*
|
|
592
|
+
# the file, which the import graph above cannot see (#191).
|
|
593
|
+
for context_file in _pytest_context_files(test_file):
|
|
594
|
+
try:
|
|
595
|
+
context_stat = context_file.stat()
|
|
596
|
+
context_content = context_file.read_bytes()
|
|
597
|
+
except OSError as error:
|
|
598
|
+
raise CacheKeyUnavailableError(
|
|
599
|
+
f"cannot read pytest context file {context_file}: {error}"
|
|
600
|
+
) from error
|
|
601
|
+
parts.extend(
|
|
602
|
+
(
|
|
603
|
+
f"context={context_file}",
|
|
604
|
+
str(context_stat.st_mtime_ns),
|
|
605
|
+
# A part must be a str; hex keeps the exact bytes without
|
|
606
|
+
# asking the digest to carry binary data.
|
|
607
|
+
context_content.hex(),
|
|
608
|
+
)
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# Add key environment variables to cache key. NUL separates name from
|
|
612
|
+
# value so no pair can alias another: with a bare `=`, a value that
|
|
613
|
+
# happens to embed the next `NAME=` prefix (`PYTHONPATH=/xPYTHONOPTIMIZE=1`)
|
|
614
|
+
# hashes to the same bytes as that variable set separately.
|
|
615
|
+
for env_var in _KEY_ENVIRONMENT_VARIABLES:
|
|
616
|
+
if env_var in os.environ:
|
|
617
|
+
parts.append(f"{env_var}\0{os.environ[env_var]}\0")
|
|
618
|
+
|
|
619
|
+
# Add pytest version to cache key
|
|
620
|
+
if HAS_PYTEST:
|
|
621
|
+
pytest_version = pytest.__version__
|
|
622
|
+
parts.append(f"pytest_version={pytest_version}")
|
|
623
|
+
|
|
624
|
+
# The interpreter that will actually run pytest, fingerprinted in a
|
|
625
|
+
# child process: version/prefix *and* the pytest/plugin versions it can
|
|
626
|
+
# import. Raises CacheKeyUnavailableError rather than keying on a guess.
|
|
627
|
+
executable = python_executable or sys.executable
|
|
628
|
+
parts.append(f"interpreter={self._fingerprint(executable)}")
|
|
629
|
+
|
|
630
|
+
return CacheInputs(self.NAMESPACE, tuple(parts))
|
|
631
|
+
|
|
632
|
+
def _fingerprint(self, python_executable: str) -> str:
|
|
633
|
+
"""Memoised :func:`_interpreter_fingerprint` for this generator."""
|
|
634
|
+
cached = self._fingerprints.get(python_executable)
|
|
635
|
+
if cached is not None:
|
|
636
|
+
return cached
|
|
637
|
+
failure = self._fingerprint_failures.get(python_executable)
|
|
638
|
+
if failure is not None:
|
|
639
|
+
raise CacheKeyUnavailableError(failure)
|
|
640
|
+
try:
|
|
641
|
+
digest = _interpreter_fingerprint(python_executable)
|
|
642
|
+
except CacheKeyUnavailableError as error:
|
|
643
|
+
self._fingerprint_failures[python_executable] = str(error)
|
|
644
|
+
raise
|
|
645
|
+
self._fingerprints[python_executable] = digest
|
|
646
|
+
return digest
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# ---------------------------------------------------------------------------
|
|
650
|
+
# CacheManager (facade)
|
|
651
|
+
# ---------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
class CacheManager:
|
|
655
|
+
"""Cache manager for test results (the test-result cache adapter).
|
|
656
|
+
|
|
657
|
+
Narrow public cache interface: hit/record/purge/report operations composed
|
|
658
|
+
over the ledger/CacheKeyGenerator/DependencyAnalyzer/CacheMaintenance
|
|
659
|
+
behind a thread-safe lock, with read-only per-run hit tracking. The ledger
|
|
660
|
+
owns the mechanism -- key spelling, atomic writes, freshness and the write
|
|
661
|
+
error policy; this class keeps only *this cache's* policy: the inputs, the
|
|
662
|
+
user-level location and the 24-hour TTL (ADR-0011 keeps those per adapter).
|
|
663
|
+
"""
|
|
664
|
+
|
|
665
|
+
CACHE_EXPIRY_SECONDS = 86400 # 24 hours
|
|
666
|
+
|
|
667
|
+
# Annotated at class level, not only in ``__init__``: mypy does not check
|
|
668
|
+
# the body of an untyped function, so an attribute assigned only there
|
|
669
|
+
# would read as ``Any`` at every typed use site.
|
|
670
|
+
_dependency_analyzer: DependencyAnalyzer
|
|
671
|
+
_key_generator: CacheKeyGenerator
|
|
672
|
+
_ledger: CacheLedger
|
|
673
|
+
_maintenance: CacheMaintenance
|
|
674
|
+
|
|
675
|
+
def __init__(self):
|
|
676
|
+
"""Initialize CacheManager."""
|
|
677
|
+
# Cache statistics
|
|
678
|
+
self.cache_stats = {
|
|
679
|
+
"hits": 0,
|
|
680
|
+
"misses": 0,
|
|
681
|
+
"expired": 0,
|
|
682
|
+
"invalid": 0,
|
|
683
|
+
"total": 0,
|
|
684
|
+
}
|
|
685
|
+
# Runtime-set cache hit tracking
|
|
686
|
+
self._cache_hits: set[Path] = set()
|
|
687
|
+
# Shared caches passed to components for efficiency
|
|
688
|
+
self._dependency_cache: dict[str, tuple[list[Path], float]] = {}
|
|
689
|
+
self._module_file_cache: dict[tuple[str, str], Path | None] = {}
|
|
690
|
+
|
|
691
|
+
# Initialize specialized components
|
|
692
|
+
self._dependency_analyzer = DependencyAnalyzer(self._module_file_cache)
|
|
693
|
+
self._key_generator = CacheKeyGenerator(self._dependency_cache)
|
|
694
|
+
# All entry I/O goes through the ledger, rooted at the user-level
|
|
695
|
+
# test-cache directory and judged against this adapter's 24 hours. The
|
|
696
|
+
# per-run statistics below still own the "expired"/"invalid" lines, so
|
|
697
|
+
# they read raw entries and ask the ledger to judge them.
|
|
698
|
+
self._ledger = CacheLedger(default_test_cache_dir(), self.CACHE_EXPIRY_SECONDS)
|
|
699
|
+
self._maintenance = CacheMaintenance(self._ledger.store)
|
|
700
|
+
|
|
701
|
+
# Thread safety
|
|
702
|
+
self._ensure_cache_lock()
|
|
703
|
+
|
|
704
|
+
def _ensure_cache_lock(self):
|
|
705
|
+
"""Ensure cache lock exists for thread-safe operations with enhanced initialization."""
|
|
706
|
+
if not hasattr(self, "_cache_lock"):
|
|
707
|
+
with threading.Lock():
|
|
708
|
+
if not hasattr(self, "_cache_lock"):
|
|
709
|
+
self._cache_lock = threading.RLock()
|
|
710
|
+
|
|
711
|
+
@property
|
|
712
|
+
def cache_dir(self) -> Path:
|
|
713
|
+
"""Directory where cache files are stored."""
|
|
714
|
+
return self._ledger.store_dir
|
|
715
|
+
|
|
716
|
+
@property
|
|
717
|
+
def cache_hit_files(self) -> frozenset[Path]:
|
|
718
|
+
"""Read-only view of files that hit the cache during the current run."""
|
|
719
|
+
return frozenset(self._cache_hits)
|
|
720
|
+
|
|
721
|
+
def start_run(self) -> None:
|
|
722
|
+
"""Reset per-run hit tracking and counters before a fresh test run."""
|
|
723
|
+
self._cache_hits = set()
|
|
724
|
+
self.cache_stats = {
|
|
725
|
+
"hits": 0,
|
|
726
|
+
"misses": 0,
|
|
727
|
+
"expired": 0,
|
|
728
|
+
"invalid": 0,
|
|
729
|
+
"total": 0,
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
def get_cache_key(
|
|
733
|
+
self, test_file: Path, python_executable: str | None = None
|
|
734
|
+
) -> str:
|
|
735
|
+
"""Generate cache key based on file content, dependencies and environment.
|
|
736
|
+
|
|
737
|
+
Raises:
|
|
738
|
+
CacheKeyUnavailableError: the key cannot be derived truthfully, so the
|
|
739
|
+
file must not be cached (see :class:`CacheKeyUnavailableError`).
|
|
740
|
+
"""
|
|
741
|
+
return self._key_generator.generate(test_file, python_executable)
|
|
742
|
+
|
|
743
|
+
def invalidate_file(
|
|
744
|
+
self,
|
|
745
|
+
test_file: Path,
|
|
746
|
+
step_by_step: bool = False,
|
|
747
|
+
concise: bool = False,
|
|
748
|
+
python_executable: str | None = None,
|
|
749
|
+
) -> None:
|
|
750
|
+
"""Drop *test_file*'s entry.
|
|
751
|
+
|
|
752
|
+
A key that cannot be derived names no entry to delete, so an unavailable
|
|
753
|
+
key is a no-op here rather than an error: the caller's intent (leave no
|
|
754
|
+
passing entry behind) is already satisfied.
|
|
755
|
+
"""
|
|
756
|
+
try:
|
|
757
|
+
cache_key = self.get_cache_key(test_file, python_executable)
|
|
758
|
+
except CacheKeyUnavailableError:
|
|
759
|
+
return
|
|
760
|
+
self.delete(cache_key, step_by_step, concise)
|
|
761
|
+
|
|
762
|
+
def get_test_dependencies(self, test_file: Path) -> list[Path]:
|
|
763
|
+
"""Get test file dependencies by analyzing imports, including indirect dependencies."""
|
|
764
|
+
return self._dependency_analyzer.analyze(test_file)
|
|
765
|
+
|
|
766
|
+
def _validate_cache_timestamp(
|
|
767
|
+
self, data: dict[str, Any], step_by_step: bool = False, concise: bool = False
|
|
768
|
+
) -> bool:
|
|
769
|
+
"""Validate cache timestamp.
|
|
770
|
+
|
|
771
|
+
The freshness decision itself is :func:`is_fresh` (the stack's single
|
|
772
|
+
freshness judgement); the checks around it only distinguish the log
|
|
773
|
+
lines and keep the cache statistics.
|
|
774
|
+
"""
|
|
775
|
+
try:
|
|
776
|
+
timestamp = data.get("timestamp")
|
|
777
|
+
if not timestamp:
|
|
778
|
+
if not concise and not step_by_step:
|
|
779
|
+
print("[Cache] Missing timestamp in cache")
|
|
780
|
+
return False
|
|
781
|
+
|
|
782
|
+
current_time = time.time()
|
|
783
|
+
# Computed before the freshness check so a non-numeric timestamp
|
|
784
|
+
# still takes the error path it always has (the ``except`` below).
|
|
785
|
+
time_diff = current_time - timestamp
|
|
786
|
+
if not self._ledger.fresh(data, now=current_time):
|
|
787
|
+
if not concise and not step_by_step:
|
|
788
|
+
print(
|
|
789
|
+
f"[Cache] Cache expired: current={current_time}, timestamp={timestamp}, diff={time_diff}"
|
|
790
|
+
)
|
|
791
|
+
self.cache_stats["expired"] += 1
|
|
792
|
+
self.cache_stats["total"] += 1
|
|
793
|
+
return False
|
|
794
|
+
else:
|
|
795
|
+
if not concise and not step_by_step:
|
|
796
|
+
print(
|
|
797
|
+
f"[Cache] Cache valid: current={current_time}, timestamp={timestamp}, diff={time_diff}"
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
return True
|
|
801
|
+
except Exception as e:
|
|
802
|
+
if not concise and not step_by_step:
|
|
803
|
+
print(f"[Cache] Error validating timestamp: {e}")
|
|
804
|
+
return False
|
|
805
|
+
|
|
806
|
+
def _validate_cache_result(
|
|
807
|
+
self, data: dict[str, Any], step_by_step: bool = False
|
|
808
|
+
) -> tuple[bool, bool | None]:
|
|
809
|
+
"""Validate cache result format."""
|
|
810
|
+
result = data.get("result", False)
|
|
811
|
+
if not isinstance(result, bool):
|
|
812
|
+
if not step_by_step:
|
|
813
|
+
print("[Cache] Invalid result format in cache file")
|
|
814
|
+
self.cache_stats["invalid"] += 1
|
|
815
|
+
self.cache_stats["total"] += 1
|
|
816
|
+
return False, None
|
|
817
|
+
return True, result
|
|
818
|
+
|
|
819
|
+
def _check_cache_consistency(
|
|
820
|
+
self,
|
|
821
|
+
test_file: Path | None,
|
|
822
|
+
cache_key: str,
|
|
823
|
+
step_by_step: bool = False,
|
|
824
|
+
python_executable: str | None = None,
|
|
825
|
+
) -> bool:
|
|
826
|
+
"""Check cache consistency."""
|
|
827
|
+
if test_file:
|
|
828
|
+
current_cache_key = self.get_cache_key(test_file, python_executable)
|
|
829
|
+
if current_cache_key != cache_key:
|
|
830
|
+
if not step_by_step:
|
|
831
|
+
print(
|
|
832
|
+
f"[Cache] Cache inconsistent for {test_file.name}, key mismatch"
|
|
833
|
+
)
|
|
834
|
+
self.cache_stats["invalid"] += 1
|
|
835
|
+
self.cache_stats["total"] += 1
|
|
836
|
+
return False
|
|
837
|
+
return True
|
|
838
|
+
|
|
839
|
+
def load(
|
|
840
|
+
self,
|
|
841
|
+
cache_key: str,
|
|
842
|
+
test_file: Path | None = None,
|
|
843
|
+
step_by_step: bool = False,
|
|
844
|
+
concise: bool = False,
|
|
845
|
+
python_executable: str | None = None,
|
|
846
|
+
) -> bool | None:
|
|
847
|
+
"""Load cached test result with enhanced error handling and consistency check.
|
|
848
|
+
|
|
849
|
+
Returns ``None`` -- a miss -- whenever the entry cannot be used: an
|
|
850
|
+
illegal key (see ``KeyedStore``, which degrades instead of raising), a
|
|
851
|
+
missing entry, one whose file cannot be read, one that exists but holds
|
|
852
|
+
corrupt JSON or something other than a JSON object, and one that fails
|
|
853
|
+
the timestamp/result/consistency checks. Every miss is counted in
|
|
854
|
+
``cache_stats``.
|
|
855
|
+
|
|
856
|
+
Counting a corrupt or non-object entry as a miss narrows the behaviour
|
|
857
|
+
from before this class moved onto ``KeyedStore`` (#132), which returned
|
|
858
|
+
``None`` silently without touching the counters: the entry is unusable,
|
|
859
|
+
so it *is* a miss, and counting it makes the stats more honest. The
|
|
860
|
+
change is intentional and pinned by ``test_cache_manager``. The only
|
|
861
|
+
reachable route to such an entry is external tampering -- every write
|
|
862
|
+
here is atomic and goes through :meth:`save`.
|
|
863
|
+
"""
|
|
864
|
+
try:
|
|
865
|
+
self._ensure_cache_lock()
|
|
866
|
+
|
|
867
|
+
with self._cache_lock:
|
|
868
|
+
data = self._ledger.read(cache_key)
|
|
869
|
+
if data is None:
|
|
870
|
+
if not concise and not step_by_step:
|
|
871
|
+
print(
|
|
872
|
+
f"[Cache] Cache file not found: "
|
|
873
|
+
f"{_log_safe_key(cache_key)}.json"
|
|
874
|
+
)
|
|
875
|
+
self.cache_stats["misses"] += 1
|
|
876
|
+
self.cache_stats["total"] += 1
|
|
877
|
+
return None
|
|
878
|
+
|
|
879
|
+
if not self._validate_cache_timestamp(data, step_by_step, concise):
|
|
880
|
+
return None
|
|
881
|
+
|
|
882
|
+
valid, result = self._validate_cache_result(data, step_by_step)
|
|
883
|
+
if not valid:
|
|
884
|
+
return None
|
|
885
|
+
|
|
886
|
+
if not self._check_cache_consistency(
|
|
887
|
+
test_file, cache_key, step_by_step, python_executable
|
|
888
|
+
):
|
|
889
|
+
return None
|
|
890
|
+
|
|
891
|
+
if not step_by_step:
|
|
892
|
+
print(
|
|
893
|
+
f"[Cache] Cache loaded successfully for key: {cache_key[:8]}..."
|
|
894
|
+
)
|
|
895
|
+
self.cache_stats["hits"] += 1
|
|
896
|
+
self.cache_stats["total"] += 1
|
|
897
|
+
return result
|
|
898
|
+
except OSError as e:
|
|
899
|
+
if not step_by_step:
|
|
900
|
+
print(f"[Cache] File access error: {e}")
|
|
901
|
+
self.cache_stats["misses"] += 1
|
|
902
|
+
self.cache_stats["total"] += 1
|
|
903
|
+
return None
|
|
904
|
+
except Exception as e:
|
|
905
|
+
if not step_by_step:
|
|
906
|
+
print(f"[Cache] Unexpected error loading cache: {e}")
|
|
907
|
+
self.cache_stats["misses"] += 1
|
|
908
|
+
self.cache_stats["total"] += 1
|
|
909
|
+
return None
|
|
910
|
+
|
|
911
|
+
def save(
|
|
912
|
+
self,
|
|
913
|
+
cache_key: str,
|
|
914
|
+
result: bool,
|
|
915
|
+
step_by_step: bool = False,
|
|
916
|
+
concise: bool = False,
|
|
917
|
+
) -> None:
|
|
918
|
+
"""Save test result to cache with enhanced error handling.
|
|
919
|
+
|
|
920
|
+
Reports failure with the ``[Cache] Failed to save`` line instead of a
|
|
921
|
+
false success: the ledger returns ``False`` for an illegal key (see
|
|
922
|
+
``KeyedStore``) or a failed write, and that result is passed on rather
|
|
923
|
+
than ignored. The entry's ``timestamp`` is the ledger's.
|
|
924
|
+
"""
|
|
925
|
+
data = {
|
|
926
|
+
"result": result,
|
|
927
|
+
"version": "1.0",
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
try:
|
|
931
|
+
self._ensure_cache_lock()
|
|
932
|
+
|
|
933
|
+
with self._cache_lock:
|
|
934
|
+
if not self._ledger.put(cache_key, data):
|
|
935
|
+
if not concise and not step_by_step:
|
|
936
|
+
print(
|
|
937
|
+
f"[Cache] Failed to save cache for key: "
|
|
938
|
+
f"{_log_safe_key(cache_key)}"
|
|
939
|
+
)
|
|
940
|
+
return
|
|
941
|
+
|
|
942
|
+
if not concise and not step_by_step:
|
|
943
|
+
print(
|
|
944
|
+
f"[Cache] Cache saved successfully for key: {cache_key[:8]}..."
|
|
945
|
+
)
|
|
946
|
+
except OSError as e:
|
|
947
|
+
if not concise and not step_by_step:
|
|
948
|
+
print(f"[Cache] File access error saving cache: {e}")
|
|
949
|
+
except Exception as e:
|
|
950
|
+
if not concise and not step_by_step:
|
|
951
|
+
print(f"[Cache] Unexpected error saving cache: {e}")
|
|
952
|
+
|
|
953
|
+
def delete(
|
|
954
|
+
self, cache_key: str, step_by_step: bool = False, concise: bool = False
|
|
955
|
+
) -> None:
|
|
956
|
+
"""Delete cache for the specified key with enhanced error handling.
|
|
957
|
+
|
|
958
|
+
Reports failure with the ``[Cache] Failed to delete`` line instead of
|
|
959
|
+
raising: an illegal key (see ``KeyedStore`` -- a non-``str``, the empty
|
|
960
|
+
string, a path separator, a directory reference, a control character, or
|
|
961
|
+
a character unencodable as a file name) takes the same path as a missing
|
|
962
|
+
entry, because an illegal key names no entry to delete. The key is
|
|
963
|
+
rendered log-safe, so even an unencodable one cannot crash the line.
|
|
964
|
+
"""
|
|
965
|
+
success = self._ledger.invalidate(cache_key)
|
|
966
|
+
if not success and not concise and not step_by_step:
|
|
967
|
+
print(f"[Cache] Failed to delete cache for key: {_log_safe_key(cache_key)}")
|
|
968
|
+
|
|
969
|
+
def purge_expired(self) -> None:
|
|
970
|
+
"""Remove cache entries older than 24 hours, judged by payload timestamp."""
|
|
971
|
+
deleted = self._maintenance.purge_expired(self.CACHE_EXPIRY_SECONDS)
|
|
972
|
+
if deleted > 0:
|
|
973
|
+
print(f"[Cache] Cleaned up {deleted} expired cache files")
|
|
974
|
+
|
|
975
|
+
def purge_all(self) -> None:
|
|
976
|
+
"""Clean all test cache."""
|
|
977
|
+
deleted = self._maintenance.clear_all()
|
|
978
|
+
print(f"\nCleanup completed. Removed {deleted} cache files.")
|
|
979
|
+
|
|
980
|
+
def info(self) -> None:
|
|
981
|
+
"""Show cache information."""
|
|
982
|
+
print(self._maintenance.print_stats(output=False))
|
|
983
|
+
|
|
984
|
+
print("\n=== Cache Usage Tips ===")
|
|
985
|
+
print("- Use --no-cache to skip cache for a single run")
|
|
986
|
+
print("- Use --clean-cache to remove all cache files")
|
|
987
|
+
print("- Cache automatically expires after 24 hours")
|
|
988
|
+
|
|
989
|
+
def report_summary(self, test_files: list[Path], concise: bool = False) -> None:
|
|
990
|
+
"""Print per-run cache-usage summary from hit tracking."""
|
|
991
|
+
total_files = len(test_files)
|
|
992
|
+
cache_hit_count = len(self._cache_hits)
|
|
993
|
+
|
|
994
|
+
if cache_hit_count > 0 and not concise:
|
|
995
|
+
cache_hit_percent = (cache_hit_count / total_files) * 100
|
|
996
|
+
print(
|
|
997
|
+
f"Cache usage: {cache_hit_count}/{total_files} files ({cache_hit_percent:.1f}%) used cached results"
|
|
998
|
+
)
|
|
999
|
+
elif not concise:
|
|
1000
|
+
print("Cache usage: No cached results used")
|
|
1001
|
+
|
|
1002
|
+
def report_detail(self) -> None:
|
|
1003
|
+
"""Print detailed cache statistics and reset the run counters."""
|
|
1004
|
+
print("\n=== Detailed Cache Statistics ===")
|
|
1005
|
+
print(f"Total cache operations: {self.cache_stats['total']}")
|
|
1006
|
+
print(f"Cache hits: {self.cache_stats['hits']}")
|
|
1007
|
+
print(f"Cache misses: {self.cache_stats['misses']}")
|
|
1008
|
+
print(f"Expired caches: {self.cache_stats['expired']}")
|
|
1009
|
+
print(f"Invalid caches: {self.cache_stats['invalid']}")
|
|
1010
|
+
|
|
1011
|
+
if self.cache_stats["total"] > 0:
|
|
1012
|
+
hit_rate = (self.cache_stats["hits"] / self.cache_stats["total"]) * 100
|
|
1013
|
+
miss_rate = (self.cache_stats["misses"] / self.cache_stats["total"]) * 100
|
|
1014
|
+
invalid_rate = (
|
|
1015
|
+
(self.cache_stats["expired"] + self.cache_stats["invalid"])
|
|
1016
|
+
/ self.cache_stats["total"]
|
|
1017
|
+
) * 100
|
|
1018
|
+
print(f"\nCache hit rate: {hit_rate:.1f}%")
|
|
1019
|
+
print(f"Cache miss rate: {miss_rate:.1f}%")
|
|
1020
|
+
print(f"Cache invalidation rate: {invalid_rate:.1f}%")
|
|
1021
|
+
|
|
1022
|
+
self.cache_stats = {
|
|
1023
|
+
"hits": 0,
|
|
1024
|
+
"misses": 0,
|
|
1025
|
+
"expired": 0,
|
|
1026
|
+
"invalid": 0,
|
|
1027
|
+
"total": 0,
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
def all_cached(
|
|
1031
|
+
self,
|
|
1032
|
+
test_files: list[Path],
|
|
1033
|
+
concise: bool = False,
|
|
1034
|
+
python_executable: str | None = None,
|
|
1035
|
+
) -> bool:
|
|
1036
|
+
"""Check if all test files have valid cached results.
|
|
1037
|
+
|
|
1038
|
+
An unavailable key counts as "not cached": the run must happen rather
|
|
1039
|
+
than be skipped on the strength of a key that could not be derived.
|
|
1040
|
+
"""
|
|
1041
|
+
if not test_files:
|
|
1042
|
+
return False
|
|
1043
|
+
|
|
1044
|
+
if not concise:
|
|
1045
|
+
print("[Cache] Checking cache for all test files...")
|
|
1046
|
+
for test_file in test_files:
|
|
1047
|
+
try:
|
|
1048
|
+
cache_key = self.get_cache_key(test_file, python_executable)
|
|
1049
|
+
if not concise:
|
|
1050
|
+
print(
|
|
1051
|
+
f"[Cache] Checking cache for {test_file.name} (key: {cache_key[:8]}...)"
|
|
1052
|
+
)
|
|
1053
|
+
cache_result = self.load(
|
|
1054
|
+
cache_key,
|
|
1055
|
+
test_file,
|
|
1056
|
+
concise=concise,
|
|
1057
|
+
python_executable=python_executable,
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
if cache_result is None:
|
|
1061
|
+
if not concise:
|
|
1062
|
+
print(f"[Cache] Cache miss for {test_file.name}")
|
|
1063
|
+
return False
|
|
1064
|
+
elif not cache_result:
|
|
1065
|
+
if not concise:
|
|
1066
|
+
print(
|
|
1067
|
+
f"[Cache] Cached failure for {test_file.name}, need to re-run"
|
|
1068
|
+
)
|
|
1069
|
+
return False
|
|
1070
|
+
else:
|
|
1071
|
+
if not concise:
|
|
1072
|
+
print(f"[Cache] Cache hit for {test_file.name}")
|
|
1073
|
+
except Exception as e:
|
|
1074
|
+
if not concise:
|
|
1075
|
+
print(f"[Cache] Error checking cache for {test_file.name}: {e}")
|
|
1076
|
+
return False
|
|
1077
|
+
return True
|
|
1078
|
+
|
|
1079
|
+
def record_passed_batch(
|
|
1080
|
+
self,
|
|
1081
|
+
test_files: list[Path],
|
|
1082
|
+
concise: bool = False,
|
|
1083
|
+
python_executable: str | None = None,
|
|
1084
|
+
) -> None:
|
|
1085
|
+
"""Record a successful run for exactly the given test files.
|
|
1086
|
+
|
|
1087
|
+
The caller decides *which* files these are -- the set the run actually
|
|
1088
|
+
executed (#191). Only the success path lives here. Attributing an
|
|
1089
|
+
individual failing file (re-running pytest per file) is the runner's
|
|
1090
|
+
job, which then calls :meth:`record_passed_single` / :meth:`delete` per
|
|
1091
|
+
outcome.
|
|
1092
|
+
"""
|
|
1093
|
+
print("[Cache] All tests passed, saving cache for all files...")
|
|
1094
|
+
for test_file in test_files:
|
|
1095
|
+
try:
|
|
1096
|
+
cache_key = self.get_cache_key(test_file, python_executable)
|
|
1097
|
+
except CacheKeyUnavailableError as error:
|
|
1098
|
+
print(f"[Cache] Not caching {test_file.name}: {error}")
|
|
1099
|
+
continue
|
|
1100
|
+
try:
|
|
1101
|
+
print(
|
|
1102
|
+
f"[Cache] Saving cache for {test_file.name} (key: {cache_key[:8]}...)"
|
|
1103
|
+
)
|
|
1104
|
+
self.save(cache_key, True, concise=concise)
|
|
1105
|
+
except Exception as e:
|
|
1106
|
+
print(f"[Cache] Error saving cache for {test_file.name}: {e}")
|
|
1107
|
+
|
|
1108
|
+
def cached_result(
|
|
1109
|
+
self,
|
|
1110
|
+
test_file: Path,
|
|
1111
|
+
step_by_step: bool = False,
|
|
1112
|
+
concise: bool = False,
|
|
1113
|
+
python_executable: str | None = None,
|
|
1114
|
+
) -> tuple[bool, str, bool, None]:
|
|
1115
|
+
"""Check cache for a single test file.
|
|
1116
|
+
|
|
1117
|
+
An unavailable key is reported as a miss, never as a hit and never as
|
|
1118
|
+
an error the caller has to know about.
|
|
1119
|
+
"""
|
|
1120
|
+
try:
|
|
1121
|
+
cache_key = self.get_cache_key(test_file, python_executable)
|
|
1122
|
+
cache_result = self.load(
|
|
1123
|
+
cache_key,
|
|
1124
|
+
test_file,
|
|
1125
|
+
step_by_step,
|
|
1126
|
+
concise,
|
|
1127
|
+
python_executable,
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
if cache_result is not None:
|
|
1131
|
+
if not hasattr(self, "_cache_hits"):
|
|
1132
|
+
self._cache_hits = set()
|
|
1133
|
+
self._cache_hits.add(test_file)
|
|
1134
|
+
return True, "Cache hit", cache_result, None
|
|
1135
|
+
else:
|
|
1136
|
+
return False, "Cache miss", False, None
|
|
1137
|
+
except Exception as e:
|
|
1138
|
+
return False, f"Cache check error: {e}", False, None
|
|
1139
|
+
|
|
1140
|
+
def record_passed_single(
|
|
1141
|
+
self,
|
|
1142
|
+
test_file: Path,
|
|
1143
|
+
step_by_step: bool = False,
|
|
1144
|
+
concise: bool = False,
|
|
1145
|
+
python_executable: str | None = None,
|
|
1146
|
+
) -> None:
|
|
1147
|
+
"""Record a passing single-file result in the cache.
|
|
1148
|
+
|
|
1149
|
+
Silent by design: an unavailable key simply means the file is not
|
|
1150
|
+
cached, which is the same outcome as a save failure.
|
|
1151
|
+
"""
|
|
1152
|
+
try:
|
|
1153
|
+
cache_key = self.get_cache_key(test_file, python_executable)
|
|
1154
|
+
self.save(cache_key, True, step_by_step, concise)
|
|
1155
|
+
except Exception:
|
|
1156
|
+
pass
|