tactus 0.34.1__py3-none-any.whl → 0.35.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.
- tactus/__init__.py +1 -1
- tactus/adapters/broker_log.py +17 -14
- tactus/adapters/channels/__init__.py +17 -15
- tactus/adapters/channels/base.py +16 -7
- tactus/adapters/channels/broker.py +43 -13
- tactus/adapters/channels/cli.py +19 -15
- tactus/adapters/channels/host.py +15 -6
- tactus/adapters/channels/ipc.py +82 -31
- tactus/adapters/channels/sse.py +41 -23
- tactus/adapters/cli_hitl.py +19 -19
- tactus/adapters/cli_log.py +4 -4
- tactus/adapters/control_loop.py +138 -99
- tactus/adapters/cost_collector_log.py +9 -9
- tactus/adapters/file_storage.py +56 -52
- tactus/adapters/http_callback_log.py +23 -13
- tactus/adapters/ide_log.py +17 -9
- tactus/adapters/lua_tools.py +4 -5
- tactus/adapters/mcp.py +16 -19
- tactus/adapters/mcp_manager.py +46 -30
- tactus/adapters/memory.py +9 -9
- tactus/adapters/plugins.py +42 -42
- tactus/broker/client.py +75 -78
- tactus/broker/protocol.py +57 -57
- tactus/broker/server.py +252 -197
- tactus/cli/app.py +3 -1
- tactus/cli/control.py +2 -2
- tactus/core/config_manager.py +181 -135
- tactus/core/dependencies/registry.py +66 -48
- tactus/core/dsl_stubs.py +222 -163
- tactus/core/exceptions.py +10 -1
- tactus/core/execution_context.py +152 -112
- tactus/core/lua_sandbox.py +72 -64
- tactus/core/message_history_manager.py +138 -43
- tactus/core/mocking.py +41 -27
- tactus/core/output_validator.py +49 -44
- tactus/core/registry.py +94 -80
- tactus/core/runtime.py +211 -176
- tactus/core/template_resolver.py +16 -16
- tactus/core/yaml_parser.py +55 -45
- tactus/docs/extractor.py +7 -6
- tactus/ide/server.py +119 -78
- tactus/primitives/control.py +10 -6
- tactus/primitives/file.py +48 -46
- tactus/primitives/handles.py +47 -35
- tactus/primitives/host.py +29 -27
- tactus/primitives/human.py +154 -137
- tactus/primitives/json.py +22 -23
- tactus/primitives/log.py +26 -26
- tactus/primitives/message_history.py +285 -31
- tactus/primitives/model.py +15 -9
- tactus/primitives/procedure.py +86 -64
- tactus/primitives/procedure_callable.py +58 -51
- tactus/primitives/retry.py +31 -29
- tactus/primitives/session.py +42 -29
- tactus/primitives/state.py +54 -43
- tactus/primitives/step.py +9 -13
- tactus/primitives/system.py +34 -21
- tactus/primitives/tool.py +44 -31
- tactus/primitives/tool_handle.py +76 -54
- tactus/primitives/toolset.py +25 -22
- tactus/sandbox/config.py +4 -4
- tactus/sandbox/container_runner.py +161 -107
- tactus/sandbox/docker_manager.py +20 -20
- tactus/sandbox/entrypoint.py +16 -14
- tactus/sandbox/protocol.py +15 -15
- tactus/stdlib/classify/llm.py +1 -3
- tactus/stdlib/core/validation.py +0 -3
- tactus/testing/pydantic_eval_runner.py +1 -1
- tactus/utils/asyncio_helpers.py +27 -0
- tactus/utils/cost_calculator.py +7 -7
- tactus/utils/model_pricing.py +11 -12
- tactus/utils/safe_file_library.py +156 -132
- tactus/utils/safe_libraries.py +27 -27
- tactus/validation/error_listener.py +18 -5
- tactus/validation/semantic_visitor.py +392 -333
- tactus/validation/validator.py +89 -49
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/METADATA +12 -3
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/RECORD +81 -80
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/WHEEL +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/entry_points.txt +0 -0
- {tactus-0.34.1.dist-info → tactus-0.35.0.dist-info}/licenses/LICENSE +0 -0
tactus/core/config_manager.py
CHANGED
|
@@ -4,13 +4,13 @@ Configuration Manager for Tactus.
|
|
|
4
4
|
Implements cascading configuration from multiple sources with clear priority ordering.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import logging
|
|
8
|
-
import os
|
|
9
|
-
import yaml
|
|
10
7
|
from pathlib import Path
|
|
11
|
-
from typing import Dict, Any, Optional, List, Tuple
|
|
12
8
|
from copy import deepcopy
|
|
13
9
|
from dataclasses import dataclass, field
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
import yaml
|
|
14
14
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
@@ -39,7 +39,7 @@ class ConfigValue:
|
|
|
39
39
|
overridden_by: Optional[str] = None
|
|
40
40
|
"""If overridden, what source did the override? None if this is the final value."""
|
|
41
41
|
|
|
42
|
-
override_chain:
|
|
42
|
+
override_chain: list[tuple[str, Any]] = field(default_factory=list)
|
|
43
43
|
"""History of overrides: [(source, value), ...] in chronological order"""
|
|
44
44
|
|
|
45
45
|
is_env_override: bool = False
|
|
@@ -48,7 +48,7 @@ class ConfigValue:
|
|
|
48
48
|
original_env_var: Optional[str] = None
|
|
49
49
|
"""Original environment variable name if value came from env (e.g., 'OPENAI_API_KEY')"""
|
|
50
50
|
|
|
51
|
-
def to_dict(self) ->
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
52
|
"""Convert to dictionary for JSON serialization."""
|
|
53
53
|
return {
|
|
54
54
|
"value": self.value,
|
|
@@ -82,7 +82,7 @@ class ConfigManager:
|
|
|
82
82
|
self.loaded_configs = [] # Track loaded configs for debugging
|
|
83
83
|
self.env_var_mapping = {} # Track which env var each config key came from
|
|
84
84
|
|
|
85
|
-
def load_cascade(self, procedure_path: Path) ->
|
|
85
|
+
def load_cascade(self, procedure_path: Path) -> dict[str, Any]:
|
|
86
86
|
"""
|
|
87
87
|
Load and merge all configuration sources in priority order.
|
|
88
88
|
|
|
@@ -95,53 +95,53 @@ class ConfigManager:
|
|
|
95
95
|
Returns:
|
|
96
96
|
Merged configuration dictionary
|
|
97
97
|
"""
|
|
98
|
-
|
|
98
|
+
config_sources: list[tuple[str, dict[str, Any]]] = []
|
|
99
99
|
|
|
100
100
|
# 1. System config (lowest precedence)
|
|
101
101
|
for system_path in self._get_system_config_paths():
|
|
102
102
|
if system_path.exists():
|
|
103
103
|
system_config = self._load_yaml_file(system_path)
|
|
104
104
|
if system_config:
|
|
105
|
-
|
|
106
|
-
logger.debug(
|
|
105
|
+
config_sources.append((f"system:{system_path}", system_config))
|
|
106
|
+
logger.debug("Loaded system config: %s", system_path)
|
|
107
107
|
|
|
108
108
|
# 2. User config (~/.tactus/config.yml, XDG, etc.)
|
|
109
109
|
for user_path in self._get_user_config_paths():
|
|
110
110
|
if user_path.exists():
|
|
111
111
|
user_config = self._load_yaml_file(user_path)
|
|
112
112
|
if user_config:
|
|
113
|
-
|
|
114
|
-
logger.debug(
|
|
113
|
+
config_sources.append((f"user:{user_path}", user_config))
|
|
114
|
+
logger.debug("Loaded user config: %s", user_path)
|
|
115
115
|
|
|
116
116
|
# 3. Project config (.tactus/config.yml in cwd)
|
|
117
117
|
root_config_path = Path.cwd() / ".tactus" / "config.yml"
|
|
118
118
|
if root_config_path.exists():
|
|
119
119
|
root_config = self._load_yaml_file(root_config_path)
|
|
120
120
|
if root_config:
|
|
121
|
-
|
|
122
|
-
logger.debug(
|
|
121
|
+
config_sources.append(("root", root_config))
|
|
122
|
+
logger.debug("Loaded root config: %s", root_config_path)
|
|
123
123
|
|
|
124
124
|
# 4. Parent directory configs (walk up from procedure directory)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
for config_path in
|
|
128
|
-
|
|
129
|
-
if
|
|
130
|
-
|
|
131
|
-
logger.debug(
|
|
125
|
+
procedure_directory = procedure_path.parent.resolve()
|
|
126
|
+
parent_config_paths = self._find_directory_configs(procedure_directory)
|
|
127
|
+
for config_path in parent_config_paths:
|
|
128
|
+
parent_config = self._load_yaml_file(config_path)
|
|
129
|
+
if parent_config:
|
|
130
|
+
config_sources.append((f"parent:{config_path}", parent_config))
|
|
131
|
+
logger.debug("Loaded parent config: %s", config_path)
|
|
132
132
|
|
|
133
133
|
# 5. Local directory config (.tactus/config.yml in procedure's directory)
|
|
134
|
-
local_config_path =
|
|
135
|
-
if local_config_path.exists() and local_config_path not in
|
|
134
|
+
local_config_path = procedure_directory / ".tactus" / "config.yml"
|
|
135
|
+
if local_config_path.exists() and local_config_path not in parent_config_paths:
|
|
136
136
|
local_config = self._load_yaml_file(local_config_path)
|
|
137
137
|
if local_config:
|
|
138
|
-
|
|
139
|
-
logger.debug(
|
|
138
|
+
config_sources.append(("local", local_config))
|
|
139
|
+
logger.debug("Loaded local config: %s", local_config_path)
|
|
140
140
|
|
|
141
141
|
# 6. Environment variables (override config files)
|
|
142
|
-
|
|
143
|
-
if
|
|
144
|
-
|
|
142
|
+
environment_config = self._load_from_environment()
|
|
143
|
+
if environment_config:
|
|
144
|
+
config_sources.append(("environment", environment_config))
|
|
145
145
|
logger.debug("Loaded config from environment variables")
|
|
146
146
|
|
|
147
147
|
# 7. Sidecar config (highest priority, except CLI args)
|
|
@@ -149,16 +149,16 @@ class ConfigManager:
|
|
|
149
149
|
if sidecar_path:
|
|
150
150
|
sidecar_config = self._load_yaml_file(sidecar_path)
|
|
151
151
|
if sidecar_config:
|
|
152
|
-
|
|
153
|
-
logger.debug(
|
|
152
|
+
config_sources.append(("sidecar", sidecar_config))
|
|
153
|
+
logger.debug("Loaded sidecar config: %s", sidecar_path)
|
|
154
154
|
|
|
155
155
|
# Store for debugging
|
|
156
|
-
self.loaded_configs =
|
|
156
|
+
self.loaded_configs = config_sources
|
|
157
157
|
|
|
158
158
|
# Merge all configs (later configs override earlier ones)
|
|
159
|
-
merged = self._merge_configs([
|
|
159
|
+
merged = self._merge_configs([config for _, config in config_sources])
|
|
160
160
|
|
|
161
|
-
logger.debug(
|
|
161
|
+
logger.debug("Merged configuration from %s source(s)", len(config_sources))
|
|
162
162
|
return merged
|
|
163
163
|
|
|
164
164
|
def _find_sidecar_config(self, tac_path: Path) -> Optional[Path]:
|
|
@@ -188,7 +188,7 @@ class ConfigManager:
|
|
|
188
188
|
|
|
189
189
|
return None
|
|
190
190
|
|
|
191
|
-
def _find_directory_configs(self, start_path: Path) ->
|
|
191
|
+
def _find_directory_configs(self, start_path: Path) -> list[Path]:
|
|
192
192
|
"""
|
|
193
193
|
Walk up directory tree to find all .tactus/config.yml files.
|
|
194
194
|
|
|
@@ -217,7 +217,7 @@ class ConfigManager:
|
|
|
217
217
|
# Return in order from root to start_path (so later ones override)
|
|
218
218
|
return list(reversed(configs))
|
|
219
219
|
|
|
220
|
-
def _load_yaml_file(self, path: Path) -> Optional[
|
|
220
|
+
def _load_yaml_file(self, path: Path) -> Optional[dict[str, Any]]:
|
|
221
221
|
"""
|
|
222
222
|
Load YAML configuration file.
|
|
223
223
|
|
|
@@ -228,14 +228,14 @@ class ConfigManager:
|
|
|
228
228
|
Configuration dictionary or None if loading fails
|
|
229
229
|
"""
|
|
230
230
|
try:
|
|
231
|
-
with open(path, "r") as
|
|
232
|
-
|
|
233
|
-
return
|
|
234
|
-
except Exception as
|
|
235
|
-
logger.warning(
|
|
231
|
+
with open(path, "r") as file_handle:
|
|
232
|
+
loaded_config = yaml.safe_load(file_handle)
|
|
233
|
+
return loaded_config if isinstance(loaded_config, dict) else {}
|
|
234
|
+
except Exception as exception:
|
|
235
|
+
logger.warning("Failed to load config from %s: %s", path, exception)
|
|
236
236
|
return None
|
|
237
237
|
|
|
238
|
-
def _load_from_environment(self) ->
|
|
238
|
+
def _load_from_environment(self) -> dict[str, Any]:
|
|
239
239
|
"""
|
|
240
240
|
Load configuration from environment variables.
|
|
241
241
|
|
|
@@ -244,11 +244,11 @@ class ConfigManager:
|
|
|
244
244
|
Returns:
|
|
245
245
|
Configuration dictionary from environment
|
|
246
246
|
"""
|
|
247
|
-
config = {}
|
|
247
|
+
config: dict[str, Any] = {}
|
|
248
248
|
|
|
249
249
|
# Load known config keys from environment
|
|
250
250
|
# NOTE: Keys must match the config file structure (nested under provider name)
|
|
251
|
-
|
|
251
|
+
env_var_to_config_path = {
|
|
252
252
|
"OPENAI_API_KEY": ("openai", "api_key"),
|
|
253
253
|
"GOOGLE_API_KEY": ("google", "api_key"),
|
|
254
254
|
"AWS_ACCESS_KEY_ID": ("aws", "access_key_id"),
|
|
@@ -256,6 +256,7 @@ class ConfigManager:
|
|
|
256
256
|
"AWS_DEFAULT_REGION": ("aws", "default_region"),
|
|
257
257
|
"AWS_PROFILE": ("aws", "profile"),
|
|
258
258
|
"TOOL_PATHS": "tool_paths",
|
|
259
|
+
"TACTUS_DEFAULT_PROVIDER": "default_provider",
|
|
259
260
|
# Sandbox configuration
|
|
260
261
|
"TACTUS_SANDBOX_ENABLED": ("sandbox", "enabled"),
|
|
261
262
|
"TACTUS_SANDBOX_IMAGE": ("sandbox", "image"),
|
|
@@ -279,49 +280,49 @@ class ConfigManager:
|
|
|
279
280
|
}
|
|
280
281
|
|
|
281
282
|
# Boolean env vars that need special parsing
|
|
282
|
-
|
|
283
|
+
boolean_env_var_keys = {
|
|
283
284
|
"TACTUS_SANDBOX_ENABLED",
|
|
284
285
|
"TACTUS_NOTIFICATIONS_ENABLED",
|
|
285
286
|
"TACTUS_CONTROL_ENABLED",
|
|
286
287
|
"TACTUS_CONTROL_CLI_ENABLED",
|
|
287
288
|
}
|
|
288
289
|
|
|
289
|
-
for
|
|
290
|
-
|
|
291
|
-
if
|
|
290
|
+
for env_var_name, config_key in env_var_to_config_path.items():
|
|
291
|
+
env_var_value = os.environ.get(env_var_name)
|
|
292
|
+
if env_var_value:
|
|
292
293
|
# Parse boolean values
|
|
293
|
-
if
|
|
294
|
-
|
|
294
|
+
if env_var_name in boolean_env_var_keys:
|
|
295
|
+
env_var_value = env_var_value.lower() in ("true", "1", "yes", "on")
|
|
295
296
|
|
|
296
297
|
if isinstance(config_key, tuple):
|
|
297
298
|
# Nested key - handle arbitrary depth
|
|
298
299
|
# e.g., ("aws", "access_key_id") -> config["aws"]["access_key_id"]
|
|
299
300
|
# e.g., ("notifications", "channels", "slack", "token")
|
|
300
|
-
|
|
301
|
-
for
|
|
302
|
-
if key not in
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
301
|
+
current_container = config
|
|
302
|
+
for key in config_key[:-1]:
|
|
303
|
+
if key not in current_container:
|
|
304
|
+
current_container[key] = {}
|
|
305
|
+
current_container = current_container[key]
|
|
306
|
+
current_container[config_key[-1]] = env_var_value
|
|
306
307
|
# Track env var name for this nested key
|
|
307
|
-
|
|
308
|
-
self.env_var_mapping[
|
|
308
|
+
config_path = ".".join(config_key)
|
|
309
|
+
self.env_var_mapping[config_path] = env_var_name
|
|
309
310
|
elif config_key == "tool_paths":
|
|
310
311
|
# Parse JSON list
|
|
311
312
|
import json
|
|
312
313
|
|
|
313
314
|
try:
|
|
314
|
-
config[config_key] = json.loads(
|
|
315
|
-
self.env_var_mapping[config_key] =
|
|
315
|
+
config[config_key] = json.loads(env_var_value)
|
|
316
|
+
self.env_var_mapping[config_key] = env_var_name
|
|
316
317
|
except json.JSONDecodeError:
|
|
317
|
-
logger.warning(
|
|
318
|
+
logger.warning("Failed to parse TOOL_PATHS as JSON: %s", env_var_value)
|
|
318
319
|
else:
|
|
319
|
-
config[config_key] =
|
|
320
|
-
self.env_var_mapping[config_key] =
|
|
320
|
+
config[config_key] = env_var_value
|
|
321
|
+
self.env_var_mapping[config_key] = env_var_name
|
|
321
322
|
|
|
322
323
|
return config
|
|
323
324
|
|
|
324
|
-
def _get_system_config_paths(self) ->
|
|
325
|
+
def _get_system_config_paths(self) -> list[Path]:
|
|
325
326
|
"""
|
|
326
327
|
Return system-wide config locations (lowest precedence).
|
|
327
328
|
|
|
@@ -336,13 +337,13 @@ class ConfigManager:
|
|
|
336
337
|
Path("/usr/local/etc/tactus/config.yml"),
|
|
337
338
|
]
|
|
338
339
|
|
|
339
|
-
def _get_user_config_paths(self) ->
|
|
340
|
+
def _get_user_config_paths(self) -> list[Path]:
|
|
340
341
|
"""
|
|
341
342
|
Return per-user config locations (lower precedence than project configs).
|
|
342
343
|
|
|
343
344
|
Order is from lower to higher precedence so later configs override earlier ones.
|
|
344
345
|
"""
|
|
345
|
-
paths:
|
|
346
|
+
paths: list[Path] = []
|
|
346
347
|
|
|
347
348
|
xdg_home = os.environ.get("XDG_CONFIG_HOME")
|
|
348
349
|
if xdg_home:
|
|
@@ -355,14 +356,14 @@ class ConfigManager:
|
|
|
355
356
|
|
|
356
357
|
# Deduplicate while preserving order
|
|
357
358
|
seen = set()
|
|
358
|
-
unique:
|
|
359
|
+
unique: list[Path] = []
|
|
359
360
|
for p in paths:
|
|
360
361
|
if p not in seen:
|
|
361
362
|
unique.append(p)
|
|
362
363
|
seen.add(p)
|
|
363
364
|
return unique
|
|
364
365
|
|
|
365
|
-
def _merge_configs(self, configs:
|
|
366
|
+
def _merge_configs(self, configs: list[dict[str, Any]]) -> dict[str, Any]:
|
|
366
367
|
"""
|
|
367
368
|
Deep merge multiple configuration dictionaries.
|
|
368
369
|
|
|
@@ -379,14 +380,14 @@ class ConfigManager:
|
|
|
379
380
|
if not configs:
|
|
380
381
|
return {}
|
|
381
382
|
|
|
382
|
-
|
|
383
|
+
merged_config: dict[str, Any] = {}
|
|
383
384
|
|
|
384
385
|
for config in configs:
|
|
385
|
-
|
|
386
|
+
merged_config = self._deep_merge(merged_config, config)
|
|
386
387
|
|
|
387
|
-
return
|
|
388
|
+
return merged_config
|
|
388
389
|
|
|
389
|
-
def _deep_merge(self, base:
|
|
390
|
+
def _deep_merge(self, base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
390
391
|
"""
|
|
391
392
|
Deep merge two dictionaries.
|
|
392
393
|
|
|
@@ -397,15 +398,15 @@ class ConfigManager:
|
|
|
397
398
|
Returns:
|
|
398
399
|
Merged dictionary
|
|
399
400
|
"""
|
|
400
|
-
|
|
401
|
+
merged_result = deepcopy(base)
|
|
401
402
|
|
|
402
403
|
for key, value in override.items():
|
|
403
|
-
if key in
|
|
404
|
-
base_value =
|
|
404
|
+
if key in merged_result:
|
|
405
|
+
base_value = merged_result[key]
|
|
405
406
|
|
|
406
407
|
# If both are dicts, deep merge
|
|
407
408
|
if isinstance(base_value, dict) and isinstance(value, dict):
|
|
408
|
-
|
|
409
|
+
merged_result[key] = self._deep_merge(base_value, value)
|
|
409
410
|
|
|
410
411
|
# If both are lists, extend (combine)
|
|
411
412
|
elif isinstance(base_value, list) and isinstance(value, list):
|
|
@@ -414,25 +415,25 @@ class ConfigManager:
|
|
|
414
415
|
for item in value:
|
|
415
416
|
if item not in combined:
|
|
416
417
|
combined.append(item)
|
|
417
|
-
|
|
418
|
+
merged_result[key] = combined
|
|
418
419
|
|
|
419
420
|
# Otherwise, override takes precedence
|
|
420
421
|
else:
|
|
421
|
-
|
|
422
|
+
merged_result[key] = deepcopy(value)
|
|
422
423
|
else:
|
|
423
|
-
|
|
424
|
+
merged_result[key] = deepcopy(value)
|
|
424
425
|
|
|
425
|
-
return
|
|
426
|
+
return merged_result
|
|
426
427
|
|
|
427
428
|
def _deep_merge_with_tracking(
|
|
428
429
|
self,
|
|
429
|
-
base:
|
|
430
|
-
override:
|
|
430
|
+
base: dict[str, Any],
|
|
431
|
+
override: dict[str, Any],
|
|
431
432
|
base_source: str,
|
|
432
433
|
override_source: str,
|
|
433
434
|
path_prefix: str = "",
|
|
434
|
-
base_source_map: Optional[
|
|
435
|
-
) ->
|
|
435
|
+
base_source_map: Optional[dict[str, ConfigValue]] = None,
|
|
436
|
+
) -> tuple[dict[str, Any], dict[str, ConfigValue]]:
|
|
436
437
|
"""
|
|
437
438
|
Deep merge with source tracking at every level.
|
|
438
439
|
|
|
@@ -452,8 +453,8 @@ class ConfigManager:
|
|
|
452
453
|
- merged_dict: The merged configuration
|
|
453
454
|
- source_map: Dict mapping paths to ConfigValue objects
|
|
454
455
|
"""
|
|
455
|
-
|
|
456
|
-
source_map:
|
|
456
|
+
merged_result = deepcopy(base)
|
|
457
|
+
source_map: dict[str, ConfigValue] = base_source_map.copy() if base_source_map else {}
|
|
457
458
|
|
|
458
459
|
# Normalize source types
|
|
459
460
|
base_source_type = base_source.split(":")[0] if ":" in base_source else base_source
|
|
@@ -464,14 +465,16 @@ class ConfigManager:
|
|
|
464
465
|
for key, value in override.items():
|
|
465
466
|
current_path = f"{path_prefix}.{key}" if path_prefix else key
|
|
466
467
|
|
|
467
|
-
if key in
|
|
468
|
-
base_value =
|
|
468
|
+
if key in merged_result:
|
|
469
|
+
base_value = merged_result[key]
|
|
469
470
|
|
|
470
471
|
# If both are dicts, deep merge recursively with tracking
|
|
471
472
|
if isinstance(base_value, dict) and isinstance(value, dict):
|
|
472
473
|
# Get nested source map for base
|
|
473
474
|
nested_base_source_map = {
|
|
474
|
-
|
|
475
|
+
key_path: config_value
|
|
476
|
+
for key_path, config_value in source_map.items()
|
|
477
|
+
if key_path.startswith(current_path + ".")
|
|
475
478
|
}
|
|
476
479
|
|
|
477
480
|
# Ensure all base dict values are tracked before merge
|
|
@@ -494,7 +497,7 @@ class ConfigManager:
|
|
|
494
497
|
current_path,
|
|
495
498
|
nested_base_source_map,
|
|
496
499
|
)
|
|
497
|
-
|
|
500
|
+
merged_result[key] = merged_dict
|
|
498
501
|
|
|
499
502
|
# Update source map with nested results
|
|
500
503
|
source_map.update(nested_source_map)
|
|
@@ -505,7 +508,10 @@ class ConfigManager:
|
|
|
505
508
|
override_chain = source_map[current_path].override_chain.copy()
|
|
506
509
|
override_chain.append((override_source, value))
|
|
507
510
|
else:
|
|
508
|
-
override_chain = [
|
|
511
|
+
override_chain = [
|
|
512
|
+
(base_source, base_value),
|
|
513
|
+
(override_source, value),
|
|
514
|
+
]
|
|
509
515
|
|
|
510
516
|
# Get env var name if from environment
|
|
511
517
|
env_var_name = None
|
|
@@ -530,14 +536,17 @@ class ConfigManager:
|
|
|
530
536
|
for item in value:
|
|
531
537
|
if item not in combined:
|
|
532
538
|
combined.append(item)
|
|
533
|
-
|
|
539
|
+
merged_result[key] = combined
|
|
534
540
|
|
|
535
541
|
# Track list override
|
|
536
542
|
if current_path in source_map:
|
|
537
543
|
override_chain = source_map[current_path].override_chain.copy()
|
|
538
544
|
override_chain.append((override_source, value))
|
|
539
545
|
else:
|
|
540
|
-
override_chain = [
|
|
546
|
+
override_chain = [
|
|
547
|
+
(base_source, base_value),
|
|
548
|
+
(override_source, value),
|
|
549
|
+
]
|
|
541
550
|
|
|
542
551
|
# Get env var name if from environment
|
|
543
552
|
env_var_name = None
|
|
@@ -557,14 +566,17 @@ class ConfigManager:
|
|
|
557
566
|
|
|
558
567
|
# Otherwise, override takes precedence
|
|
559
568
|
else:
|
|
560
|
-
|
|
569
|
+
merged_result[key] = deepcopy(value)
|
|
561
570
|
|
|
562
571
|
# Track simple value override
|
|
563
572
|
if current_path in source_map:
|
|
564
573
|
override_chain = source_map[current_path].override_chain.copy()
|
|
565
574
|
override_chain.append((override_source, value))
|
|
566
575
|
else:
|
|
567
|
-
override_chain = [
|
|
576
|
+
override_chain = [
|
|
577
|
+
(base_source, base_value),
|
|
578
|
+
(override_source, value),
|
|
579
|
+
]
|
|
568
580
|
|
|
569
581
|
# Get env var name if from environment
|
|
570
582
|
env_var_name = None
|
|
@@ -583,7 +595,7 @@ class ConfigManager:
|
|
|
583
595
|
)
|
|
584
596
|
else:
|
|
585
597
|
# New key, not an override
|
|
586
|
-
|
|
598
|
+
merged_result[key] = deepcopy(value)
|
|
587
599
|
|
|
588
600
|
# Get env var name if from environment
|
|
589
601
|
env_var_name = None
|
|
@@ -605,10 +617,14 @@ class ConfigManager:
|
|
|
605
617
|
# For nested dicts/lists in new keys, track their children
|
|
606
618
|
if isinstance(value, dict):
|
|
607
619
|
self._track_nested_values(
|
|
608
|
-
value,
|
|
620
|
+
value,
|
|
621
|
+
override_source,
|
|
622
|
+
override_source_type,
|
|
623
|
+
current_path,
|
|
624
|
+
source_map,
|
|
609
625
|
)
|
|
610
626
|
|
|
611
|
-
return
|
|
627
|
+
return merged_result, source_map
|
|
612
628
|
|
|
613
629
|
def _track_nested_values(
|
|
614
630
|
self,
|
|
@@ -616,7 +632,7 @@ class ConfigManager:
|
|
|
616
632
|
source: str,
|
|
617
633
|
source_type: str,
|
|
618
634
|
path_prefix: str,
|
|
619
|
-
source_map:
|
|
635
|
+
source_map: dict[str, ConfigValue],
|
|
620
636
|
overwrite: bool = True,
|
|
621
637
|
) -> None:
|
|
622
638
|
"""
|
|
@@ -641,7 +657,12 @@ class ConfigManager:
|
|
|
641
657
|
# Still recurse for nested structures
|
|
642
658
|
if isinstance(value, (dict, list)):
|
|
643
659
|
self._track_nested_values(
|
|
644
|
-
value,
|
|
660
|
+
value,
|
|
661
|
+
source,
|
|
662
|
+
source_type,
|
|
663
|
+
current_path,
|
|
664
|
+
source_map,
|
|
665
|
+
overwrite,
|
|
645
666
|
)
|
|
646
667
|
continue
|
|
647
668
|
|
|
@@ -666,7 +687,12 @@ class ConfigManager:
|
|
|
666
687
|
)
|
|
667
688
|
if isinstance(value, (dict, list)):
|
|
668
689
|
self._track_nested_values(
|
|
669
|
-
value,
|
|
690
|
+
value,
|
|
691
|
+
source,
|
|
692
|
+
source_type,
|
|
693
|
+
current_path,
|
|
694
|
+
source_map,
|
|
695
|
+
overwrite,
|
|
670
696
|
)
|
|
671
697
|
elif isinstance(obj, list):
|
|
672
698
|
for i, item in enumerate(obj):
|
|
@@ -676,7 +702,12 @@ class ConfigManager:
|
|
|
676
702
|
# Still recurse for nested structures
|
|
677
703
|
if isinstance(item, (dict, list)):
|
|
678
704
|
self._track_nested_values(
|
|
679
|
-
item,
|
|
705
|
+
item,
|
|
706
|
+
source,
|
|
707
|
+
source_type,
|
|
708
|
+
current_path,
|
|
709
|
+
source_map,
|
|
710
|
+
overwrite,
|
|
680
711
|
)
|
|
681
712
|
continue
|
|
682
713
|
|
|
@@ -692,7 +723,12 @@ class ConfigManager:
|
|
|
692
723
|
)
|
|
693
724
|
if isinstance(item, (dict, list)):
|
|
694
725
|
self._track_nested_values(
|
|
695
|
-
item,
|
|
726
|
+
item,
|
|
727
|
+
source,
|
|
728
|
+
source_type,
|
|
729
|
+
current_path,
|
|
730
|
+
source_map,
|
|
731
|
+
overwrite,
|
|
696
732
|
)
|
|
697
733
|
|
|
698
734
|
def _extract_env_var_name(self, source: str) -> Optional[str]:
|
|
@@ -711,7 +747,7 @@ class ConfigManager:
|
|
|
711
747
|
|
|
712
748
|
def load_cascade_with_sources(
|
|
713
749
|
self, procedure_path: Path
|
|
714
|
-
) ->
|
|
750
|
+
) -> tuple[dict[str, Any], dict[str, ConfigValue]]:
|
|
715
751
|
"""
|
|
716
752
|
Load cascade and return both merged config and detailed source map.
|
|
717
753
|
|
|
@@ -729,59 +765,60 @@ class ConfigManager:
|
|
|
729
765
|
- merged_config: Traditional flat merged config (backward compatible)
|
|
730
766
|
- source_map: Dict mapping paths to ConfigValue objects with full metadata
|
|
731
767
|
"""
|
|
732
|
-
|
|
768
|
+
config_sources: list[tuple[str, dict[str, Any]]] = []
|
|
733
769
|
|
|
734
770
|
# 1. System config (lowest precedence)
|
|
735
771
|
for system_path in self._get_system_config_paths():
|
|
736
772
|
if system_path.exists():
|
|
737
773
|
system_config = self._load_yaml_file(system_path)
|
|
738
774
|
if system_config:
|
|
739
|
-
|
|
740
|
-
logger.debug(
|
|
775
|
+
config_sources.append((f"system:{system_path}", system_config))
|
|
776
|
+
logger.debug("Loaded system config: %s", system_path)
|
|
741
777
|
|
|
742
778
|
# 2. User config (~/.tactus/config.yml, XDG, etc.)
|
|
743
779
|
for user_path in self._get_user_config_paths():
|
|
744
780
|
if user_path.exists():
|
|
745
781
|
user_config = self._load_yaml_file(user_path)
|
|
746
782
|
if user_config:
|
|
747
|
-
|
|
748
|
-
logger.debug(
|
|
783
|
+
config_sources.append((f"user:{user_path}", user_config))
|
|
784
|
+
logger.debug("Loaded user config: %s", user_path)
|
|
749
785
|
|
|
750
786
|
# 3. Project config (.tactus/config.yml in cwd)
|
|
751
787
|
root_config_path = Path.cwd() / ".tactus" / "config.yml"
|
|
752
788
|
if root_config_path.exists():
|
|
753
789
|
root_config = self._load_yaml_file(root_config_path)
|
|
754
790
|
if root_config:
|
|
755
|
-
|
|
756
|
-
logger.debug(
|
|
791
|
+
config_sources.append((f"project:{root_config_path}", root_config))
|
|
792
|
+
logger.debug("Loaded root config: %s", root_config_path)
|
|
757
793
|
|
|
758
794
|
# 4. Parent directory configs (walk up from procedure directory)
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
for config_path in
|
|
762
|
-
|
|
763
|
-
if
|
|
764
|
-
|
|
765
|
-
logger.debug(
|
|
795
|
+
procedure_directory = procedure_path.parent.resolve()
|
|
796
|
+
parent_config_paths = self._find_directory_configs(procedure_directory)
|
|
797
|
+
for config_path in parent_config_paths:
|
|
798
|
+
parent_config = self._load_yaml_file(config_path)
|
|
799
|
+
if parent_config:
|
|
800
|
+
config_sources.append((f"parent:{config_path}", parent_config))
|
|
801
|
+
logger.debug("Loaded parent config: %s", config_path)
|
|
766
802
|
|
|
767
803
|
# 5. Local directory config (.tactus/config.yml in procedure's directory)
|
|
768
|
-
local_config_path =
|
|
804
|
+
local_config_path = procedure_directory / ".tactus" / "config.yml"
|
|
769
805
|
if (
|
|
770
806
|
local_config_path.exists()
|
|
771
|
-
and local_config_path not in [root_config_path] +
|
|
807
|
+
and local_config_path not in [root_config_path] + parent_config_paths
|
|
772
808
|
):
|
|
773
809
|
local_config = self._load_yaml_file(local_config_path)
|
|
774
810
|
if local_config:
|
|
775
|
-
|
|
776
|
-
logger.debug(
|
|
811
|
+
config_sources.append((f"local:{local_config_path}", local_config))
|
|
812
|
+
logger.debug("Loaded local config: %s", local_config_path)
|
|
777
813
|
|
|
778
814
|
# 6. Environment variables (override config files)
|
|
779
|
-
|
|
780
|
-
if
|
|
815
|
+
environment_config = self._load_from_environment()
|
|
816
|
+
if environment_config:
|
|
781
817
|
# We use "environment" as source, but individual var names are in self.env_var_mapping
|
|
782
|
-
|
|
818
|
+
config_sources.append(("environment", environment_config))
|
|
783
819
|
logger.debug(
|
|
784
|
-
|
|
820
|
+
"Loaded config from environment variables: %s",
|
|
821
|
+
list(self.env_var_mapping.keys()),
|
|
785
822
|
)
|
|
786
823
|
|
|
787
824
|
# 7. Sidecar config (highest priority, except CLI args)
|
|
@@ -789,29 +826,38 @@ class ConfigManager:
|
|
|
789
826
|
if sidecar_path:
|
|
790
827
|
sidecar_config = self._load_yaml_file(sidecar_path)
|
|
791
828
|
if sidecar_config:
|
|
792
|
-
|
|
793
|
-
logger.info(
|
|
829
|
+
config_sources.append((f"sidecar:{sidecar_path}", sidecar_config))
|
|
830
|
+
logger.info("Loaded sidecar config: %s", sidecar_path)
|
|
794
831
|
|
|
795
832
|
# Store for debugging
|
|
796
|
-
self.loaded_configs =
|
|
833
|
+
self.loaded_configs = config_sources
|
|
797
834
|
|
|
798
835
|
# Merge all configs with source tracking
|
|
799
|
-
if not
|
|
836
|
+
if not config_sources:
|
|
800
837
|
return {}, {}
|
|
801
838
|
|
|
802
839
|
# Start with first config
|
|
803
|
-
|
|
804
|
-
result = deepcopy(
|
|
805
|
-
source_map:
|
|
840
|
+
first_source, first_config = config_sources[0]
|
|
841
|
+
result = deepcopy(first_config)
|
|
842
|
+
source_map: dict[str, ConfigValue] = {}
|
|
806
843
|
|
|
807
844
|
# Track initial values
|
|
808
|
-
self._track_nested_values(
|
|
845
|
+
self._track_nested_values(
|
|
846
|
+
first_config,
|
|
847
|
+
first_source,
|
|
848
|
+
first_source.split(":")[0],
|
|
849
|
+
"",
|
|
850
|
+
source_map,
|
|
851
|
+
)
|
|
809
852
|
|
|
810
853
|
# Merge remaining configs with tracking
|
|
811
|
-
for source, config in
|
|
854
|
+
for source, config in config_sources[1:]:
|
|
812
855
|
result, source_map = self._deep_merge_with_tracking(
|
|
813
856
|
result, config, "merged", source, "", source_map
|
|
814
857
|
)
|
|
815
858
|
|
|
816
|
-
logger.info(
|
|
859
|
+
logger.info(
|
|
860
|
+
"Merged configuration from %s source(s) with full tracking",
|
|
861
|
+
len(config_sources),
|
|
862
|
+
)
|
|
817
863
|
return result, source_map
|