maqet 0.0.1.4__py3-none-any.whl → 0.0.5__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.
- maqet/__init__.py +50 -6
- maqet/__main__.py +96 -0
- maqet/__version__.py +3 -0
- maqet/api/__init__.py +35 -0
- maqet/api/decorators.py +184 -0
- maqet/api/metadata.py +147 -0
- maqet/api/registry.py +182 -0
- maqet/cli.py +71 -0
- maqet/config/__init__.py +26 -0
- maqet/config/merger.py +237 -0
- maqet/config/parser.py +198 -0
- maqet/config/validators.py +519 -0
- maqet/config_handlers.py +684 -0
- maqet/constants.py +200 -0
- maqet/exceptions.py +226 -0
- maqet/formatters.py +294 -0
- maqet/generators/__init__.py +12 -0
- maqet/generators/base_generator.py +101 -0
- maqet/generators/cli_generator.py +635 -0
- maqet/generators/python_generator.py +247 -0
- maqet/generators/rest_generator.py +58 -0
- maqet/handlers/__init__.py +12 -0
- maqet/handlers/base.py +108 -0
- maqet/handlers/init.py +147 -0
- maqet/handlers/stage.py +196 -0
- maqet/ipc/__init__.py +29 -0
- maqet/ipc/retry.py +265 -0
- maqet/ipc/runner_client.py +285 -0
- maqet/ipc/unix_socket_server.py +239 -0
- maqet/logger.py +160 -55
- maqet/machine.py +884 -0
- maqet/managers/__init__.py +7 -0
- maqet/managers/qmp_manager.py +333 -0
- maqet/managers/snapshot_coordinator.py +327 -0
- maqet/managers/vm_manager.py +683 -0
- maqet/maqet.py +1120 -0
- maqet/os_interactions.py +46 -0
- maqet/process_spawner.py +395 -0
- maqet/qemu_args.py +76 -0
- maqet/qmp/__init__.py +10 -0
- maqet/qmp/commands.py +92 -0
- maqet/qmp/keyboard.py +311 -0
- maqet/qmp/qmp.py +17 -0
- maqet/snapshot.py +473 -0
- maqet/state.py +958 -0
- maqet/storage.py +702 -162
- maqet/validation/__init__.py +9 -0
- maqet/validation/config_validator.py +170 -0
- maqet/vm_runner.py +523 -0
- maqet-0.0.5.dist-info/METADATA +237 -0
- maqet-0.0.5.dist-info/RECORD +55 -0
- {maqet-0.0.1.4.dist-info → maqet-0.0.5.dist-info}/WHEEL +1 -1
- maqet-0.0.5.dist-info/entry_points.txt +2 -0
- maqet-0.0.5.dist-info/licenses/LICENSE +21 -0
- {maqet-0.0.1.4.dist-info → maqet-0.0.5.dist-info}/top_level.txt +0 -1
- maqet/core.py +0 -411
- maqet/functions.py +0 -104
- maqet-0.0.1.4.dist-info/METADATA +0 -6
- maqet-0.0.1.4.dist-info/RECORD +0 -33
- qemu/machine/__init__.py +0 -36
- qemu/machine/console_socket.py +0 -142
- qemu/machine/machine.py +0 -954
- qemu/machine/py.typed +0 -0
- qemu/machine/qtest.py +0 -191
- qemu/qmp/__init__.py +0 -59
- qemu/qmp/error.py +0 -50
- qemu/qmp/events.py +0 -717
- qemu/qmp/legacy.py +0 -319
- qemu/qmp/message.py +0 -209
- qemu/qmp/models.py +0 -146
- qemu/qmp/protocol.py +0 -1057
- qemu/qmp/py.typed +0 -0
- qemu/qmp/qmp_client.py +0 -655
- qemu/qmp/qmp_shell.py +0 -618
- qemu/qmp/qmp_tui.py +0 -655
- qemu/qmp/util.py +0 -219
- qemu/utils/__init__.py +0 -162
- qemu/utils/accel.py +0 -84
- qemu/utils/py.typed +0 -0
- qemu/utils/qemu_ga_client.py +0 -323
- qemu/utils/qom.py +0 -273
- qemu/utils/qom_common.py +0 -175
- qemu/utils/qom_fuse.py +0 -207
maqet/api/registry.py
ADDED
@@ -0,0 +1,182 @@
|
|
1
|
+
"""
|
2
|
+
API Registry
|
3
|
+
|
4
|
+
Registry for tracking all decorated API methods and providing
|
5
|
+
lookup capabilities for CLI and Python API generation.
|
6
|
+
|
7
|
+
Supports both global registry (backward compatibility) and instance-based
|
8
|
+
registry (for parallel tests and multiple Maqet instances).
|
9
|
+
"""
|
10
|
+
|
11
|
+
import inspect
|
12
|
+
from typing import Any, Dict, List, Optional
|
13
|
+
|
14
|
+
from .metadata import APIMethodMetadata
|
15
|
+
|
16
|
+
|
17
|
+
class APIRegistry:
|
18
|
+
"""
|
19
|
+
Registry for API methods decorated with @api_method.
|
20
|
+
|
21
|
+
This registry enables generators to discover all available methods
|
22
|
+
and their metadata for creating CLI commands and Python APIs.
|
23
|
+
|
24
|
+
Can be used as:
|
25
|
+
- Global registry (API_REGISTRY) for backward compatibility
|
26
|
+
- Instance registry (one per Maqet instance) for isolated registries
|
27
|
+
"""
|
28
|
+
|
29
|
+
def __init__(self):
|
30
|
+
"""Initialize the API registry with empty collections."""
|
31
|
+
self._methods: Dict[str, APIMethodMetadata] = {}
|
32
|
+
self._cli_commands: Dict[str, str] = {} # cli_name -> full_name
|
33
|
+
self._categories: Dict[str, List[str]] = {} # category -> [full_names]
|
34
|
+
|
35
|
+
def register(self, metadata: APIMethodMetadata) -> None:
|
36
|
+
"""
|
37
|
+
Register a new API method.
|
38
|
+
|
39
|
+
Args:
|
40
|
+
metadata: Method metadata to register
|
41
|
+
"""
|
42
|
+
full_name = metadata.full_name
|
43
|
+
self._methods[full_name] = metadata
|
44
|
+
|
45
|
+
# Register CLI command mapping
|
46
|
+
if metadata.cli_name:
|
47
|
+
self._cli_commands[metadata.cli_name] = full_name
|
48
|
+
|
49
|
+
# Register category mapping
|
50
|
+
if metadata.category not in self._categories:
|
51
|
+
self._categories[metadata.category] = []
|
52
|
+
self._categories[metadata.category].append(full_name)
|
53
|
+
|
54
|
+
def get_method(self, full_name: str) -> Optional[APIMethodMetadata]:
|
55
|
+
"""
|
56
|
+
Get method metadata by full name.
|
57
|
+
|
58
|
+
Args:
|
59
|
+
full_name: Full method name (e.g., 'Maqet.start')
|
60
|
+
|
61
|
+
Returns:
|
62
|
+
Method metadata or None if not found
|
63
|
+
"""
|
64
|
+
return self._methods.get(full_name)
|
65
|
+
|
66
|
+
def get_by_cli_name(self, cli_name: str) -> Optional[APIMethodMetadata]:
|
67
|
+
"""
|
68
|
+
Get method metadata by CLI command name.
|
69
|
+
|
70
|
+
Args:
|
71
|
+
cli_name: CLI command name (e.g., 'start')
|
72
|
+
|
73
|
+
Returns:
|
74
|
+
Method metadata or None if not found
|
75
|
+
"""
|
76
|
+
full_name = self._cli_commands.get(cli_name)
|
77
|
+
return self._methods.get(full_name) if full_name else None
|
78
|
+
|
79
|
+
def get_by_category(self, category: str) -> List[APIMethodMetadata]:
|
80
|
+
"""
|
81
|
+
Get all methods in a category.
|
82
|
+
|
83
|
+
Args:
|
84
|
+
category: Category name (e.g., 'vm', 'qmp')
|
85
|
+
|
86
|
+
Returns:
|
87
|
+
List of method metadata in the category
|
88
|
+
"""
|
89
|
+
full_names = self._categories.get(category, [])
|
90
|
+
return [self._methods[name] for name in full_names]
|
91
|
+
|
92
|
+
def get_all_methods(self) -> List[APIMethodMetadata]:
|
93
|
+
"""
|
94
|
+
Get all registered methods.
|
95
|
+
|
96
|
+
Returns:
|
97
|
+
List of all method metadata
|
98
|
+
"""
|
99
|
+
return list(self._methods.values())
|
100
|
+
|
101
|
+
def get_all_methods_dict(self) -> Dict[str, APIMethodMetadata]:
|
102
|
+
"""
|
103
|
+
Get all registered methods as a dictionary.
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
Dictionary mapping method names to metadata
|
107
|
+
"""
|
108
|
+
return self._methods.copy()
|
109
|
+
|
110
|
+
def get_all_cli_commands(self) -> Dict[str, APIMethodMetadata]:
|
111
|
+
"""
|
112
|
+
Get all CLI commands and their metadata.
|
113
|
+
|
114
|
+
Returns:
|
115
|
+
Dictionary mapping CLI command names to metadata
|
116
|
+
"""
|
117
|
+
return {
|
118
|
+
cli_name: self._methods[full_name]
|
119
|
+
for cli_name, full_name in self._cli_commands.items()
|
120
|
+
}
|
121
|
+
|
122
|
+
def get_categories(self) -> List[str]:
|
123
|
+
"""
|
124
|
+
Get all available categories.
|
125
|
+
|
126
|
+
Returns:
|
127
|
+
List of category names
|
128
|
+
"""
|
129
|
+
return list(self._categories.keys())
|
130
|
+
|
131
|
+
def clear(self) -> None:
|
132
|
+
"""Clear all registered methods (mainly for testing)."""
|
133
|
+
self._methods.clear()
|
134
|
+
self._cli_commands.clear()
|
135
|
+
self._categories.clear()
|
136
|
+
|
137
|
+
def register_from_instance(self, instance: Any) -> None:
|
138
|
+
"""
|
139
|
+
Register all @api_method decorated methods from an instance.
|
140
|
+
|
141
|
+
This enables instance-based registries where each Maqet instance
|
142
|
+
has its own registry, allowing for:
|
143
|
+
- Parallel test execution without registry pollution
|
144
|
+
- Multiple Maqet instances with different configurations
|
145
|
+
- Thread-safe operation
|
146
|
+
|
147
|
+
Args:
|
148
|
+
instance: Object instance to scan for @api_method decorated methods
|
149
|
+
|
150
|
+
Example:
|
151
|
+
registry = APIRegistry()
|
152
|
+
maqet = Maqet()
|
153
|
+
registry.register_from_instance(maqet)
|
154
|
+
"""
|
155
|
+
# Get the class of the instance
|
156
|
+
cls = instance.__class__
|
157
|
+
|
158
|
+
# Scan for decorated methods
|
159
|
+
for name, method in inspect.getmembers(cls, predicate=inspect.isfunction):
|
160
|
+
if hasattr(method, "_api_metadata"):
|
161
|
+
metadata: APIMethodMetadata = method._api_metadata
|
162
|
+
# Clone metadata with bound method (instance-specific)
|
163
|
+
bound_metadata = APIMethodMetadata(
|
164
|
+
name=metadata.name,
|
165
|
+
function=getattr(instance, name), # Bound method
|
166
|
+
owner_class=metadata.owner_class,
|
167
|
+
cli_name=metadata.cli_name,
|
168
|
+
description=metadata.description,
|
169
|
+
signature=metadata.signature,
|
170
|
+
category=metadata.category,
|
171
|
+
requires_vm=metadata.requires_vm,
|
172
|
+
examples=metadata.examples,
|
173
|
+
aliases=metadata.aliases,
|
174
|
+
hidden=metadata.hidden,
|
175
|
+
parent=metadata.parent,
|
176
|
+
)
|
177
|
+
self.register(bound_metadata)
|
178
|
+
|
179
|
+
|
180
|
+
# Global registry instance (for backward compatibility)
|
181
|
+
# New code should prefer instance-based registries via register_from_instance()
|
182
|
+
API_REGISTRY = APIRegistry()
|
maqet/cli.py
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
import argparse
|
2
|
+
import os
|
3
|
+
from pathlib import Path
|
4
|
+
|
5
|
+
from benedict import benedict
|
6
|
+
|
7
|
+
from maqet.logger import LOG, configure_file_logging
|
8
|
+
from maqet.maqet import Maqet
|
9
|
+
|
10
|
+
|
11
|
+
def cli():
|
12
|
+
LOG.debug("Maqet CLI started")
|
13
|
+
parser = argparse.ArgumentParser(
|
14
|
+
prog="MAQET - m4x0n QEMU Tool",
|
15
|
+
description="Using YAML file for running QEMU VM and automate actions",
|
16
|
+
epilog="Still in development"
|
17
|
+
)
|
18
|
+
|
19
|
+
parser.add_argument(
|
20
|
+
"-f", "--file",
|
21
|
+
type=Path,
|
22
|
+
help="yaml with config" # TODO: Write more
|
23
|
+
)
|
24
|
+
parser.add_argument(
|
25
|
+
"stages",
|
26
|
+
nargs="*",
|
27
|
+
help="stages to run. If not stated - just start VM",
|
28
|
+
)
|
29
|
+
parser.add_argument(
|
30
|
+
"-v", "--verbose",
|
31
|
+
action='count',
|
32
|
+
help="increase verbose level",
|
33
|
+
default=0
|
34
|
+
)
|
35
|
+
parser.add_argument(
|
36
|
+
"-a", "--argument",
|
37
|
+
nargs="*",
|
38
|
+
help="Plain arguments to pass to qemu,"
|
39
|
+
"use with quotes: -a '-arg_a ... -arg_z' "
|
40
|
+
)
|
41
|
+
|
42
|
+
|
43
|
+
cli_args = parser.parse_args()
|
44
|
+
|
45
|
+
# Set console handler level based on verbosity, but leave file handler at DEBUG
|
46
|
+
console_handler = LOG.handlers[0]
|
47
|
+
console_handler.setLevel(50 - cli_args.verbose * 10 if cli_args.verbose < 5 else 10)
|
48
|
+
|
49
|
+
if cli_args.file is not None:
|
50
|
+
os.chdir(cli_args.file.parent)
|
51
|
+
raw_config = benedict.from_yaml(cli_args.file.name)
|
52
|
+
# NOTE: should rise Exception if yaml incorrect
|
53
|
+
else:
|
54
|
+
raw_config = benedict({})
|
55
|
+
|
56
|
+
if 'log_file' in raw_config:
|
57
|
+
configure_file_logging(raw_config['log_file'])
|
58
|
+
|
59
|
+
raw_config.plain_arguments = cli_args.argument
|
60
|
+
|
61
|
+
LOG.debug(f"CLI Arguments: {cli_args}")
|
62
|
+
|
63
|
+
maqet = Maqet(*cli_args.stages, **raw_config)
|
64
|
+
LOG.debug(f"Maqet initialized, stages to run: {cli_args.stages}")
|
65
|
+
maqet()
|
66
|
+
LOG.debug("Maqet finished")
|
67
|
+
|
68
|
+
|
69
|
+
def main():
|
70
|
+
"""Entry point for the maqet command."""
|
71
|
+
cli()
|
maqet/config/__init__.py
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
"""
|
2
|
+
MAQET Configuration Module
|
3
|
+
|
4
|
+
Dynamic configuration parsing and validation system using decorators.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from .merger import ConfigError, ConfigMerger
|
8
|
+
from .parser import ConfigParser
|
9
|
+
from .validators import (
|
10
|
+
ConfigValidationError,
|
11
|
+
config_validator,
|
12
|
+
get_required_keys,
|
13
|
+
get_validators,
|
14
|
+
validate_config_data,
|
15
|
+
)
|
16
|
+
|
17
|
+
__all__ = [
|
18
|
+
"ConfigError",
|
19
|
+
"ConfigMerger",
|
20
|
+
"ConfigParser",
|
21
|
+
"ConfigValidationError",
|
22
|
+
"config_validator",
|
23
|
+
"get_validators",
|
24
|
+
"get_required_keys",
|
25
|
+
"validate_config_data",
|
26
|
+
]
|
maqet/config/merger.py
ADDED
@@ -0,0 +1,237 @@
|
|
1
|
+
"""
|
2
|
+
Configuration Merger
|
3
|
+
|
4
|
+
Handles deep-merging of multiple configuration files.
|
5
|
+
Provides utilities for loading and merging YAML configuration files
|
6
|
+
with deep merge support for complex nested structures.
|
7
|
+
"""
|
8
|
+
|
9
|
+
from pathlib import Path
|
10
|
+
from typing import Any, Dict, List, Union
|
11
|
+
|
12
|
+
import yaml
|
13
|
+
|
14
|
+
|
15
|
+
class ConfigError(Exception):
|
16
|
+
"""Configuration parsing errors"""
|
17
|
+
|
18
|
+
|
19
|
+
class ConfigMerger:
|
20
|
+
"""
|
21
|
+
Handles deep-merging of multiple configuration files.
|
22
|
+
|
23
|
+
Provides utilities for loading and merging YAML configuration files
|
24
|
+
with deep merge support for complex nested structures.
|
25
|
+
"""
|
26
|
+
|
27
|
+
@staticmethod
|
28
|
+
def _merge_arguments_list(
|
29
|
+
base_args: List[Any], override_args: List[Any]
|
30
|
+
) -> List[Any]:
|
31
|
+
"""
|
32
|
+
Merge two arguments lists with override behavior.
|
33
|
+
|
34
|
+
For arguments like [{foo: 0}, {bar: 10}] and [{bar: 20}, {baz: 30}],
|
35
|
+
later configs override earlier ones by key, producing:
|
36
|
+
[{foo: 0}, {bar: 20}, {baz: 30}]
|
37
|
+
|
38
|
+
Args:
|
39
|
+
base_args: Base arguments list
|
40
|
+
override_args: Override arguments list
|
41
|
+
|
42
|
+
Returns:
|
43
|
+
Merged arguments list with overrides applied
|
44
|
+
"""
|
45
|
+
# Track arguments by their keys (for dict items)
|
46
|
+
# Use dict to maintain insertion order (Python 3.7+)
|
47
|
+
merged = {}
|
48
|
+
|
49
|
+
# Process base arguments first
|
50
|
+
for arg in base_args:
|
51
|
+
if isinstance(arg, dict):
|
52
|
+
# For dict items, use the key as identifier
|
53
|
+
for key in arg.keys():
|
54
|
+
merged[key] = arg
|
55
|
+
else:
|
56
|
+
# For non-dict items (strings), use the item itself as key
|
57
|
+
merged[str(arg)] = arg
|
58
|
+
|
59
|
+
# Process override arguments (later configs win)
|
60
|
+
for arg in override_args:
|
61
|
+
if isinstance(arg, dict):
|
62
|
+
# Override or add dict items by key
|
63
|
+
for key in arg.keys():
|
64
|
+
merged[key] = arg
|
65
|
+
else:
|
66
|
+
# Override or add non-dict items
|
67
|
+
merged[str(arg)] = arg
|
68
|
+
|
69
|
+
# Convert back to list, preserving order
|
70
|
+
return list(merged.values())
|
71
|
+
|
72
|
+
@staticmethod
|
73
|
+
def deep_merge(
|
74
|
+
base: Dict[str, Any], override: Dict[str, Any]
|
75
|
+
) -> Dict[str, Any]:
|
76
|
+
"""
|
77
|
+
Deep merge two dictionaries.
|
78
|
+
|
79
|
+
Args:
|
80
|
+
base: Base configuration dictionary
|
81
|
+
override: Override configuration dictionary
|
82
|
+
|
83
|
+
Returns:
|
84
|
+
Deep-merged configuration dictionary
|
85
|
+
"""
|
86
|
+
result = base.copy()
|
87
|
+
|
88
|
+
for key, value in override.items():
|
89
|
+
if (
|
90
|
+
key in result
|
91
|
+
and isinstance(result[key], dict)
|
92
|
+
and isinstance(value, dict)
|
93
|
+
):
|
94
|
+
# Recursively merge nested dictionaries
|
95
|
+
result[key] = ConfigMerger.deep_merge(result[key], value)
|
96
|
+
elif (
|
97
|
+
key in result
|
98
|
+
and isinstance(result[key], list)
|
99
|
+
and isinstance(value, list)
|
100
|
+
):
|
101
|
+
# Special handling for 'arguments' list: merge dict items by key
|
102
|
+
if key == "arguments":
|
103
|
+
result[key] = ConfigMerger._merge_arguments_list(
|
104
|
+
result[key], value
|
105
|
+
)
|
106
|
+
else:
|
107
|
+
# For other lists (storage, etc.), concatenate
|
108
|
+
# This allows adding storage devices, network interfaces, etc.
|
109
|
+
result[key] = result[key] + value
|
110
|
+
else:
|
111
|
+
# Override scalar values and non-dict types
|
112
|
+
result[key] = value
|
113
|
+
|
114
|
+
return result
|
115
|
+
|
116
|
+
@staticmethod
|
117
|
+
def _resolve_relative_paths(
|
118
|
+
config_data: Dict[str, Any], config_dir: Path
|
119
|
+
) -> Dict[str, Any]:
|
120
|
+
"""
|
121
|
+
Resolve relative file paths in config to absolute paths.
|
122
|
+
|
123
|
+
Layer 3 of automatic path resolution: resolve paths relative to config file location.
|
124
|
+
This makes configs portable and allows relative paths like ./live.iso to work.
|
125
|
+
|
126
|
+
Args:
|
127
|
+
config_data: Configuration dictionary
|
128
|
+
config_dir: Directory containing the config file
|
129
|
+
|
130
|
+
Returns:
|
131
|
+
Configuration with resolved absolute paths
|
132
|
+
"""
|
133
|
+
# Resolve storage device file paths
|
134
|
+
if "storage" in config_data and isinstance(
|
135
|
+
config_data["storage"], list
|
136
|
+
):
|
137
|
+
for storage_item in config_data["storage"]:
|
138
|
+
if isinstance(storage_item, dict) and "file" in storage_item:
|
139
|
+
file_path = storage_item["file"]
|
140
|
+
if not Path(file_path).is_absolute():
|
141
|
+
# Resolve relative to config file directory
|
142
|
+
storage_item["file"] = str(
|
143
|
+
(config_dir / file_path).resolve()
|
144
|
+
)
|
145
|
+
|
146
|
+
# Resolve VirtFS path entries
|
147
|
+
if isinstance(storage_item, dict) and "path" in storage_item:
|
148
|
+
path_value = storage_item["path"]
|
149
|
+
if not Path(path_value).is_absolute():
|
150
|
+
storage_item["path"] = str(
|
151
|
+
(config_dir / path_value).resolve()
|
152
|
+
)
|
153
|
+
|
154
|
+
# Resolve file paths in arguments (drive file=./path,...)
|
155
|
+
if "arguments" in config_data and isinstance(
|
156
|
+
config_data["arguments"], list
|
157
|
+
):
|
158
|
+
for arg_item in config_data["arguments"]:
|
159
|
+
if isinstance(arg_item, dict):
|
160
|
+
for key, value in arg_item.items():
|
161
|
+
if isinstance(value, str) and "file=" in value:
|
162
|
+
# Parse drive argument: file=./live.iso,media=cdrom
|
163
|
+
parts = value.split(",")
|
164
|
+
for i, part in enumerate(parts):
|
165
|
+
if part.startswith("file="):
|
166
|
+
file_path = part[
|
167
|
+
5:
|
168
|
+
] # Remove 'file=' prefix
|
169
|
+
if not Path(file_path).is_absolute():
|
170
|
+
abs_path = str(
|
171
|
+
(config_dir / file_path).resolve()
|
172
|
+
)
|
173
|
+
parts[i] = f"file={abs_path}"
|
174
|
+
arg_item[key] = ",".join(parts)
|
175
|
+
|
176
|
+
return config_data
|
177
|
+
|
178
|
+
@staticmethod
|
179
|
+
def load_and_merge_files(
|
180
|
+
config_files: Union[str, List[str]],
|
181
|
+
) -> Dict[str, Any]:
|
182
|
+
"""
|
183
|
+
Load and merge multiple configuration files.
|
184
|
+
|
185
|
+
Args:
|
186
|
+
config_files: Single config file path or list of config file paths
|
187
|
+
|
188
|
+
Returns:
|
189
|
+
Merged configuration data
|
190
|
+
|
191
|
+
Raises:
|
192
|
+
ConfigError: If any config file cannot be loaded or parsed
|
193
|
+
"""
|
194
|
+
if isinstance(config_files, str):
|
195
|
+
config_files = [config_files]
|
196
|
+
|
197
|
+
if not config_files:
|
198
|
+
return {}
|
199
|
+
|
200
|
+
merged_config = {}
|
201
|
+
|
202
|
+
for config_file in config_files:
|
203
|
+
config_path = Path(config_file)
|
204
|
+
if not config_path.exists():
|
205
|
+
raise ConfigError(
|
206
|
+
f"Configuration file not found: {config_file}"
|
207
|
+
)
|
208
|
+
|
209
|
+
try:
|
210
|
+
with open(config_path) as f:
|
211
|
+
config_data = yaml.safe_load(f) or {}
|
212
|
+
|
213
|
+
if not isinstance(config_data, dict):
|
214
|
+
raise ConfigError(
|
215
|
+
f"Configuration in {config_file} must be a "
|
216
|
+
f"YAML dictionary"
|
217
|
+
)
|
218
|
+
|
219
|
+
# Layer 3: Resolve relative paths in config relative to config file location
|
220
|
+
config_dir = config_path.parent.resolve()
|
221
|
+
config_data = ConfigMerger._resolve_relative_paths(
|
222
|
+
config_data, config_dir
|
223
|
+
)
|
224
|
+
|
225
|
+
# Deep merge with previous configs
|
226
|
+
merged_config = ConfigMerger.deep_merge(
|
227
|
+
merged_config, config_data
|
228
|
+
)
|
229
|
+
|
230
|
+
except yaml.YAMLError as e:
|
231
|
+
raise ConfigError(f"Invalid YAML in {config_file}: {e}")
|
232
|
+
except Exception as e:
|
233
|
+
raise ConfigError(
|
234
|
+
f"Error loading configuration from {config_file}: {e}"
|
235
|
+
)
|
236
|
+
|
237
|
+
return merged_config
|