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,546 @@
|
|
|
1
|
+
"""Generic keyed store with atomic writes.
|
|
2
|
+
|
|
3
|
+
Extracted from ``CacheStorage`` in
|
|
4
|
+
:mod:`wl_cli.commands.testcommands.cache_manager` -- the storage mechanics
|
|
5
|
+
(temp file + rename, including the Windows retry loop) -- so they can be reused
|
|
6
|
+
and tested on their own, without the test-cache specifics (default cache root,
|
|
7
|
+
dependency analysis, statistics).
|
|
8
|
+
|
|
9
|
+
Lives in ``utils`` (not ``commands/testcommands``) so every cache adapter can
|
|
10
|
+
reach it without importing the test subsystem (#213, ADR-0011). The ledger in
|
|
11
|
+
:mod:`wl_cli.utils.cache_ledger` is the production path to it; the format
|
|
12
|
+
adapter goes through the ledger, never through this module.
|
|
13
|
+
|
|
14
|
+
The interface is deliberately narrow:
|
|
15
|
+
|
|
16
|
+
- :meth:`KeyedStore.put` -- write a JSON dict under a key, atomically
|
|
17
|
+
- :meth:`KeyedStore.get` -- read it back, or ``None`` on miss
|
|
18
|
+
- :meth:`KeyedStore.invalidate` -- drop it
|
|
19
|
+
- :meth:`KeyedStore.invalidate_file` -- drop an entry by its file-name-derived
|
|
20
|
+
key, including one whose name is not a legal key
|
|
21
|
+
- :meth:`KeyedStore.iter_entries` -- enumerate the readable entries
|
|
22
|
+
- :meth:`KeyedStore.iter_entry_files` -- enumerate every entry file, unusable
|
|
23
|
+
ones included
|
|
24
|
+
- :meth:`KeyedStore.clear` -- drop every entry
|
|
25
|
+
- :meth:`KeyedStore.remove_temp_files` -- drop leftovers from interrupted writes
|
|
26
|
+
- :meth:`KeyedStore.total_size_bytes` -- the store's on-disk footprint
|
|
27
|
+
|
|
28
|
+
The store does not judge freshness. Entries carry their own ``timestamp`` field,
|
|
29
|
+
and :func:`is_fresh` -- defined here, next to the store -- is the single
|
|
30
|
+
freshness judgement in the cache stack: ``CacheLedger`` (in
|
|
31
|
+
:mod:`wl_cli.utils.cache_ledger`) owns the clock and passes the TTL its adapter
|
|
32
|
+
chose (24 hours for test results, 7 days for coverage and format). Nothing
|
|
33
|
+
expires by file mtime.
|
|
34
|
+
|
|
35
|
+
Keys become file names, so an illegal key is rejected rather than sanitized --
|
|
36
|
+
sanitizing would alias distinct keys onto one file. A key is illegal when it is
|
|
37
|
+
not a ``str``, is the empty string, contains a path separator (``/``, ``\\``)
|
|
38
|
+
or a C0 control character (including NUL) or DEL, is one of the directory
|
|
39
|
+
references ``.`` and ``..``, or holds a character that cannot be encoded as a
|
|
40
|
+
file name (a lone surrogate, which no file-system encoding accepts). These are
|
|
41
|
+
pure string checks: POSIX rejects a NUL in a path and Windows rejects several
|
|
42
|
+
other control characters, so rejecting C0 and DEL on every platform keeps that
|
|
43
|
+
part of the naming rule identical across platforms. Rejection never raises out
|
|
44
|
+
of the public interface: like every other unusable state, it degrades to the
|
|
45
|
+
documented miss/failure return -- :meth:`KeyedStore.put` and
|
|
46
|
+
:meth:`KeyedStore.invalidate` report ``False``, :meth:`KeyedStore.get` reports
|
|
47
|
+
``None``. Only the private :meth:`KeyedStore._entry_path` raises, and the
|
|
48
|
+
public methods go through :meth:`KeyedStore._safe_entry_path` to turn that into
|
|
49
|
+
the documented return.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
import time
|
|
55
|
+
import uuid
|
|
56
|
+
from collections.abc import Iterator
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
from typing import Any
|
|
59
|
+
|
|
60
|
+
from .platform_adapter import PlatformAdapter
|
|
61
|
+
|
|
62
|
+
_ENTRY_SUFFIX = ".json"
|
|
63
|
+
_TEMP_SUFFIX = ".tmp"
|
|
64
|
+
|
|
65
|
+
# Keys become file names, so anything that could escape the store directory
|
|
66
|
+
# (or land outside the entry-file naming scheme) is rejected rather than
|
|
67
|
+
# silently sanitized -- silent sanitizing would alias distinct keys together.
|
|
68
|
+
_KEY_PATH_SEPARATORS = ("/", "\\")
|
|
69
|
+
_KEY_DIRECTORY_REFERENCES = (".", "..")
|
|
70
|
+
|
|
71
|
+
# C0 control characters (including NUL) and DEL are rejected on every platform:
|
|
72
|
+
# a NUL in a path is rejected by POSIX and Windows alike, and Windows rejects
|
|
73
|
+
# several of the others, so applying the same rule everywhere keeps that part of
|
|
74
|
+
# the naming rule platform-independent. (Windows-only restrictions such as
|
|
75
|
+
# reserved device names are deliberately out of scope.)
|
|
76
|
+
_FIRST_PRINTABLE_CODEPOINT = 0x20
|
|
77
|
+
_DELETE_CODEPOINT = 0x7F
|
|
78
|
+
|
|
79
|
+
_WINDOWS_RENAME_ATTEMPTS = 5
|
|
80
|
+
_WINDOWS_RENAME_DELAY_SECONDS = 0.1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_fresh(entry: Any, max_age_seconds: float, *, now: float | None = None) -> bool:
|
|
84
|
+
"""Judge whether an entry's payload timestamp is within ``max_age_seconds``.
|
|
85
|
+
|
|
86
|
+
This is the single freshness judgement in the cache stack. Every adapter
|
|
87
|
+
reaches it through ``CacheLedger``, which passes the age limit the adapter
|
|
88
|
+
owns: 24 hours for the test-result cache, 7 days for coverage and format.
|
|
89
|
+
|
|
90
|
+
Only the entry's own ``timestamp`` field is consulted -- never the file
|
|
91
|
+
mtime. An entry that is not a dict, carries no timestamp, or carries a
|
|
92
|
+
non-numeric one counts as stale, so a purge removes it.
|
|
93
|
+
|
|
94
|
+
A timestamp in the future (clock skew between writers, or a value stored in
|
|
95
|
+
milliseconds rather than seconds) yields a negative age and therefore
|
|
96
|
+
counts as fresh. That is deliberate and pinned by tests: the predicate has
|
|
97
|
+
no upper bound, so such an entry stays cached until it is invalidated or a
|
|
98
|
+
purge is asked to drop it by other means.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
entry: The stored entry, as returned by :meth:`KeyedStore.get`.
|
|
102
|
+
max_age_seconds: Maximum age of a fresh entry, in seconds.
|
|
103
|
+
now: Current epoch time; ``time.time()`` when omitted. Injectable so
|
|
104
|
+
the boundary can be tested exactly.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
True when ``now - entry["timestamp"] < max_age_seconds``; False
|
|
108
|
+
otherwise, including for a malformed or timestamp-less entry.
|
|
109
|
+
"""
|
|
110
|
+
if not isinstance(entry, dict):
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
timestamp = entry.get("timestamp")
|
|
114
|
+
if not isinstance(timestamp, (int, float)):
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
current_time = time.time() if now is None else now
|
|
118
|
+
return current_time - timestamp < max_age_seconds
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class KeyedStore:
|
|
122
|
+
"""A directory of JSON entries addressed by key, written atomically.
|
|
123
|
+
|
|
124
|
+
Responsibilities:
|
|
125
|
+
- Create the store directory if it doesn't exist
|
|
126
|
+
- Read an entry (``None`` on miss)
|
|
127
|
+
- Write an entry atomically, with a retry loop on Windows file locking
|
|
128
|
+
- Delete an entry
|
|
129
|
+
- Delete an entry by its file-name-derived key, even when that name is not
|
|
130
|
+
a legal key (housekeeping over files this store would never have written)
|
|
131
|
+
- Enumerate the readable entries, and report their count and on-disk size
|
|
132
|
+
- Reject an illegal key by degrading to the documented miss/failure return,
|
|
133
|
+
never by raising (see the module docstring)
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(self, store_dir: Path) -> None:
|
|
137
|
+
"""Initialize the store.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
store_dir: Directory holding the entry files. Created lazily on
|
|
141
|
+
first write, not here.
|
|
142
|
+
"""
|
|
143
|
+
self._store_dir = store_dir
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def store_dir(self) -> Path:
|
|
147
|
+
"""Get the directory holding the entry files."""
|
|
148
|
+
return self._store_dir
|
|
149
|
+
|
|
150
|
+
def ensure_store_dir_exists(self) -> None:
|
|
151
|
+
"""Ensure the store directory exists, create it if not."""
|
|
152
|
+
if not self._store_dir.exists():
|
|
153
|
+
self._store_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
|
|
155
|
+
def put(self, key: str, value: dict[str, Any]) -> bool:
|
|
156
|
+
"""Write an entry atomically.
|
|
157
|
+
|
|
158
|
+
Writes to a temporary file first and renames it into place, so a reader
|
|
159
|
+
never observes a half-written entry. Includes a retry loop for Windows
|
|
160
|
+
rename failures caused by file locking. The temporary name is unique
|
|
161
|
+
per write (see :meth:`_temp_path`), so two processes writing the same
|
|
162
|
+
key do not stage into -- and steal -- one another's file.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
key: Entry key.
|
|
166
|
+
value: JSON-serializable dict to store.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
True if the write succeeded; False if the key is illegal (see the
|
|
170
|
+
module docstring) or the write failed, in which case the temporary
|
|
171
|
+
file has been cleaned up and no half-written entry is visible. The
|
|
172
|
+
previous entry file is removed just before the rename, so a write
|
|
173
|
+
that fails at the rename stage can leave the entry absent rather
|
|
174
|
+
than stale.
|
|
175
|
+
"""
|
|
176
|
+
entry_file = self._safe_entry_path(key)
|
|
177
|
+
if entry_file is None:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
self.ensure_store_dir_exists()
|
|
181
|
+
temp_file = self._temp_path(entry_file)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
# Write to a temporary file first for atomicity
|
|
185
|
+
with open(temp_file, "w", encoding="utf-8") as f:
|
|
186
|
+
json.dump(value, f, indent=2, ensure_ascii=False)
|
|
187
|
+
f.write("\n")
|
|
188
|
+
except OSError:
|
|
189
|
+
self._discard(temp_file)
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
return self._rename_into_place(temp_file, entry_file)
|
|
194
|
+
except OSError:
|
|
195
|
+
self._discard(temp_file)
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
def get(self, key: str) -> dict[str, Any] | None:
|
|
199
|
+
"""Read an entry.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
key: Entry key.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The stored dict, or None if the key is illegal (see the module
|
|
206
|
+
docstring), or the entry is missing, unreadable, or holds something
|
|
207
|
+
other than a JSON object. Freshness is the caller's business --
|
|
208
|
+
see :func:`is_fresh`.
|
|
209
|
+
"""
|
|
210
|
+
entry_file = self._safe_entry_path(key)
|
|
211
|
+
if entry_file is None or not entry_file.exists():
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
with open(entry_file, encoding="utf-8") as f:
|
|
216
|
+
data = json.load(f)
|
|
217
|
+
except (OSError, ValueError, RecursionError):
|
|
218
|
+
# ValueError covers both malformed JSON and bytes that are not
|
|
219
|
+
# valid UTF-8; RecursionError covers a pathologically deep document.
|
|
220
|
+
# A hand-written or corrupted entry must degrade to a miss, never
|
|
221
|
+
# escape as an exception.
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
# Only ``put`` writes dicts, but an entry can also be hand-written or
|
|
225
|
+
# left by another tool; anything that is not a JSON object is a miss
|
|
226
|
+
# rather than a value that violates the declared return type.
|
|
227
|
+
return data if isinstance(data, dict) else None
|
|
228
|
+
|
|
229
|
+
def invalidate(self, key: str) -> bool:
|
|
230
|
+
"""Delete an entry.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
key: Entry key.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
True if an entry was deleted, False if the key is illegal (see the
|
|
237
|
+
module docstring), there was nothing to delete, or the deletion
|
|
238
|
+
failed.
|
|
239
|
+
"""
|
|
240
|
+
entry_file = self._safe_entry_path(key)
|
|
241
|
+
if entry_file is None or not entry_file.exists():
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
entry_file.unlink()
|
|
246
|
+
return True
|
|
247
|
+
except OSError:
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
def invalidate_file(self, key: object) -> bool:
|
|
251
|
+
"""Delete an entry by the key encoded in its file name.
|
|
252
|
+
|
|
253
|
+
:meth:`iter_entry_files` yields every ``*.json`` file's stem, including
|
|
254
|
+
names that are not legal keys -- an entry file literally named
|
|
255
|
+
``.json`` yields the empty key, and ``a<TAB>b.json`` yields a key
|
|
256
|
+
rejected for its control character. :meth:`invalidate` refuses those
|
|
257
|
+
keys by design (it is the key-addressed interface), so maintenance
|
|
258
|
+
could count such a file in its statistics but never reclaim it
|
|
259
|
+
(#172 wave 9). This is the housekeeping entry point: it accepts any
|
|
260
|
+
name that cannot escape the store directory, so every file
|
|
261
|
+
:meth:`iter_entry_files` reports is deletable.
|
|
262
|
+
|
|
263
|
+
The name is *not* held to the key-naming rule: a file whose name this
|
|
264
|
+
store would never have written still deserves deletion. Only path
|
|
265
|
+
traversal is refused (a separator, or NUL) -- the store appends the
|
|
266
|
+
``.json`` suffix itself, so ``..`` cannot escape either.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
key: Entry-file stem (a ``str``), as yielded by
|
|
270
|
+
:meth:`iter_entry_files`. Typed ``object`` because a caller
|
|
271
|
+
that breaks the declared type must still degrade, not crash.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
True if a file was deleted, False if the name is unusable, there
|
|
275
|
+
was nothing to delete, or the deletion failed.
|
|
276
|
+
"""
|
|
277
|
+
if not isinstance(key, str):
|
|
278
|
+
return False
|
|
279
|
+
if "\x00" in key or any(separator in key for separator in _KEY_PATH_SEPARATORS):
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
entry_file = self._store_dir / f"{key}{_ENTRY_SUFFIX}"
|
|
283
|
+
try:
|
|
284
|
+
entry_file.unlink()
|
|
285
|
+
return True
|
|
286
|
+
except (OSError, UnicodeError):
|
|
287
|
+
# Missing, a directory, or an unremovable file: all the same
|
|
288
|
+
# documented failure return, like every other unusable state.
|
|
289
|
+
# ``UnicodeError`` covers a lone surrogate, which reaches the
|
|
290
|
+
# filesystem encoding as a ``UnicodeEncodeError`` at ``unlink`` --
|
|
291
|
+
# a ``str`` that passed the path checks above but cannot name a
|
|
292
|
+
# file must still degrade, not crash (#172 review, MINOR-2).
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
def iter_entry_files(self) -> Iterator[tuple[str, dict[str, Any] | None]]:
|
|
296
|
+
"""Iterate over every entry file, with its payload when readable.
|
|
297
|
+
|
|
298
|
+
Unlike :meth:`iter_entries`, this does not hide unusable entries: a file
|
|
299
|
+
that cannot be read (unreadable, bytes that are not valid UTF-8, a
|
|
300
|
+
pathologically deep document) or that holds something other than a JSON
|
|
301
|
+
object is yielded with ``None`` as its payload. That lets a purge see --
|
|
302
|
+
and delete -- the same set of files the statistics count and measure.
|
|
303
|
+
|
|
304
|
+
The key is the entry file's name, so a file whose name is not a legal
|
|
305
|
+
key (see the module docstring) is still yielded but cannot be addressed
|
|
306
|
+
by :meth:`invalidate`.
|
|
307
|
+
|
|
308
|
+
Yields:
|
|
309
|
+
``(key, entry-or-None)`` pairs in file-name order.
|
|
310
|
+
"""
|
|
311
|
+
if not self._store_dir.exists():
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
for entry_file in sorted(self._store_dir.glob(f"*{_ENTRY_SUFFIX}")):
|
|
315
|
+
if not entry_file.is_file():
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
with open(entry_file, encoding="utf-8") as f:
|
|
320
|
+
data = json.load(f)
|
|
321
|
+
except (OSError, ValueError, RecursionError):
|
|
322
|
+
data = None
|
|
323
|
+
|
|
324
|
+
key = entry_file.name[: -len(_ENTRY_SUFFIX)]
|
|
325
|
+
yield key, data if isinstance(data, dict) else None
|
|
326
|
+
|
|
327
|
+
def iter_entries(self) -> Iterator[tuple[str, dict[str, Any]]]:
|
|
328
|
+
"""Iterate over the readable entries in the store.
|
|
329
|
+
|
|
330
|
+
Entries are addressed by the key encoded in their file name. Files that
|
|
331
|
+
are not entry files (leftover ``.tmp`` files), entries that cannot be
|
|
332
|
+
read, and entries holding something other than a JSON object are
|
|
333
|
+
skipped -- they are not values this store could ever have written. Use
|
|
334
|
+
:meth:`iter_entry_files` when the unusable ones matter (purging).
|
|
335
|
+
|
|
336
|
+
Yields:
|
|
337
|
+
``(key, entry)`` pairs in file-name order.
|
|
338
|
+
"""
|
|
339
|
+
for key, entry in self.iter_entry_files():
|
|
340
|
+
if entry is not None:
|
|
341
|
+
yield key, entry
|
|
342
|
+
|
|
343
|
+
def remove_temp_files(self) -> int:
|
|
344
|
+
"""Delete leftover temporary files from interrupted writes.
|
|
345
|
+
|
|
346
|
+
A write stages its entry in ``<key>.<pid>.<random>.tmp`` and renames it
|
|
347
|
+
into place; a process killed between those steps leaves the temporary
|
|
348
|
+
file behind.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Number of temporary files deleted.
|
|
352
|
+
"""
|
|
353
|
+
if not self._store_dir.exists():
|
|
354
|
+
return 0
|
|
355
|
+
|
|
356
|
+
deleted = 0
|
|
357
|
+
for temp_file in self._store_dir.glob(f"*{_TEMP_SUFFIX}"):
|
|
358
|
+
try:
|
|
359
|
+
if temp_file.is_file():
|
|
360
|
+
temp_file.unlink()
|
|
361
|
+
deleted += 1
|
|
362
|
+
except OSError:
|
|
363
|
+
continue
|
|
364
|
+
return deleted
|
|
365
|
+
|
|
366
|
+
def clear(self) -> int:
|
|
367
|
+
"""Delete every file in the store directory.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Number of files deleted. ``0`` when the store directory does not
|
|
371
|
+
exist or nothing could be removed.
|
|
372
|
+
"""
|
|
373
|
+
if not self._store_dir.exists():
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
deleted = 0
|
|
377
|
+
for item in self._store_dir.glob("*"):
|
|
378
|
+
try:
|
|
379
|
+
if item.is_file():
|
|
380
|
+
item.unlink()
|
|
381
|
+
deleted += 1
|
|
382
|
+
except OSError:
|
|
383
|
+
continue
|
|
384
|
+
return deleted
|
|
385
|
+
|
|
386
|
+
def total_size_bytes(self) -> int:
|
|
387
|
+
"""Get the total size of the entry files in bytes.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Total size in bytes; ``0`` when the store directory does not exist.
|
|
391
|
+
"""
|
|
392
|
+
if not self._store_dir.exists():
|
|
393
|
+
return 0
|
|
394
|
+
|
|
395
|
+
total = 0
|
|
396
|
+
for entry_file in self._store_dir.glob(f"*{_ENTRY_SUFFIX}"):
|
|
397
|
+
if entry_file.is_file():
|
|
398
|
+
try:
|
|
399
|
+
total += entry_file.stat().st_size
|
|
400
|
+
except OSError:
|
|
401
|
+
continue
|
|
402
|
+
return total
|
|
403
|
+
|
|
404
|
+
def _safe_entry_path(self, key: object) -> Path | None:
|
|
405
|
+
"""Get the entry file path for a key, or ``None`` when the key is illegal.
|
|
406
|
+
|
|
407
|
+
The public methods go through here so that an illegal key degrades to
|
|
408
|
+
their documented miss/failure return instead of raising -- see the
|
|
409
|
+
module docstring. The key is typed ``object`` because a caller that
|
|
410
|
+
breaks the declared ``str`` type must still degrade, not crash.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
key: Entry key.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
Full Path to the entry file, or ``None`` if the key is illegal.
|
|
417
|
+
"""
|
|
418
|
+
try:
|
|
419
|
+
return self._entry_path(key)
|
|
420
|
+
except ValueError:
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
def _entry_path(self, key: object) -> Path:
|
|
424
|
+
"""Get the entry file path for a key.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
key: Entry key, which must be a ``str``, must not be empty, must not
|
|
428
|
+
contain a path separator or a control character (C0 or DEL),
|
|
429
|
+
must not be a directory reference, and must be encodable as a
|
|
430
|
+
file name.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
Full Path to the entry file.
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
ValueError: If the key is not a ``str``, is empty, contains a path
|
|
437
|
+
separator or a control character, is a directory reference, or
|
|
438
|
+
cannot be encoded as a file name (a lone surrogate).
|
|
439
|
+
"""
|
|
440
|
+
if not isinstance(key, str):
|
|
441
|
+
raise ValueError(f"Store key must be a str, not {type(key).__name__}")
|
|
442
|
+
if not key:
|
|
443
|
+
raise ValueError("Store key must not be empty")
|
|
444
|
+
if any(separator in key for separator in _KEY_PATH_SEPARATORS):
|
|
445
|
+
raise ValueError(f"Store key must not contain a path separator: {key!r}")
|
|
446
|
+
if key in _KEY_DIRECTORY_REFERENCES:
|
|
447
|
+
raise ValueError(f"Store key must not be a directory reference: {key!r}")
|
|
448
|
+
if any(
|
|
449
|
+
ord(character) < _FIRST_PRINTABLE_CODEPOINT
|
|
450
|
+
or ord(character) == _DELETE_CODEPOINT
|
|
451
|
+
for character in key
|
|
452
|
+
):
|
|
453
|
+
raise ValueError(f"Store key must not contain control characters: {key!r}")
|
|
454
|
+
# A lone surrogate cannot be encoded by any file-system encoding (POSIX
|
|
455
|
+
# paths are UTF-8 bytes, Windows paths are UTF-16), so it can never name
|
|
456
|
+
# a file; reject it here rather than let the file-system layer raise
|
|
457
|
+
# ``UnicodeEncodeError`` -- a ``ValueError`` that would slip past the
|
|
458
|
+
# ``except OSError`` guards in ``put``.
|
|
459
|
+
try:
|
|
460
|
+
key.encode("utf-8")
|
|
461
|
+
except UnicodeEncodeError:
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"Store key must be encodable as a file name: {key!r}"
|
|
464
|
+
) from None
|
|
465
|
+
|
|
466
|
+
return self._store_dir / f"{key}{_ENTRY_SUFFIX}"
|
|
467
|
+
|
|
468
|
+
@staticmethod
|
|
469
|
+
def _temp_path(entry_file: Path) -> Path:
|
|
470
|
+
"""A temporary path next to *entry_file*, unique to this write.
|
|
471
|
+
|
|
472
|
+
Staging under the fixed ``<key>.tmp`` made two writers of one key share
|
|
473
|
+
a file: whichever renamed first took it away, and the other's rename
|
|
474
|
+
failed with ``ENOENT`` -- reported as a cache write failure even though
|
|
475
|
+
the entry was fine (#213 review NIT-6). The process id and a random
|
|
476
|
+
suffix keep concurrent writers apart; ``_TEMP_SUFFIX`` stays last so
|
|
477
|
+
:meth:`remove_temp_files` still finds every leftover.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
entry_file: The entry file this write is staging.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
Path of the temporary file to write.
|
|
484
|
+
"""
|
|
485
|
+
return entry_file.with_name(
|
|
486
|
+
f"{entry_file.stem}.{os.getpid()}.{uuid.uuid4().hex[:8]}{_TEMP_SUFFIX}"
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
@staticmethod
|
|
490
|
+
def _is_windows() -> bool:
|
|
491
|
+
"""Report whether the rename needs the Windows file-locking retry loop.
|
|
492
|
+
|
|
493
|
+
Delegates to :class:`PlatformAdapter` so platform detection lives on a
|
|
494
|
+
single seam rather than as a bare ``sys.platform`` check here.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
True on Windows, as reported by :meth:`PlatformAdapter.is_windows`
|
|
498
|
+
-- platform detection is never read from ``sys.platform`` here
|
|
499
|
+
(review !159 minor).
|
|
500
|
+
"""
|
|
501
|
+
return PlatformAdapter.is_windows()
|
|
502
|
+
|
|
503
|
+
def _rename_into_place(self, temp_file: Path, entry_file: Path) -> bool:
|
|
504
|
+
"""Move the temporary file onto the entry file.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
temp_file: Fully written temporary file.
|
|
508
|
+
entry_file: Destination entry file.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
True if the rename succeeded, False if every Windows retry failed.
|
|
512
|
+
"""
|
|
513
|
+
if self._is_windows():
|
|
514
|
+
# On Windows, rename might fail due to file locking.
|
|
515
|
+
# Retry a few times with short delays.
|
|
516
|
+
for attempt in range(_WINDOWS_RENAME_ATTEMPTS):
|
|
517
|
+
try:
|
|
518
|
+
if entry_file.exists():
|
|
519
|
+
entry_file.unlink()
|
|
520
|
+
temp_file.rename(entry_file)
|
|
521
|
+
return True
|
|
522
|
+
except OSError:
|
|
523
|
+
if attempt < _WINDOWS_RENAME_ATTEMPTS - 1:
|
|
524
|
+
time.sleep(_WINDOWS_RENAME_DELAY_SECONDS)
|
|
525
|
+
|
|
526
|
+
# All retries failed
|
|
527
|
+
self._discard(temp_file)
|
|
528
|
+
return False
|
|
529
|
+
|
|
530
|
+
# On Unix, direct rename is atomic. A concurrent writer of the same key
|
|
531
|
+
# may have removed the entry between the existence check and the
|
|
532
|
+
# unlink; that is not a failure -- the rename publishes our copy.
|
|
533
|
+
try:
|
|
534
|
+
entry_file.unlink()
|
|
535
|
+
except FileNotFoundError:
|
|
536
|
+
pass
|
|
537
|
+
temp_file.rename(entry_file)
|
|
538
|
+
return True
|
|
539
|
+
|
|
540
|
+
def _discard(self, temp_file: Path) -> None:
|
|
541
|
+
"""Remove a temporary file, ignoring failures."""
|
|
542
|
+
try:
|
|
543
|
+
if temp_file.exists():
|
|
544
|
+
temp_file.unlink()
|
|
545
|
+
except OSError:
|
|
546
|
+
pass
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Configuration utilities for WL Commands."""
|
|
2
|
+
|
|
3
|
+
from .core import (
|
|
4
|
+
ConfigContext,
|
|
5
|
+
ConfigManager,
|
|
6
|
+
ConfigProvider,
|
|
7
|
+
EnvExporter,
|
|
8
|
+
get_config,
|
|
9
|
+
get_config_manager,
|
|
10
|
+
get_message,
|
|
11
|
+
reload_config,
|
|
12
|
+
reset_config_manager,
|
|
13
|
+
set_config,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ConfigContext",
|
|
18
|
+
"ConfigManager",
|
|
19
|
+
"ConfigProvider",
|
|
20
|
+
"EnvExporter",
|
|
21
|
+
"get_config",
|
|
22
|
+
"get_config_manager",
|
|
23
|
+
"reload_config",
|
|
24
|
+
"reset_config_manager",
|
|
25
|
+
"set_config",
|
|
26
|
+
"get_message",
|
|
27
|
+
]
|