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,862 @@
|
|
|
1
|
+
"""Configuration management utilities for WL Commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..logging import log_error_pair
|
|
14
|
+
from ..platform_adapter import PlatformAdapter
|
|
15
|
+
from ..root import RootKind
|
|
16
|
+
from ..root_fallback import resolve_root_or_start
|
|
17
|
+
from .config_loader import ConfigLoader
|
|
18
|
+
from .defaults import DEFAULT_LOG_FILE, RUNTIME_DERIVED_KEYS
|
|
19
|
+
from .env_exporter import EnvExporter
|
|
20
|
+
from .validation import validate_config
|
|
21
|
+
|
|
22
|
+
# Directory and file paths
|
|
23
|
+
CONFIG_FILE_NAME = "config.json"
|
|
24
|
+
LOG_DIRECTORY = "log"
|
|
25
|
+
LOG_FILE_NAME = "wl_action.log"
|
|
26
|
+
|
|
27
|
+
# Environment variables
|
|
28
|
+
WL_CONFIG_PATH_ENV = "WL_CONFIG_PATH"
|
|
29
|
+
WL_ENVIRONMENT_ENV = "WL_ENVIRONMENT"
|
|
30
|
+
|
|
31
|
+
# Environment names
|
|
32
|
+
ENVIRONMENT_DEVELOPMENT = "development"
|
|
33
|
+
ENVIRONMENT_PRODUCTION = "production"
|
|
34
|
+
ENVIRONMENT_TESTING = "testing"
|
|
35
|
+
|
|
36
|
+
# CI environment variables
|
|
37
|
+
CI_ENV_VARS = ["CI", "GITHUB_ACTIONS", "JENKINS_HOME"]
|
|
38
|
+
|
|
39
|
+
# Test environment variable
|
|
40
|
+
TEST_ENV_VAR = "PYTEST_CURRENT_TEST"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConfigPathResolver:
|
|
44
|
+
"""Resolves configuration file paths with cross-platform support."""
|
|
45
|
+
|
|
46
|
+
def get_default_config_path(self) -> str:
|
|
47
|
+
"""Get default configuration file path.
|
|
48
|
+
|
|
49
|
+
First checks environment variable WL_CONFIG_PATH_ENV, then resolves the
|
|
50
|
+
**deploy root** through the root module (the git work-tree root inside a
|
|
51
|
+
repository, else the project root -- see
|
|
52
|
+
:class:`~wl_cli.utils.root.RootKind`), falling back to the current
|
|
53
|
+
directory. Anchoring the config at the deploy root keeps it in the *same*
|
|
54
|
+
``.wl`` as the hooks leg (#95 / #96); resolving it at the project root
|
|
55
|
+
split one ``.wl`` into two, so a workspace member repo silently stopped
|
|
56
|
+
reading the workspace config.
|
|
57
|
+
|
|
58
|
+
``resolve`` answers ``None`` when there is neither a git work tree nor a
|
|
59
|
+
project marker; the substitution that used to hide inside the deploy
|
|
60
|
+
resolver (it returned the starting directory) is supplied by the single
|
|
61
|
+
CLI-top-level fallback policy,
|
|
62
|
+
:func:`~wl_cli.utils.root_fallback.resolve_root_or_start` (#126), per
|
|
63
|
+
``docs/adr/0006-root-resolution-layering.md``.
|
|
64
|
+
"""
|
|
65
|
+
config_path = os.environ.get(WL_CONFIG_PATH_ENV)
|
|
66
|
+
if config_path:
|
|
67
|
+
return config_path
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
start = Path.cwd()
|
|
71
|
+
except OSError:
|
|
72
|
+
# A deleted working directory (#95): the chain this replaced
|
|
73
|
+
# (deploy resolver -> project walk) degraded to a relative ``.wl``
|
|
74
|
+
# rather than raising, so keep that result instead of letting
|
|
75
|
+
# ``Path.cwd()`` escape.
|
|
76
|
+
start = Path(".")
|
|
77
|
+
|
|
78
|
+
# ADR 0006: 回退是调用方策略;唯一实现在 utils/root_fallback.py(#126).
|
|
79
|
+
# This is the one landing point that does NOT canonicalise ``start``: it
|
|
80
|
+
# passes the un-resolved ``Path.cwd()``, so the fallback answer keeps the
|
|
81
|
+
# spelling the old no-argument resolver returned.
|
|
82
|
+
deploy_root = resolve_root_or_start(start, RootKind.DEPLOY)
|
|
83
|
+
|
|
84
|
+
return str(deploy_root.wl_dir() / CONFIG_FILE_NAME)
|
|
85
|
+
|
|
86
|
+
def get_user_home_dir(self) -> str:
|
|
87
|
+
"""Get user home directory in a cross-platform way.
|
|
88
|
+
|
|
89
|
+
Mirrors the platform order it has always had: ``Path.home()`` first, then
|
|
90
|
+
``PATH``-independent ``~`` expansion with the Windows ``USERPROFILE`` /
|
|
91
|
+
``HOMEDRIVE`` + ``HOMEPATH`` handling, and finally the caller-side
|
|
92
|
+
fallback to the current directory. That last step is a *fallback*, not
|
|
93
|
+
part of "which home does this platform have". ADR 0006 keeps it written
|
|
94
|
+
out here rather than inside a resolver, and #126 deliberately does **not**
|
|
95
|
+
fold it into the single root-fallback policy: the question here is the
|
|
96
|
+
user's **home**, a different domain from the project **root**, so merging
|
|
97
|
+
the two would conflate them (see ADR 0006 修订 1).
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
return str(Path.home())
|
|
101
|
+
except Exception:
|
|
102
|
+
# Fallback for when Path.home() fails
|
|
103
|
+
home_dir = os.path.expanduser("~")
|
|
104
|
+
|
|
105
|
+
# Windows-specific additional handling
|
|
106
|
+
if PlatformAdapter.is_windows() and (not home_dir or home_dir == "~"):
|
|
107
|
+
home_dir = os.environ.get("USERPROFILE", "")
|
|
108
|
+
if not home_dir:
|
|
109
|
+
home_drive = os.environ.get("HOMEDRIVE", "")
|
|
110
|
+
home_path_env = os.environ.get("HOMEPATH", "")
|
|
111
|
+
if home_drive and home_path_env:
|
|
112
|
+
home_dir = os.path.join(home_drive, home_path_env)
|
|
113
|
+
|
|
114
|
+
# Final fallback
|
|
115
|
+
if not home_dir or home_dir == "~":
|
|
116
|
+
# ADR 0006: 这是 home 解析兜底(C 类),非「根回退」,按
|
|
117
|
+
# ADR 修订 1 的裁决保留、不收编(#126)。
|
|
118
|
+
home_dir = os.getcwd()
|
|
119
|
+
|
|
120
|
+
return home_dir
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ConfigMerger:
|
|
124
|
+
"""Recursively merge configuration dictionaries with proper precedence."""
|
|
125
|
+
|
|
126
|
+
def merge(self, base: dict[str, Any], *overlays: dict[str, Any]) -> dict[str, Any]:
|
|
127
|
+
"""Recursively merge multiple configuration dictionaries.
|
|
128
|
+
|
|
129
|
+
Later overlays override earlier ones. For nested dictionaries,
|
|
130
|
+
merging is done recursively rather than replacing the whole dict.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
base: Base configuration
|
|
134
|
+
*overlays: Overlay configurations to merge on top
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
New merged configuration dictionary (base is not modified)
|
|
138
|
+
"""
|
|
139
|
+
result = copy.deepcopy(base)
|
|
140
|
+
for overlay in overlays:
|
|
141
|
+
self._recursive_merge(result, overlay)
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
def _recursive_merge(self, target: dict[str, Any], source: dict[str, Any]) -> None:
|
|
145
|
+
"""Recursively merge source into target."""
|
|
146
|
+
for key, value in source.items():
|
|
147
|
+
if (
|
|
148
|
+
key in target
|
|
149
|
+
and isinstance(target[key], dict)
|
|
150
|
+
and isinstance(value, dict)
|
|
151
|
+
):
|
|
152
|
+
self._recursive_merge(target[key], value)
|
|
153
|
+
else:
|
|
154
|
+
target[key] = value
|
|
155
|
+
|
|
156
|
+
def merge_changed_keys(
|
|
157
|
+
self,
|
|
158
|
+
target: dict[str, Any],
|
|
159
|
+
source: dict[str, Any],
|
|
160
|
+
original: dict[str, Any],
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Merge only changed keys from source into target.
|
|
163
|
+
|
|
164
|
+
Only adds/updates keys that are not in original or have changed value.
|
|
165
|
+
Used for saving configuration to preserve original user settings.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
target: Target dictionary to update
|
|
169
|
+
source: Source dictionary with current values
|
|
170
|
+
original: Original dictionary to compare against
|
|
171
|
+
"""
|
|
172
|
+
for key, value in source.items():
|
|
173
|
+
if key not in original:
|
|
174
|
+
# Key doesn't exist in original, add it
|
|
175
|
+
target[key] = value
|
|
176
|
+
elif isinstance(value, dict) and isinstance(original[key], dict):
|
|
177
|
+
# Both are dicts, recursively check nested keys
|
|
178
|
+
if key not in target:
|
|
179
|
+
target[key] = {}
|
|
180
|
+
self.merge_changed_keys(target[key], value, original[key])
|
|
181
|
+
elif value != original.get(key):
|
|
182
|
+
# Value has changed, update it
|
|
183
|
+
target[key] = value
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ConfigSaver:
|
|
187
|
+
"""Save configuration to file with smart change tracking.
|
|
188
|
+
|
|
189
|
+
Only saves changed keys compared to the original project configuration,
|
|
190
|
+
preserving default values that aren't saved to disk.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(self, config_file_path: str) -> None:
|
|
194
|
+
"""Initialize configuration saver.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
config_file_path: Path to the configuration file to save
|
|
198
|
+
"""
|
|
199
|
+
self.config_file_path = config_file_path
|
|
200
|
+
self.merger = ConfigMerger()
|
|
201
|
+
|
|
202
|
+
def save(
|
|
203
|
+
self,
|
|
204
|
+
full_config: dict[str, Any],
|
|
205
|
+
original_project_config: dict[str, Any],
|
|
206
|
+
*,
|
|
207
|
+
force: bool = False,
|
|
208
|
+
) -> bool:
|
|
209
|
+
"""Save configuration changes to file.
|
|
210
|
+
|
|
211
|
+
Only saves keys that have changed from the original project config.
|
|
212
|
+
Creates parent directory if it doesn't exist.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
full_config: Complete merged configuration from all sources
|
|
216
|
+
original_project_config: Original project configuration as loaded from disk
|
|
217
|
+
force: Write even in a test environment. The test-environment skip
|
|
218
|
+
below is a default for *incidental* saves; an explicit
|
|
219
|
+
"materialise this file" call must not inherit it, or the caller
|
|
220
|
+
gets ``True`` for a file that was never written (#202).
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
True when the file was written (or the write was intentionally
|
|
224
|
+
skipped in a test environment without ``force``); False when the
|
|
225
|
+
write failed. The error is logged -- callers such as ``wl config
|
|
226
|
+
set`` and the .wl self-sync must be able to report the failure
|
|
227
|
+
instead of claiming success (#177).
|
|
228
|
+
"""
|
|
229
|
+
# Don't save to disk in test environment -- unless the caller is
|
|
230
|
+
# explicitly materialising a file (see ``force``).
|
|
231
|
+
if not force and os.environ.get(TEST_ENV_VAR):
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
config_path = Path(self.config_file_path)
|
|
236
|
+
config_dir = config_path.parent
|
|
237
|
+
if config_dir and not config_dir.exists():
|
|
238
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
|
|
240
|
+
# Only save the original project config plus any new keys that were set
|
|
241
|
+
# This prevents default values from being saved to the config file
|
|
242
|
+
if original_project_config:
|
|
243
|
+
# Use deep copy to ensure no mutation of original input
|
|
244
|
+
config_to_save = copy.deepcopy(original_project_config)
|
|
245
|
+
self.merger.merge_changed_keys(
|
|
246
|
+
config_to_save, full_config, original_project_config
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
# If no original project config, just save a deep copy of current config
|
|
250
|
+
config_to_save = copy.deepcopy(full_config)
|
|
251
|
+
|
|
252
|
+
with open(self.config_file_path, "w", encoding="utf-8") as f:
|
|
253
|
+
json.dump(config_to_save, f, indent=2, ensure_ascii=False)
|
|
254
|
+
f.write("\n")
|
|
255
|
+
return True
|
|
256
|
+
except OSError as e:
|
|
257
|
+
# Saving is still best-effort, but no longer silent: the caller gets
|
|
258
|
+
# False and the user gets a bilingual error (#177).
|
|
259
|
+
log_error_pair(
|
|
260
|
+
f"Failed to save configuration to {self.config_file_path}: {e}",
|
|
261
|
+
f"无法将配置保存到 {self.config_file_path}:{e}",
|
|
262
|
+
)
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class ConfigManager:
|
|
267
|
+
"""Manage application configuration with auto-reload support.
|
|
268
|
+
|
|
269
|
+
This is a facade that delegates to smaller, single-responsibility components:
|
|
270
|
+
- ConfigPathResolver: Resolves configuration file paths
|
|
271
|
+
- ConfigLoader: Loads configuration from multiple sources
|
|
272
|
+
- ConfigSaver: Saves configuration changes smartly
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
def __init__(self, config_file: str | None = None) -> None:
|
|
276
|
+
"""
|
|
277
|
+
Initialize configuration manager with support for configuration layers.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
config_file (str, optional): Path to project configuration file.
|
|
281
|
+
"""
|
|
282
|
+
self._path_resolver = ConfigPathResolver()
|
|
283
|
+
self.config_file = config_file or self._path_resolver.get_default_config_path()
|
|
284
|
+
self._loader = ConfigLoader(self.config_file, self._path_resolver)
|
|
285
|
+
self._env_exporter = EnvExporter(self._loader)
|
|
286
|
+
self._config: dict[str, Any] = {}
|
|
287
|
+
self._last_modified_time: float = 0.0
|
|
288
|
+
self._original_project_config: dict[str, Any] = {}
|
|
289
|
+
# Top-level keys this process set explicitly. A runtime-derived key is
|
|
290
|
+
# only written to disk when it is in here (#208).
|
|
291
|
+
self._explicit_keys: set[str] = set()
|
|
292
|
+
self._load_config()
|
|
293
|
+
|
|
294
|
+
def _get_default_config(self) -> dict[str, Any]:
|
|
295
|
+
"""Get default configuration dictionary.
|
|
296
|
+
|
|
297
|
+
This is kept for backward compatibility with tests.
|
|
298
|
+
"""
|
|
299
|
+
from .defaults import get_default_config
|
|
300
|
+
|
|
301
|
+
return get_default_config()
|
|
302
|
+
|
|
303
|
+
def _get_environment(self) -> str:
|
|
304
|
+
"""Get current environment mode.
|
|
305
|
+
|
|
306
|
+
This is kept for backward compatibility with tests.
|
|
307
|
+
"""
|
|
308
|
+
return self._loader.get_environment()
|
|
309
|
+
|
|
310
|
+
def _load_config(self) -> None:
|
|
311
|
+
"""Load configuration from all sources using ConfigLoader."""
|
|
312
|
+
# Load all configuration layers
|
|
313
|
+
merged_config = self._loader.load_all()
|
|
314
|
+
|
|
315
|
+
# Save original project config for later comparison when saving
|
|
316
|
+
project_config = self._loader.load_config_file(self.config_file)
|
|
317
|
+
self._original_project_config = project_config.copy()
|
|
318
|
+
|
|
319
|
+
# Validate configuration
|
|
320
|
+
self._config = validate_config(merged_config)
|
|
321
|
+
|
|
322
|
+
# Loading is a **read** and must never materialise the project config.
|
|
323
|
+
# Writing here meant that merely importing wl_cli (module-level logger →
|
|
324
|
+
# LoggingConfig → get_config) dropped a `.wl/` into whatever directory
|
|
325
|
+
# happened to be the CWD — see the lazy logger in utils/logging.py.
|
|
326
|
+
#
|
|
327
|
+
# Creating the file is an explicit act now: `wl init` calls
|
|
328
|
+
# ensure_project_config(), and `set()` still persists on a write.
|
|
329
|
+
#
|
|
330
|
+
# Gating the write on "does the project dir exist?" was tried and is NOT
|
|
331
|
+
# enough: get_config_manager() is a process-cached singleton, so the
|
|
332
|
+
# decision falls to whichever call happens to run first — and `wl init`
|
|
333
|
+
# logs before it ever reaches generate_configs(), so the manager gets
|
|
334
|
+
# built while `.wl/` does not exist yet and never re-checks.
|
|
335
|
+
self._last_modified_time = time.time()
|
|
336
|
+
|
|
337
|
+
def ensure_project_config(self) -> bool:
|
|
338
|
+
"""Materialise the project config file, writing defaults when absent.
|
|
339
|
+
|
|
340
|
+
The one explicit way to create `.wl/config.json`. `wl init` calls this;
|
|
341
|
+
nothing else should need to, because every other path either only reads
|
|
342
|
+
or writes through `set()`.
|
|
343
|
+
|
|
344
|
+
This is a *create* contract, so it passes ``force`` down: under pytest
|
|
345
|
+
the incidental-save guard would otherwise answer "written" while leaving
|
|
346
|
+
no file behind, and the .wl self-sync then half-materialised a checkout
|
|
347
|
+
-- templates deployed, no `config.json`, a failed version stamp and the
|
|
348
|
+
one-hour `.update_failed` cooldown on a brand-new worktree (#202).
|
|
349
|
+
|
|
350
|
+
It also writes the *creation* defaults rather than ``self._config``.
|
|
351
|
+
``self._config`` is the merged runtime view, so it carries values derived
|
|
352
|
+
from the process that happens to be creating the file -- under pytest
|
|
353
|
+
``environment=testing``, in CI ``production``. Since ``load_all`` prefers
|
|
354
|
+
the file's ``environment`` over detection, persisting that value turned
|
|
355
|
+
a derivation into a permanent override, and the version stamp then
|
|
356
|
+
certified it as current (#208). See
|
|
357
|
+
:func:`~wl_cli.utils.config.defaults.get_creation_defaults`.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
True when the file exists afterwards; False when the defaults could
|
|
361
|
+
not be written (#202).
|
|
362
|
+
"""
|
|
363
|
+
if os.path.exists(self.config_file):
|
|
364
|
+
return True
|
|
365
|
+
ok = self._save_config(force=True, creation=True)
|
|
366
|
+
return ok and os.path.exists(self.config_file)
|
|
367
|
+
|
|
368
|
+
def _persistable_config(self) -> dict[str, Any]:
|
|
369
|
+
"""The merged config minus runtime-derived values nobody set (#208).
|
|
370
|
+
|
|
371
|
+
``self._config`` is the merged runtime view, and ``ConfigLoader.load_all``
|
|
372
|
+
always fills in ``environment`` -- derived from this process or read back
|
|
373
|
+
from the file. Writing that value on an ordinary save would recreate the
|
|
374
|
+
permanent override that :meth:`ensure_project_config` deliberately keeps
|
|
375
|
+
out of a new file, so it reaches disk only when ``set()`` recorded it as
|
|
376
|
+
explicit user intent.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
A shallow copy of the merged config safe to persist.
|
|
380
|
+
"""
|
|
381
|
+
return {
|
|
382
|
+
key: value
|
|
383
|
+
for key, value in self._config.items()
|
|
384
|
+
if key not in RUNTIME_DERIVED_KEYS or key in self._explicit_keys
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
def _save_config(self, *, force: bool = False, creation: bool = False) -> bool:
|
|
388
|
+
"""Save configuration to file using ConfigSaver.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
force: Write even in a test environment (see
|
|
392
|
+
:meth:`ensure_project_config`).
|
|
393
|
+
creation: Write the environment-independent creation payload instead
|
|
394
|
+
of the merged runtime config (see :meth:`ensure_project_config`).
|
|
395
|
+
Only the create path passes this; ``set()`` keeps persisting the
|
|
396
|
+
merged config, so an explicit ``wl config set environment ...``
|
|
397
|
+
is still written as the user's own setting (#208).
|
|
398
|
+
|
|
399
|
+
The runtime-derived keys are filtered out of the merged view unless
|
|
400
|
+
``set()`` recorded them as explicit user intent. Doing this only on the
|
|
401
|
+
create path would leave the invariant one ``wl config set`` away from
|
|
402
|
+
being undone: the merged view always carries ``environment``, so the
|
|
403
|
+
first ordinary save in an empty repo (or any save in CI) wrote the
|
|
404
|
+
derivation back into the neutral file (#208).
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
True when the write succeeded; False when it failed (#177).
|
|
408
|
+
"""
|
|
409
|
+
if creation:
|
|
410
|
+
from .defaults import get_creation_defaults
|
|
411
|
+
|
|
412
|
+
full_config, original_config = get_creation_defaults(), {}
|
|
413
|
+
else:
|
|
414
|
+
full_config = self._persistable_config()
|
|
415
|
+
original_config = self._original_project_config
|
|
416
|
+
|
|
417
|
+
saver = ConfigSaver(self.config_file)
|
|
418
|
+
ok = saver.save(
|
|
419
|
+
full_config=full_config,
|
|
420
|
+
original_project_config=original_config,
|
|
421
|
+
force=force,
|
|
422
|
+
)
|
|
423
|
+
if ok:
|
|
424
|
+
# Only a real (or intentionally skipped) save resets the reload
|
|
425
|
+
# baseline; a failed write leaves the on-disk state behind.
|
|
426
|
+
self._last_modified_time = time.time()
|
|
427
|
+
return ok
|
|
428
|
+
|
|
429
|
+
def _should_reload(self) -> bool:
|
|
430
|
+
"""Check if configuration should be reloaded based on file modification time."""
|
|
431
|
+
# In test environment, never reload configuration to prevent losing test data
|
|
432
|
+
if os.environ.get(TEST_ENV_VAR):
|
|
433
|
+
return False
|
|
434
|
+
try:
|
|
435
|
+
config_path = Path(self.config_file)
|
|
436
|
+
if config_path.exists():
|
|
437
|
+
current_mtime = config_path.stat().st_mtime
|
|
438
|
+
return current_mtime > self._last_modified_time
|
|
439
|
+
except OSError:
|
|
440
|
+
pass
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
def get(self, key: str, default: Any = None, auto_reload: bool = True) -> Any:
|
|
444
|
+
"""
|
|
445
|
+
Get configuration value with support for nested keys.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
key (str): Configuration key (supports dot notation for nested keys).
|
|
449
|
+
default (Any, optional): Default value if key not found.
|
|
450
|
+
auto_reload (bool, optional): Whether to automatically reload if config file changed.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
Any: Configuration value.
|
|
454
|
+
"""
|
|
455
|
+
if auto_reload and self._should_reload():
|
|
456
|
+
self._load_config()
|
|
457
|
+
|
|
458
|
+
# Support dot notation for nested keys
|
|
459
|
+
value = self._config
|
|
460
|
+
for part in key.split("."):
|
|
461
|
+
if isinstance(value, dict) and part in value:
|
|
462
|
+
value = value[part]
|
|
463
|
+
else:
|
|
464
|
+
return default
|
|
465
|
+
|
|
466
|
+
if key == "log_file" and value and isinstance(value, str):
|
|
467
|
+
log_path = Path(value)
|
|
468
|
+
if log_path.is_absolute():
|
|
469
|
+
return value
|
|
470
|
+
# Check if value is the default log file path pattern
|
|
471
|
+
default_log_path = Path(DEFAULT_LOG_FILE)
|
|
472
|
+
if log_path == default_log_path:
|
|
473
|
+
return value
|
|
474
|
+
config_dir = Path(self.config_file).parent
|
|
475
|
+
return str(config_dir / value)
|
|
476
|
+
|
|
477
|
+
return value
|
|
478
|
+
|
|
479
|
+
def set(self, key: str, value: Any) -> bool:
|
|
480
|
+
"""Set configuration value with support for nested keys.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
True when the change was persisted (or the write was intentionally
|
|
484
|
+
skipped in a test environment); False when the write failed (#177).
|
|
485
|
+
"""
|
|
486
|
+
# Support dot notation for nested keys
|
|
487
|
+
parts = key.split(".")
|
|
488
|
+
config = self._config
|
|
489
|
+
|
|
490
|
+
for part in parts[:-1]:
|
|
491
|
+
if part not in config or not isinstance(config[part], dict):
|
|
492
|
+
config[part] = {}
|
|
493
|
+
config = config[part]
|
|
494
|
+
|
|
495
|
+
config[parts[-1]] = value
|
|
496
|
+
# A runtime-derived key only becomes writable when the user names it:
|
|
497
|
+
# an explicit ``wl config set environment ...`` is intent, a derivation
|
|
498
|
+
# is not (#208).
|
|
499
|
+
self._explicit_keys.add(parts[0])
|
|
500
|
+
# Only save if not in test environment
|
|
501
|
+
if not os.environ.get(TEST_ENV_VAR):
|
|
502
|
+
return self._save_config()
|
|
503
|
+
# In test environment, update last modified time to prevent unnecessary reload
|
|
504
|
+
self._last_modified_time = time.time()
|
|
505
|
+
return True
|
|
506
|
+
|
|
507
|
+
def unset(self, key: str) -> bool:
|
|
508
|
+
"""Remove a top-level ``key`` from the project config file.
|
|
509
|
+
|
|
510
|
+
``wl config reset`` uses this for runtime-derived keys: their default is
|
|
511
|
+
a *derivation* (``development``), not a project setting, so writing it
|
|
512
|
+
back would freeze exactly the value #208 keeps out of the file. Deleting
|
|
513
|
+
the key restores derivation on the next load, and also clears the
|
|
514
|
+
in-memory merged value so a later get does not read the stale one.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
True when the removal was persisted (or the write was intentionally
|
|
518
|
+
skipped in a test environment); False when the write failed (#177).
|
|
519
|
+
"""
|
|
520
|
+
self._explicit_keys.discard(key)
|
|
521
|
+
self._config.pop(key, None)
|
|
522
|
+
self._original_project_config.pop(key, None)
|
|
523
|
+
if key in RUNTIME_DERIVED_KEYS:
|
|
524
|
+
# Keep the merged view complete for the rest of the process: readers
|
|
525
|
+
# still need a value, and the re-derived one is filtered back out of
|
|
526
|
+
# any later write (see ``_persistable_config``).
|
|
527
|
+
self._config[key] = self._loader.get_environment()
|
|
528
|
+
if not os.environ.get(TEST_ENV_VAR):
|
|
529
|
+
return self._save_config()
|
|
530
|
+
self._last_modified_time = time.time()
|
|
531
|
+
return True
|
|
532
|
+
|
|
533
|
+
def get_all(self, auto_reload: bool = True) -> dict[str, Any]:
|
|
534
|
+
"""Get all configuration."""
|
|
535
|
+
if auto_reload and self._should_reload():
|
|
536
|
+
self._load_config()
|
|
537
|
+
return self._config.copy()
|
|
538
|
+
|
|
539
|
+
def reload(self) -> None:
|
|
540
|
+
"""Reload configuration from all sources."""
|
|
541
|
+
self._load_config()
|
|
542
|
+
|
|
543
|
+
def hot_reload(self) -> bool:
|
|
544
|
+
"""
|
|
545
|
+
Hot reload configuration from file.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
bool: True if configuration was reloaded, False otherwise.
|
|
549
|
+
"""
|
|
550
|
+
# Save current config
|
|
551
|
+
current_config = self._config.copy()
|
|
552
|
+
|
|
553
|
+
# In test environment, always reload to ensure test data is loaded
|
|
554
|
+
if os.environ.get(TEST_ENV_VAR):
|
|
555
|
+
self._load_config()
|
|
556
|
+
# Restore current config in test environment
|
|
557
|
+
for key, value in current_config.items():
|
|
558
|
+
self._config[key] = value
|
|
559
|
+
return True
|
|
560
|
+
if self._should_reload():
|
|
561
|
+
self._load_config()
|
|
562
|
+
# Restore existing keys that weren't reloaded to maintain current state
|
|
563
|
+
for key, value in current_config.items():
|
|
564
|
+
if key not in self._config:
|
|
565
|
+
self._config[key] = value
|
|
566
|
+
return True
|
|
567
|
+
return False
|
|
568
|
+
|
|
569
|
+
def export_environment_variables(
|
|
570
|
+
self, environment: str | None = None
|
|
571
|
+
) -> dict[str, str]:
|
|
572
|
+
"""Export current configuration as environment variables."""
|
|
573
|
+
config = self.get_all()
|
|
574
|
+
return self._env_exporter.export_environment_variables(config, environment)
|
|
575
|
+
|
|
576
|
+
def get_environment_template(self, environment: str) -> dict[str, str]:
|
|
577
|
+
"""Get environment variable template for specific environment."""
|
|
578
|
+
return self._env_exporter.get_environment_template(environment)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
_config_manager: ConfigManager | None = None
|
|
582
|
+
_config_manager_lock = threading.RLock()
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
class ConfigContext:
|
|
586
|
+
"""配置上下文管理器,用于管理配置的作用域"""
|
|
587
|
+
|
|
588
|
+
def __init__(self, config_manager: ConfigManager | None = None) -> None:
|
|
589
|
+
"""初始化配置上下文
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
config_manager: 自定义配置管理器实例,如果为None则使用全局实例
|
|
593
|
+
"""
|
|
594
|
+
self._custom_manager = config_manager or ConfigManager()
|
|
595
|
+
self._original_manager: ConfigManager | None = None
|
|
596
|
+
|
|
597
|
+
def __enter__(self) -> ConfigManager:
|
|
598
|
+
"""进入上下文,返回配置管理器"""
|
|
599
|
+
# 保存原始管理器
|
|
600
|
+
global _config_manager
|
|
601
|
+
with _config_manager_lock:
|
|
602
|
+
self._original_manager = _config_manager
|
|
603
|
+
_config_manager = self._custom_manager
|
|
604
|
+
return self._custom_manager
|
|
605
|
+
|
|
606
|
+
def __exit__(self, *_) -> None:
|
|
607
|
+
"""退出上下文,恢复原始管理器"""
|
|
608
|
+
global _config_manager
|
|
609
|
+
with _config_manager_lock:
|
|
610
|
+
# 强制恢复原始管理器,即使它是None
|
|
611
|
+
_config_manager = self._original_manager
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
class ConfigProvider:
|
|
615
|
+
"""配置提供者,用于依赖注入
|
|
616
|
+
|
|
617
|
+
ConfigProvider provides a way to inject configuration management into other components
|
|
618
|
+
without relying on global state. It can either use a custom ConfigManager instance
|
|
619
|
+
or fall back to the global one.
|
|
620
|
+
"""
|
|
621
|
+
|
|
622
|
+
def __init__(self, config_manager: ConfigManager | None = None):
|
|
623
|
+
"""初始化配置提供者
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
config_manager: 配置管理器实例,如果为None则使用全局实例
|
|
627
|
+
"""
|
|
628
|
+
self._config_manager = config_manager
|
|
629
|
+
|
|
630
|
+
def get_config(self, key: str, default: Any = None) -> Any:
|
|
631
|
+
"""获取配置值
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
key: 配置键(支持点号表示嵌套键)
|
|
635
|
+
default: 默认值,如果键不存在则返回
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
配置值
|
|
639
|
+
"""
|
|
640
|
+
from .core import get_config_manager
|
|
641
|
+
|
|
642
|
+
manager = self._config_manager or get_config_manager()
|
|
643
|
+
return manager.get(key, default)
|
|
644
|
+
|
|
645
|
+
def set_config(self, key: str, value: Any) -> None:
|
|
646
|
+
"""设置配置值
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
key: 配置键(支持点号表示嵌套键)
|
|
650
|
+
value: 配置值
|
|
651
|
+
"""
|
|
652
|
+
from .core import get_config_manager
|
|
653
|
+
|
|
654
|
+
manager = self._config_manager or get_config_manager()
|
|
655
|
+
manager.set(key, value)
|
|
656
|
+
|
|
657
|
+
def get_all(self) -> dict[str, Any]:
|
|
658
|
+
"""获取所有配置
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
所有配置的字典副本
|
|
662
|
+
"""
|
|
663
|
+
from .core import get_config_manager
|
|
664
|
+
|
|
665
|
+
manager = self._config_manager or get_config_manager()
|
|
666
|
+
return manager.get_all()
|
|
667
|
+
|
|
668
|
+
def reload(self) -> None:
|
|
669
|
+
"""重新加载配置
|
|
670
|
+
|
|
671
|
+
Reloads configuration from all sources (file, environment variables, etc.)
|
|
672
|
+
"""
|
|
673
|
+
from .core import get_config_manager
|
|
674
|
+
|
|
675
|
+
manager = self._config_manager or get_config_manager()
|
|
676
|
+
manager.reload()
|
|
677
|
+
|
|
678
|
+
@property
|
|
679
|
+
def config_manager(self) -> ConfigManager:
|
|
680
|
+
"""获取配置管理器实例
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
配置管理器实例
|
|
684
|
+
"""
|
|
685
|
+
from .core import get_config_manager
|
|
686
|
+
|
|
687
|
+
return self._config_manager or get_config_manager()
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def get_config_manager() -> ConfigManager:
|
|
691
|
+
"""Get global configuration manager instance.
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
Global ConfigManager instance
|
|
695
|
+
"""
|
|
696
|
+
import os
|
|
697
|
+
import tempfile
|
|
698
|
+
|
|
699
|
+
global _config_manager
|
|
700
|
+
with _config_manager_lock:
|
|
701
|
+
if _config_manager is None:
|
|
702
|
+
# In test environment, create a ConfigManager with an empty configuration
|
|
703
|
+
if os.environ.get(TEST_ENV_VAR):
|
|
704
|
+
with tempfile.NamedTemporaryFile(
|
|
705
|
+
mode="w", suffix=".json", delete=False
|
|
706
|
+
) as f:
|
|
707
|
+
f.write("{}") # 写入空配置
|
|
708
|
+
temp_config_file = f.name
|
|
709
|
+
try:
|
|
710
|
+
_config_manager = ConfigManager(config_file=temp_config_file)
|
|
711
|
+
finally:
|
|
712
|
+
if os.path.exists(temp_config_file):
|
|
713
|
+
os.unlink(temp_config_file)
|
|
714
|
+
else:
|
|
715
|
+
_config_manager = ConfigManager()
|
|
716
|
+
return _config_manager
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def get_config(key: str, default: Any = None) -> Any:
|
|
720
|
+
"""Get configuration value from global manager with auto-reload.
|
|
721
|
+
|
|
722
|
+
Args:
|
|
723
|
+
key: 配置键(支持点号表示嵌套键)
|
|
724
|
+
default: 默认值,如果键不存在则返回
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
配置值
|
|
728
|
+
"""
|
|
729
|
+
return get_config_manager().get(key, default)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def set_config(key: str, value: Any) -> None:
|
|
733
|
+
"""Set configuration value using the global manager.
|
|
734
|
+
|
|
735
|
+
Args:
|
|
736
|
+
key: 配置键(支持点号表示嵌套键)
|
|
737
|
+
value: 配置值
|
|
738
|
+
"""
|
|
739
|
+
get_config_manager().set(key, value)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def reload_config() -> bool:
|
|
743
|
+
"""Reload global configuration.
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
True if configuration was reloaded, False otherwise
|
|
747
|
+
"""
|
|
748
|
+
return get_config_manager().hot_reload()
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def reset_config_manager() -> None:
|
|
752
|
+
"""重置全局配置管理器,主要用于测试
|
|
753
|
+
|
|
754
|
+
Resets the global configuration manager instance to None, forcing a new instance
|
|
755
|
+
to be created on the next call to get_config_manager().
|
|
756
|
+
"""
|
|
757
|
+
global _config_manager
|
|
758
|
+
with _config_manager_lock:
|
|
759
|
+
_config_manager = None
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
# Multi-language message mappings
|
|
763
|
+
MESSAGES = {
|
|
764
|
+
"en": {
|
|
765
|
+
"config": {
|
|
766
|
+
"loaded": "Configuration loaded successfully",
|
|
767
|
+
"reloaded": "Configuration reloaded successfully",
|
|
768
|
+
"saved": "Configuration saved successfully",
|
|
769
|
+
"not_found": "Configuration file not found, using defaults",
|
|
770
|
+
"invalid": "Invalid configuration, using defaults",
|
|
771
|
+
},
|
|
772
|
+
"errors": {
|
|
773
|
+
"file_not_found": "File not found: {path}",
|
|
774
|
+
"permission_denied": "Permission denied: {path}",
|
|
775
|
+
"invalid_input": "Invalid input: {message}",
|
|
776
|
+
"command_failed": "Command failed: {command}",
|
|
777
|
+
"unknown_error": "Unknown error occurred",
|
|
778
|
+
},
|
|
779
|
+
"info": {
|
|
780
|
+
"starting": "Starting {command}",
|
|
781
|
+
"completed": "{command} completed successfully",
|
|
782
|
+
"skipped": "{command} skipped",
|
|
783
|
+
"warning": "Warning: {message}",
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
"zh": {
|
|
787
|
+
"config": {
|
|
788
|
+
"loaded": "配置加载成功",
|
|
789
|
+
"reloaded": "配置重新加载成功",
|
|
790
|
+
"saved": "配置保存成功",
|
|
791
|
+
"not_found": "未找到配置文件,使用默认值",
|
|
792
|
+
"invalid": "无效的配置,使用默认值",
|
|
793
|
+
},
|
|
794
|
+
"errors": {
|
|
795
|
+
"file_not_found": "文件未找到: {path}",
|
|
796
|
+
"permission_denied": "权限被拒绝: {path}",
|
|
797
|
+
"invalid_input": "无效的输入: {message}",
|
|
798
|
+
"command_failed": "命令执行失败: {command}",
|
|
799
|
+
"unknown_error": "发生未知错误",
|
|
800
|
+
},
|
|
801
|
+
"info": {
|
|
802
|
+
"starting": "正在启动 {command}",
|
|
803
|
+
"completed": "{command} 执行成功",
|
|
804
|
+
"skipped": "{command} 已跳过",
|
|
805
|
+
"warning": "警告: {message}",
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def get_message(key: str, language: str = "auto", **kwargs) -> str:
|
|
812
|
+
"""
|
|
813
|
+
Get message in specified language.
|
|
814
|
+
|
|
815
|
+
Args:
|
|
816
|
+
key: Message key in format "category.message"
|
|
817
|
+
language: Language code ("en", "zh", or "auto")
|
|
818
|
+
**kwargs: Parameters to format the message
|
|
819
|
+
|
|
820
|
+
Returns:
|
|
821
|
+
Formatted message string
|
|
822
|
+
"""
|
|
823
|
+
from ..language_utils import _should_display_language
|
|
824
|
+
|
|
825
|
+
# Determine language if auto
|
|
826
|
+
if language == "auto":
|
|
827
|
+
if _should_display_language("zh"):
|
|
828
|
+
language = "zh"
|
|
829
|
+
else:
|
|
830
|
+
language = "en"
|
|
831
|
+
|
|
832
|
+
# Default to English if language not supported
|
|
833
|
+
if language not in MESSAGES:
|
|
834
|
+
language = "en"
|
|
835
|
+
|
|
836
|
+
# Split key into category and message
|
|
837
|
+
parts = key.split(".")
|
|
838
|
+
if len(parts) != 2:
|
|
839
|
+
return f"Message not found: {key}"
|
|
840
|
+
|
|
841
|
+
category, message_key = parts
|
|
842
|
+
|
|
843
|
+
# Get message from dictionary
|
|
844
|
+
try:
|
|
845
|
+
message = MESSAGES[language][category][message_key]
|
|
846
|
+
return message.format(**kwargs)
|
|
847
|
+
except KeyError:
|
|
848
|
+
return f"Message not found: {key}"
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
__all__ = [
|
|
852
|
+
"ConfigManager",
|
|
853
|
+
"ConfigContext",
|
|
854
|
+
"ConfigProvider",
|
|
855
|
+
"EnvExporter",
|
|
856
|
+
"get_config_manager",
|
|
857
|
+
"get_config",
|
|
858
|
+
"set_config",
|
|
859
|
+
"reload_config",
|
|
860
|
+
"reset_config_manager",
|
|
861
|
+
"get_message",
|
|
862
|
+
]
|