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,355 @@
|
|
|
1
|
+
"""The single cache seam: cache inputs -> key, plus freshness and error policy.
|
|
2
|
+
|
|
3
|
+
ADR-0011 keeps three caches -- test results, coverage, format results -- and
|
|
4
|
+
gives them one mechanism. Each cache is an *adapter*: it answers what enters its
|
|
5
|
+
key (a :class:`CacheInputs`), how long an entry stays fresh (``ttl_seconds``)
|
|
6
|
+
and where the entries live (``store_dir``). This module answers everything else,
|
|
7
|
+
once:
|
|
8
|
+
|
|
9
|
+
- **Key spelling** -- ``{namespace}_{sha256(parts)}``, the *full* digest. The
|
|
10
|
+
coverage cache used to truncate the digest to 16 characters; that truncation
|
|
11
|
+
is gone, so a key is as unique as the hash under it.
|
|
12
|
+
- **Storage** -- atomic writes delegated to
|
|
13
|
+
:class:`~wl_cli.utils.cache_store.KeyedStore`.
|
|
14
|
+
- **Freshness** -- :func:`~wl_cli.utils.cache_store.is_fresh` applied to the
|
|
15
|
+
entry's own ``timestamp`` field with the adapter's TTL. Nothing expires by
|
|
16
|
+
file mtime, and the TTL values are the adapters' policy, not a debt to unify:
|
|
17
|
+
test results are fresh for 24 hours, coverage and format results for 7 days.
|
|
18
|
+
- **Error policy** -- a failed *read* is a cache miss (silent: not caching is
|
|
19
|
+
always safe), a failed *write* never interrupts the run but is logged with a
|
|
20
|
+
``log_*_pair``, so the user can see why the next run is slow.
|
|
21
|
+
|
|
22
|
+
The parts of a :class:`CacheInputs` are ordered and normalized by the adapter.
|
|
23
|
+
Each part is length-prefixed before hashing, so ``("ab", "c")`` and
|
|
24
|
+
``("a", "bc")`` cannot collide, and no part can smuggle in a delimiter that
|
|
25
|
+
makes two different input sets hash alike.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from .cache_store import KeyedStore, is_fresh
|
|
35
|
+
from .logging import log_warning_pair
|
|
36
|
+
|
|
37
|
+
#: Characters that would let a namespace escape the store directory (keys become
|
|
38
|
+
#: file names, see :mod:`wl_cli.utils.cache_store`).
|
|
39
|
+
_NAMESPACE_PATH_SEPARATORS = ("/", "\\")
|
|
40
|
+
|
|
41
|
+
#: C0 control characters (including NUL) and DEL are rejected on every platform,
|
|
42
|
+
#: matching the entry-key rule in :mod:`wl_cli.utils.cache_store`.
|
|
43
|
+
_FIRST_PRINTABLE_CODEPOINT = 0x20
|
|
44
|
+
_DELETE_CODEPOINT = 0x7F
|
|
45
|
+
|
|
46
|
+
#: How much of a key a write-failure log line shows. A preview only -- the key
|
|
47
|
+
#: itself is never truncated.
|
|
48
|
+
_KEY_PREVIEW_LENGTH = 16
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class CacheInputs:
|
|
53
|
+
"""What an adapter says decides its cache key.
|
|
54
|
+
|
|
55
|
+
A namespace (which cache this is) plus the ordered, already-normalized
|
|
56
|
+
fragments that must move the key when they change. The key is a *function*
|
|
57
|
+
of these inputs -- :func:`format_key` -- not a synonym for them.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
namespace: Cache identity, e.g. ``"test"``, ``"coverage"``,
|
|
61
|
+
``"format"``. Becomes the key's prefix, so it must be usable inside
|
|
62
|
+
a file name: non-empty, no path separator, no control character.
|
|
63
|
+
parts: The fragments, in the adapter's order. Each is hashed verbatim;
|
|
64
|
+
the adapter is responsible for normalizing it (path separators,
|
|
65
|
+
number formatting) so two processes agree.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
namespace: str
|
|
69
|
+
parts: tuple[str, ...]
|
|
70
|
+
|
|
71
|
+
def __post_init__(self) -> None:
|
|
72
|
+
"""Reject an unusable namespace or part at construction, once.
|
|
73
|
+
|
|
74
|
+
Validating here means a key can never be spelled from inputs that would
|
|
75
|
+
produce an illegal file name, so an adapter's mistake surfaces at the
|
|
76
|
+
call rather than as a silent cache miss later.
|
|
77
|
+
"""
|
|
78
|
+
if not isinstance(self.namespace, str) or not self.namespace:
|
|
79
|
+
raise ValueError("Cache namespace must be a non-empty str")
|
|
80
|
+
if any(separator in self.namespace for separator in _NAMESPACE_PATH_SEPARATORS):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Cache namespace must not contain a path separator: {self.namespace!r}"
|
|
83
|
+
)
|
|
84
|
+
if any(
|
|
85
|
+
ord(character) < _FIRST_PRINTABLE_CODEPOINT
|
|
86
|
+
or ord(character) == _DELETE_CODEPOINT
|
|
87
|
+
for character in self.namespace
|
|
88
|
+
):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Cache namespace must not contain control characters: "
|
|
91
|
+
f"{self.namespace!r}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# The parts are typed ``tuple[str, ...]`` and every adapter spells them
|
|
95
|
+
# as strings; a wrong type is a programming error at the call site, not
|
|
96
|
+
# a runtime condition this value object should paper over.
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _digest(parts: tuple[str, ...]) -> str:
|
|
100
|
+
"""SHA-256 over the length-prefixed parts, as hexadecimal.
|
|
101
|
+
|
|
102
|
+
Length-prefixing is what makes the parts unambiguous: without it the
|
|
103
|
+
concatenation of ``("ab", "c")`` and ``("a", "bc")`` is the same bytes, and
|
|
104
|
+
two different input sets would share a key.
|
|
105
|
+
"""
|
|
106
|
+
hasher = hashlib.sha256()
|
|
107
|
+
for part in parts:
|
|
108
|
+
encoded = part.encode("utf-8")
|
|
109
|
+
hasher.update(f"{len(encoded)}:".encode("ascii"))
|
|
110
|
+
hasher.update(encoded)
|
|
111
|
+
return hasher.hexdigest()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def format_key(inputs: CacheInputs) -> str:
|
|
115
|
+
"""Spell the key for *inputs*: ``{namespace}_{sha256(parts)}``.
|
|
116
|
+
|
|
117
|
+
A pure function of its input -- no clock, no environment, no file system --
|
|
118
|
+
so two processes that agree on the inputs agree on the key.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
inputs: The adapter's cache inputs.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The entry key: the full digest, never a truncated one.
|
|
125
|
+
"""
|
|
126
|
+
return f"{inputs.namespace}_{_digest(inputs.parts)}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _preview(key: object) -> str:
|
|
130
|
+
"""Render a key for a log line: safe to print, shortened for reading."""
|
|
131
|
+
text = key if isinstance(key, str) else repr(key)
|
|
132
|
+
safe = text.encode("utf-8", "backslashreplace").decode("utf-8")
|
|
133
|
+
if len(safe) <= _KEY_PREVIEW_LENGTH:
|
|
134
|
+
return safe
|
|
135
|
+
return f"{safe[:_KEY_PREVIEW_LENGTH]}..."
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class CacheLedger:
|
|
139
|
+
"""The cache mechanism every adapter shares.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
store_dir: Directory holding the entries. The adapter decides it --
|
|
143
|
+
user-level for test and coverage results, project-level for format.
|
|
144
|
+
ttl_seconds: How long an entry stays fresh, for this adapter only.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(self, store_dir: Path, ttl_seconds: float) -> None:
|
|
148
|
+
self._store = KeyedStore(store_dir)
|
|
149
|
+
self._ttl_seconds = ttl_seconds
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def store_dir(self) -> Path:
|
|
153
|
+
"""Directory holding the entries."""
|
|
154
|
+
return self._store.store_dir
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def store(self) -> KeyedStore:
|
|
158
|
+
"""The underlying store, for maintenance and statistics.
|
|
159
|
+
|
|
160
|
+
Enumerating and measuring entries is a different concern from deciding
|
|
161
|
+
a key or judging freshness (``CacheMaintenance`` owns the test cache's
|
|
162
|
+
CLI statistics and purge; :meth:`purge_stale` is the per-adapter
|
|
163
|
+
housekeeping step). Key spelling and the error policy stay here --
|
|
164
|
+
callers must not write entries around the ledger.
|
|
165
|
+
"""
|
|
166
|
+
return self._store
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def ttl_seconds(self) -> float:
|
|
170
|
+
"""How long an entry stays fresh, in seconds."""
|
|
171
|
+
return self._ttl_seconds
|
|
172
|
+
|
|
173
|
+
def key(self, inputs: CacheInputs) -> str:
|
|
174
|
+
"""Return the entry key for *inputs* (see :func:`format_key`)."""
|
|
175
|
+
return format_key(inputs)
|
|
176
|
+
|
|
177
|
+
def read(self, key: str) -> dict[str, Any] | None:
|
|
178
|
+
"""Read the entry under *key* without judging freshness.
|
|
179
|
+
|
|
180
|
+
A read failure of any kind is a miss: the store already degrades an
|
|
181
|
+
unreadable, corrupt or non-object entry to ``None``, and an ``OSError``
|
|
182
|
+
escaping it (a permission change between the existence check and the
|
|
183
|
+
open) means the same thing -- there is no cache here.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
key: Entry key.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The stored dict, or ``None`` on any miss or read failure.
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
return self._store.get(key)
|
|
193
|
+
except OSError:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def get(self, key: str) -> dict[str, Any] | None:
|
|
197
|
+
"""Read the entry under *key* if it is still fresh.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
key: Entry key.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
The stored dict when it exists and its ``timestamp`` is within this
|
|
204
|
+
ledger's TTL; ``None`` otherwise.
|
|
205
|
+
"""
|
|
206
|
+
entry = self.read(key)
|
|
207
|
+
if entry is None or not self.fresh(entry):
|
|
208
|
+
return None
|
|
209
|
+
return entry
|
|
210
|
+
|
|
211
|
+
def fresh(self, entry: Any, *, now: float | None = None) -> bool:
|
|
212
|
+
"""Judge *entry* against this ledger's TTL.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
entry: A stored entry, as returned by :meth:`read`.
|
|
216
|
+
now: Current epoch time; ``time.time()`` when omitted. Injectable
|
|
217
|
+
so a TTL boundary can be tested exactly.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
True when the entry's payload timestamp is younger than this
|
|
221
|
+
ledger's TTL.
|
|
222
|
+
"""
|
|
223
|
+
return is_fresh(entry, self._ttl_seconds, now=now)
|
|
224
|
+
|
|
225
|
+
def put(self, key: str, payload: dict[str, Any]) -> bool:
|
|
226
|
+
"""Write *payload* under *key*, stamped with the current time.
|
|
227
|
+
|
|
228
|
+
The entry's ``timestamp`` is the ledger's, always: freshness is judged
|
|
229
|
+
from it, so an adapter cannot accidentally write an entry that is born
|
|
230
|
+
stale or immortal.
|
|
231
|
+
|
|
232
|
+
A failed write never interrupts the caller. It is reported through the
|
|
233
|
+
return value and a bilingual warning, so the failure is visible instead
|
|
234
|
+
of silently costing the next run a full re-run.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
key: Entry key.
|
|
238
|
+
payload: JSON-serializable fields to store beside the timestamp.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
True if the entry was written; False if the key is illegal (see
|
|
242
|
+
:class:`~wl_cli.utils.cache_store.KeyedStore`) or the write failed.
|
|
243
|
+
"""
|
|
244
|
+
entry = {**payload, "timestamp": time.time()}
|
|
245
|
+
try:
|
|
246
|
+
written = self._store.put(key, entry)
|
|
247
|
+
except OSError:
|
|
248
|
+
# ``KeyedStore.put`` already degrades the failures it can see; this
|
|
249
|
+
# covers the ones that escape it (the store directory's parent is a
|
|
250
|
+
# file, so ``mkdir`` raises). A cache write must never break a run.
|
|
251
|
+
written = False
|
|
252
|
+
|
|
253
|
+
if not written:
|
|
254
|
+
preview = _preview(key)
|
|
255
|
+
log_warning_pair(
|
|
256
|
+
f"Could not write the cache entry {preview}; continuing without it",
|
|
257
|
+
f"无法写入缓存条目 {preview};已跳过该条目,继续运行",
|
|
258
|
+
)
|
|
259
|
+
return written
|
|
260
|
+
|
|
261
|
+
def touch(self, key: str, *, now: float | None = None) -> bool:
|
|
262
|
+
"""Renew a fresh entry's timestamp, reporting whether it was a hit.
|
|
263
|
+
|
|
264
|
+
A hit used to be a silent read, so an entry's age was counted from the
|
|
265
|
+
run that recorded it: a verdict served on every run still expired seven
|
|
266
|
+
days after it was first written. The old single-document format cache
|
|
267
|
+
re-stamped every entry it served when it saved, and this restores that
|
|
268
|
+
sliding window (#213 review LOW-3) -- a fresh entry being used again
|
|
269
|
+
stays fresh for another TTL.
|
|
270
|
+
|
|
271
|
+
A stale entry is **not** renewed: the caller is going to re-run and
|
|
272
|
+
record a new verdict, and resurrecting an expired one would turn a
|
|
273
|
+
failed re-run into a false hit.
|
|
274
|
+
|
|
275
|
+
The renewal is best effort and silent. A failed renewal leaves the
|
|
276
|
+
entry with its old timestamp, which can only make it expire sooner --
|
|
277
|
+
the same outcome as not touching it -- so it is not a cache-write
|
|
278
|
+
failure worth a warning on every hit (compare :meth:`put`, where the
|
|
279
|
+
entry would otherwise never exist).
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
key: Entry key.
|
|
283
|
+
now: Current epoch time; ``time.time()`` when omitted.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
True when the entry existed and was fresh; False on a miss or a
|
|
287
|
+
stale entry. A failed renewal still reports True -- the hit stands.
|
|
288
|
+
"""
|
|
289
|
+
entry = self.read(key)
|
|
290
|
+
if entry is None or not self.fresh(entry, now=now):
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
payload = {name: value for name, value in entry.items() if name != "timestamp"}
|
|
294
|
+
payload["timestamp"] = time.time() if now is None else now
|
|
295
|
+
try:
|
|
296
|
+
self._store.put(key, payload)
|
|
297
|
+
except OSError:
|
|
298
|
+
# ``KeyedStore.put`` degrades what it can see; this covers what
|
|
299
|
+
# escapes it, exactly as ``put`` does. See the docstring: a lost
|
|
300
|
+
# renewal is not a lost entry.
|
|
301
|
+
pass
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
def purge_stale(self, *, now: float | None = None) -> int:
|
|
305
|
+
"""Delete every entry that can no longer be a hit.
|
|
306
|
+
|
|
307
|
+
An entry is reclaimed when it is older than this ledger's TTL, or when
|
|
308
|
+
it cannot be read as a JSON object at all: neither can ever be served,
|
|
309
|
+
so both only cost disk. Per-entry files never expire on their own --
|
|
310
|
+
the format adapter's old single-document ``save`` was what implicitly
|
|
311
|
+
pruned them -- so this is the reclaim step that keeps the store bounded
|
|
312
|
+
(#213 review MEDIUM-2).
|
|
313
|
+
|
|
314
|
+
Deletion goes through ``invalidate_file`` rather than ``invalidate``:
|
|
315
|
+
``iter_entry_files`` also yields files whose names are not legal keys,
|
|
316
|
+
and those deserve reclaiming too.
|
|
317
|
+
|
|
318
|
+
Leftover ``*.tmp`` files are deliberately left alone: a temp name is
|
|
319
|
+
unique per write (#213 review NIT-6), so a leftover may belong to a
|
|
320
|
+
concurrent writer that is about to rename it. ``remove_temp_files`` is
|
|
321
|
+
the explicit sweep for those.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
now: Current epoch time; ``time.time()`` when omitted.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Number of entry files deleted.
|
|
328
|
+
"""
|
|
329
|
+
deleted = 0
|
|
330
|
+
for key, entry in self._store.iter_entry_files():
|
|
331
|
+
if entry is not None and self.fresh(entry, now=now):
|
|
332
|
+
continue
|
|
333
|
+
if self._store.invalidate_file(key):
|
|
334
|
+
deleted += 1
|
|
335
|
+
return deleted
|
|
336
|
+
|
|
337
|
+
def invalidate(self, key: str) -> bool:
|
|
338
|
+
"""Drop the entry under *key*.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
True if an entry was deleted; False if there was nothing to delete
|
|
342
|
+
or the deletion failed.
|
|
343
|
+
"""
|
|
344
|
+
try:
|
|
345
|
+
return self._store.invalidate(key)
|
|
346
|
+
except OSError:
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
def clear(self) -> int:
|
|
350
|
+
"""Drop every entry in this ledger's store.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Number of files deleted.
|
|
354
|
+
"""
|
|
355
|
+
return self._store.clear()
|