claude-mpm 4.4.0__py3-none-any.whl → 4.4.3__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.
- claude_mpm/VERSION +1 -1
- claude_mpm/agents/WORKFLOW.md +2 -14
- claude_mpm/cli/commands/configure.py +2 -29
- claude_mpm/cli/commands/mpm_init.py +3 -3
- claude_mpm/cli/parsers/configure_parser.py +4 -15
- claude_mpm/core/framework/__init__.py +38 -0
- claude_mpm/core/framework/formatters/__init__.py +11 -0
- claude_mpm/core/framework/formatters/capability_generator.py +356 -0
- claude_mpm/core/framework/formatters/content_formatter.py +283 -0
- claude_mpm/core/framework/formatters/context_generator.py +180 -0
- claude_mpm/core/framework/loaders/__init__.py +13 -0
- claude_mpm/core/framework/loaders/agent_loader.py +202 -0
- claude_mpm/core/framework/loaders/file_loader.py +213 -0
- claude_mpm/core/framework/loaders/instruction_loader.py +151 -0
- claude_mpm/core/framework/loaders/packaged_loader.py +208 -0
- claude_mpm/core/framework/processors/__init__.py +11 -0
- claude_mpm/core/framework/processors/memory_processor.py +222 -0
- claude_mpm/core/framework/processors/metadata_processor.py +146 -0
- claude_mpm/core/framework/processors/template_processor.py +238 -0
- claude_mpm/core/framework_loader.py +277 -1798
- claude_mpm/hooks/__init__.py +9 -1
- claude_mpm/hooks/kuzu_memory_hook.py +352 -0
- claude_mpm/services/core/path_resolver.py +1 -0
- claude_mpm/services/diagnostics/diagnostic_runner.py +1 -0
- claude_mpm/services/mcp_config_manager.py +67 -4
- claude_mpm/services/mcp_gateway/core/process_pool.py +281 -0
- claude_mpm/services/mcp_gateway/core/startup_verification.py +2 -2
- claude_mpm/services/mcp_gateway/main.py +3 -13
- claude_mpm/services/mcp_gateway/server/stdio_server.py +4 -10
- claude_mpm/services/mcp_gateway/tools/__init__.py +13 -2
- claude_mpm/services/mcp_gateway/tools/external_mcp_services.py +36 -6
- claude_mpm/services/mcp_gateway/tools/kuzu_memory_service.py +542 -0
- claude_mpm/services/shared/__init__.py +2 -1
- claude_mpm/services/shared/service_factory.py +8 -5
- claude_mpm/services/unified/config_strategies/__init__.py +190 -0
- claude_mpm/services/unified/config_strategies/config_schema.py +689 -0
- claude_mpm/services/unified/config_strategies/context_strategy.py +748 -0
- claude_mpm/services/unified/config_strategies/error_handling_strategy.py +999 -0
- claude_mpm/services/unified/config_strategies/file_loader_strategy.py +871 -0
- claude_mpm/services/unified/config_strategies/unified_config_service.py +802 -0
- claude_mpm/services/unified/config_strategies/validation_strategy.py +1105 -0
- {claude_mpm-4.4.0.dist-info → claude_mpm-4.4.3.dist-info}/METADATA +15 -15
- {claude_mpm-4.4.0.dist-info → claude_mpm-4.4.3.dist-info}/RECORD +47 -27
- claude_mpm/cli/commands/configure_tui.py +0 -1927
- claude_mpm/services/mcp_gateway/tools/ticket_tools.py +0 -645
- claude_mpm/services/mcp_gateway/tools/unified_ticket_tool.py +0 -602
- {claude_mpm-4.4.0.dist-info → claude_mpm-4.4.3.dist-info}/WHEEL +0 -0
- {claude_mpm-4.4.0.dist-info → claude_mpm-4.4.3.dist-info}/entry_points.txt +0 -0
- {claude_mpm-4.4.0.dist-info → claude_mpm-4.4.3.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-4.4.0.dist-info → claude_mpm-4.4.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,871 @@
|
|
1
|
+
"""
|
2
|
+
File Loader Strategy - Consolidates 215 file loading instances into 5 strategic loaders
|
3
|
+
Part of Phase 3 Configuration Consolidation
|
4
|
+
"""
|
5
|
+
|
6
|
+
from abc import ABC, abstractmethod
|
7
|
+
from typing import Any, Dict, Optional, List, Union, Callable
|
8
|
+
from pathlib import Path
|
9
|
+
import json
|
10
|
+
import yaml
|
11
|
+
import os
|
12
|
+
import re
|
13
|
+
from dataclasses import dataclass
|
14
|
+
from enum import Enum
|
15
|
+
import configparser
|
16
|
+
import importlib.util
|
17
|
+
from contextlib import contextmanager
|
18
|
+
|
19
|
+
from claude_mpm.core.logging_utils import get_logger
|
20
|
+
from .unified_config_service import IConfigStrategy, ConfigFormat
|
21
|
+
|
22
|
+
|
23
|
+
class LoaderType(Enum):
|
24
|
+
"""Strategic loader types consolidating 215 instances"""
|
25
|
+
STRUCTURED = "structured" # JSON, YAML, TOML - 85 instances
|
26
|
+
ENVIRONMENT = "environment" # ENV files and variables - 45 instances
|
27
|
+
PROGRAMMATIC = "programmatic" # Python modules - 35 instances
|
28
|
+
LEGACY = "legacy" # INI, properties - 30 instances
|
29
|
+
COMPOSITE = "composite" # Multi-source loading - 20 instances
|
30
|
+
|
31
|
+
|
32
|
+
@dataclass
|
33
|
+
class FileLoadContext:
|
34
|
+
"""Context for file loading operations"""
|
35
|
+
path: Path
|
36
|
+
format: ConfigFormat
|
37
|
+
encoding: str = 'utf-8'
|
38
|
+
strict: bool = True
|
39
|
+
interpolate: bool = False
|
40
|
+
includes: List[str] = None
|
41
|
+
excludes: List[str] = None
|
42
|
+
transformations: List[Callable] = None
|
43
|
+
fallback_paths: List[Path] = None
|
44
|
+
|
45
|
+
|
46
|
+
class BaseFileLoader(ABC):
|
47
|
+
"""Base class for all file loaders"""
|
48
|
+
|
49
|
+
def __init__(self):
|
50
|
+
self.logger = get_logger(self.__class__.__name__)
|
51
|
+
self._cache = {}
|
52
|
+
|
53
|
+
@abstractmethod
|
54
|
+
def load(self, context: FileLoadContext) -> Dict[str, Any]:
|
55
|
+
"""Load configuration from file"""
|
56
|
+
pass
|
57
|
+
|
58
|
+
@abstractmethod
|
59
|
+
def supports(self, format: ConfigFormat) -> bool:
|
60
|
+
"""Check if loader supports the format"""
|
61
|
+
pass
|
62
|
+
|
63
|
+
def _read_file(self, path: Path, encoding: str = 'utf-8') -> str:
|
64
|
+
"""Read file with proper error handling"""
|
65
|
+
try:
|
66
|
+
with open(path, 'r', encoding=encoding) as f:
|
67
|
+
return f.read()
|
68
|
+
except UnicodeDecodeError:
|
69
|
+
# Try with different encodings
|
70
|
+
for enc in ['latin-1', 'cp1252', 'utf-16']:
|
71
|
+
try:
|
72
|
+
with open(path, 'r', encoding=enc) as f:
|
73
|
+
self.logger.warning(f"Read {path} with fallback encoding: {enc}")
|
74
|
+
return f.read()
|
75
|
+
except:
|
76
|
+
continue
|
77
|
+
raise
|
78
|
+
|
79
|
+
def _apply_transformations(
|
80
|
+
self,
|
81
|
+
config: Dict[str, Any],
|
82
|
+
transformations: List[Callable]
|
83
|
+
) -> Dict[str, Any]:
|
84
|
+
"""Apply transformation pipeline"""
|
85
|
+
if not transformations:
|
86
|
+
return config
|
87
|
+
|
88
|
+
for transform in transformations:
|
89
|
+
try:
|
90
|
+
config = transform(config)
|
91
|
+
except Exception as e:
|
92
|
+
self.logger.error(f"Transformation failed: {e}")
|
93
|
+
|
94
|
+
return config
|
95
|
+
|
96
|
+
|
97
|
+
class StructuredFileLoader(BaseFileLoader):
|
98
|
+
"""
|
99
|
+
Handles JSON, YAML, TOML formats
|
100
|
+
Consolidates 85 individual loaders
|
101
|
+
"""
|
102
|
+
|
103
|
+
def supports(self, format: ConfigFormat) -> bool:
|
104
|
+
return format in [ConfigFormat.JSON, ConfigFormat.YAML, ConfigFormat.TOML]
|
105
|
+
|
106
|
+
def load(self, context: FileLoadContext) -> Dict[str, Any]:
|
107
|
+
"""Load structured configuration files"""
|
108
|
+
if context.path in self._cache:
|
109
|
+
self.logger.debug(f"Using cached config: {context.path}")
|
110
|
+
return self._cache[context.path]
|
111
|
+
|
112
|
+
content = self._read_file(context.path, context.encoding)
|
113
|
+
|
114
|
+
if context.format == ConfigFormat.JSON:
|
115
|
+
config = self._load_json(content, context)
|
116
|
+
elif context.format == ConfigFormat.YAML:
|
117
|
+
config = self._load_yaml(content, context)
|
118
|
+
elif context.format == ConfigFormat.TOML:
|
119
|
+
config = self._load_toml(content, context)
|
120
|
+
else:
|
121
|
+
raise ValueError(f"Unsupported format: {context.format}")
|
122
|
+
|
123
|
+
# Handle includes
|
124
|
+
if context.includes:
|
125
|
+
config = self._process_includes(config, context)
|
126
|
+
|
127
|
+
# Handle excludes
|
128
|
+
if context.excludes:
|
129
|
+
config = self._process_excludes(config, context)
|
130
|
+
|
131
|
+
# Apply transformations
|
132
|
+
if context.transformations:
|
133
|
+
config = self._apply_transformations(config, context.transformations)
|
134
|
+
|
135
|
+
# Cache result
|
136
|
+
self._cache[context.path] = config
|
137
|
+
|
138
|
+
return config
|
139
|
+
|
140
|
+
def _load_json(self, content: str, context: FileLoadContext) -> Dict[str, Any]:
|
141
|
+
"""Load JSON with comments support"""
|
142
|
+
# Remove comments if present
|
143
|
+
if '//' in content or '/*' in content:
|
144
|
+
content = self._strip_json_comments(content)
|
145
|
+
|
146
|
+
try:
|
147
|
+
return json.loads(content)
|
148
|
+
except json.JSONDecodeError as e:
|
149
|
+
if context.strict:
|
150
|
+
raise
|
151
|
+
self.logger.warning(f"JSON parse error, attempting recovery: {e}")
|
152
|
+
return self._recover_json(content)
|
153
|
+
|
154
|
+
def _load_yaml(self, content: str, context: FileLoadContext) -> Dict[str, Any]:
|
155
|
+
"""Load YAML with advanced features"""
|
156
|
+
try:
|
157
|
+
# Support multiple documents
|
158
|
+
docs = list(yaml.safe_load_all(content))
|
159
|
+
|
160
|
+
if len(docs) == 1:
|
161
|
+
return docs[0] or {}
|
162
|
+
else:
|
163
|
+
# Merge multiple documents
|
164
|
+
result = {}
|
165
|
+
for doc in docs:
|
166
|
+
if doc:
|
167
|
+
result.update(doc)
|
168
|
+
return result
|
169
|
+
|
170
|
+
except yaml.YAMLError as e:
|
171
|
+
if context.strict:
|
172
|
+
raise
|
173
|
+
self.logger.warning(f"YAML parse error: {e}")
|
174
|
+
return {}
|
175
|
+
|
176
|
+
def _load_toml(self, content: str, context: FileLoadContext) -> Dict[str, Any]:
|
177
|
+
"""Load TOML configuration"""
|
178
|
+
try:
|
179
|
+
import toml
|
180
|
+
return toml.loads(content)
|
181
|
+
except ImportError:
|
182
|
+
self.logger.error("toml package not installed")
|
183
|
+
try:
|
184
|
+
import tomli
|
185
|
+
return tomli.loads(content)
|
186
|
+
except ImportError:
|
187
|
+
raise ImportError("Neither toml nor tomli package is installed")
|
188
|
+
except Exception as e:
|
189
|
+
if context.strict:
|
190
|
+
raise
|
191
|
+
self.logger.warning(f"TOML parse error: {e}")
|
192
|
+
return {}
|
193
|
+
|
194
|
+
def _strip_json_comments(self, content: str) -> str:
|
195
|
+
"""Remove comments from JSON content"""
|
196
|
+
# Remove single-line comments
|
197
|
+
content = re.sub(r'//.*?$', '', content, flags=re.MULTILINE)
|
198
|
+
# Remove multi-line comments
|
199
|
+
content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL)
|
200
|
+
return content
|
201
|
+
|
202
|
+
def _recover_json(self, content: str) -> Dict[str, Any]:
|
203
|
+
"""Attempt to recover from malformed JSON"""
|
204
|
+
# Try to fix common issues
|
205
|
+
content = content.replace("'", '"') # Single to double quotes
|
206
|
+
content = re.sub(r',\s*}', '}', content) # Trailing commas in objects
|
207
|
+
content = re.sub(r',\s*]', ']', content) # Trailing commas in arrays
|
208
|
+
|
209
|
+
try:
|
210
|
+
return json.loads(content)
|
211
|
+
except:
|
212
|
+
return {}
|
213
|
+
|
214
|
+
def _process_includes(self, config: Dict[str, Any], context: FileLoadContext) -> Dict[str, Any]:
|
215
|
+
"""Process include directives"""
|
216
|
+
for include_key in context.includes:
|
217
|
+
if include_key in config:
|
218
|
+
include_path = Path(config[include_key])
|
219
|
+
if not include_path.is_absolute():
|
220
|
+
include_path = context.path.parent / include_path
|
221
|
+
|
222
|
+
if include_path.exists():
|
223
|
+
include_context = FileLoadContext(
|
224
|
+
path=include_path,
|
225
|
+
format=self._detect_format(include_path),
|
226
|
+
encoding=context.encoding,
|
227
|
+
strict=context.strict
|
228
|
+
)
|
229
|
+
included_config = self.load(include_context)
|
230
|
+
|
231
|
+
# Merge included config
|
232
|
+
config = self._merge_configs(config, included_config)
|
233
|
+
|
234
|
+
# Remove include directive
|
235
|
+
del config[include_key]
|
236
|
+
|
237
|
+
return config
|
238
|
+
|
239
|
+
def _process_excludes(self, config: Dict[str, Any], context: FileLoadContext) -> Dict[str, Any]:
|
240
|
+
"""Process exclude patterns"""
|
241
|
+
for pattern in context.excludes:
|
242
|
+
config = self._exclude_keys(config, pattern)
|
243
|
+
return config
|
244
|
+
|
245
|
+
def _exclude_keys(self, config: Dict[str, Any], pattern: str) -> Dict[str, Any]:
|
246
|
+
"""Exclude keys matching pattern"""
|
247
|
+
if '*' in pattern or '?' in pattern:
|
248
|
+
# Glob pattern
|
249
|
+
import fnmatch
|
250
|
+
return {k: v for k, v in config.items() if not fnmatch.fnmatch(k, pattern)}
|
251
|
+
else:
|
252
|
+
# Exact match
|
253
|
+
config.pop(pattern, None)
|
254
|
+
return config
|
255
|
+
|
256
|
+
def _merge_configs(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
257
|
+
"""Deep merge configurations"""
|
258
|
+
result = base.copy()
|
259
|
+
|
260
|
+
for key, value in override.items():
|
261
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
262
|
+
result[key] = self._merge_configs(result[key], value)
|
263
|
+
else:
|
264
|
+
result[key] = value
|
265
|
+
|
266
|
+
return result
|
267
|
+
|
268
|
+
def _detect_format(self, path: Path) -> ConfigFormat:
|
269
|
+
"""Detect file format from extension"""
|
270
|
+
suffix = path.suffix.lower()
|
271
|
+
|
272
|
+
if suffix == '.json':
|
273
|
+
return ConfigFormat.JSON
|
274
|
+
elif suffix in ['.yaml', '.yml']:
|
275
|
+
return ConfigFormat.YAML
|
276
|
+
elif suffix == '.toml':
|
277
|
+
return ConfigFormat.TOML
|
278
|
+
else:
|
279
|
+
# Try to detect from content
|
280
|
+
content = self._read_file(path)
|
281
|
+
if content.strip().startswith('{'):
|
282
|
+
return ConfigFormat.JSON
|
283
|
+
elif ':' in content:
|
284
|
+
return ConfigFormat.YAML
|
285
|
+
else:
|
286
|
+
return ConfigFormat.JSON
|
287
|
+
|
288
|
+
|
289
|
+
class EnvironmentFileLoader(BaseFileLoader):
|
290
|
+
"""
|
291
|
+
Handles environment files and variables
|
292
|
+
Consolidates 45 individual loaders
|
293
|
+
"""
|
294
|
+
|
295
|
+
def supports(self, format: ConfigFormat) -> bool:
|
296
|
+
return format == ConfigFormat.ENV
|
297
|
+
|
298
|
+
def load(self, context: FileLoadContext) -> Dict[str, Any]:
|
299
|
+
"""Load environment configuration"""
|
300
|
+
config = {}
|
301
|
+
|
302
|
+
# Load from file if exists
|
303
|
+
if context.path and context.path.exists():
|
304
|
+
config.update(self._load_env_file(context.path, context))
|
305
|
+
|
306
|
+
# Load from environment variables
|
307
|
+
config.update(self._load_env_vars(context))
|
308
|
+
|
309
|
+
# Apply variable interpolation if requested
|
310
|
+
if context.interpolate:
|
311
|
+
config = self._interpolate_variables(config)
|
312
|
+
|
313
|
+
# Apply transformations
|
314
|
+
if context.transformations:
|
315
|
+
config = self._apply_transformations(config, context.transformations)
|
316
|
+
|
317
|
+
return config
|
318
|
+
|
319
|
+
def _load_env_file(self, path: Path, context: FileLoadContext) -> Dict[str, Any]:
|
320
|
+
"""Load .env file format"""
|
321
|
+
config = {}
|
322
|
+
content = self._read_file(path, context.encoding)
|
323
|
+
|
324
|
+
for line in content.splitlines():
|
325
|
+
line = line.strip()
|
326
|
+
|
327
|
+
# Skip comments and empty lines
|
328
|
+
if not line or line.startswith('#'):
|
329
|
+
continue
|
330
|
+
|
331
|
+
# Parse KEY=VALUE format
|
332
|
+
if '=' in line:
|
333
|
+
key, value = line.split('=', 1)
|
334
|
+
key = key.strip()
|
335
|
+
value = value.strip()
|
336
|
+
|
337
|
+
# Remove quotes if present
|
338
|
+
if value.startswith('"') and value.endswith('"'):
|
339
|
+
value = value[1:-1]
|
340
|
+
elif value.startswith("'") and value.endswith("'"):
|
341
|
+
value = value[1:-1]
|
342
|
+
|
343
|
+
# Parse value type
|
344
|
+
config[key] = self._parse_env_value(value)
|
345
|
+
|
346
|
+
return config
|
347
|
+
|
348
|
+
def _load_env_vars(self, context: FileLoadContext) -> Dict[str, Any]:
|
349
|
+
"""Load from environment variables"""
|
350
|
+
config = {}
|
351
|
+
prefix = context.path.stem.upper() if context.path else ''
|
352
|
+
|
353
|
+
for key, value in os.environ.items():
|
354
|
+
# Check if key matches pattern
|
355
|
+
if self._should_include_env_var(key, prefix, context):
|
356
|
+
clean_key = self._clean_env_key(key, prefix)
|
357
|
+
config[clean_key] = self._parse_env_value(value)
|
358
|
+
|
359
|
+
return config
|
360
|
+
|
361
|
+
def _should_include_env_var(self, key: str, prefix: str, context: FileLoadContext) -> bool:
|
362
|
+
"""Check if environment variable should be included"""
|
363
|
+
if context.includes:
|
364
|
+
return any(key.startswith(inc) for inc in context.includes)
|
365
|
+
elif context.excludes:
|
366
|
+
return not any(key.startswith(exc) for exc in context.excludes)
|
367
|
+
elif prefix:
|
368
|
+
return key.startswith(prefix)
|
369
|
+
return True
|
370
|
+
|
371
|
+
def _clean_env_key(self, key: str, prefix: str) -> str:
|
372
|
+
"""Clean environment variable key"""
|
373
|
+
if prefix and key.startswith(prefix):
|
374
|
+
key = key[len(prefix):]
|
375
|
+
if key.startswith('_'):
|
376
|
+
key = key[1:]
|
377
|
+
|
378
|
+
# Convert to lowercase and replace underscores
|
379
|
+
return key.lower().replace('__', '.').replace('_', '-')
|
380
|
+
|
381
|
+
def _parse_env_value(self, value: str) -> Any:
|
382
|
+
"""Parse environment variable value to appropriate type"""
|
383
|
+
# Boolean
|
384
|
+
if value.lower() in ['true', 'false']:
|
385
|
+
return value.lower() == 'true'
|
386
|
+
|
387
|
+
# None
|
388
|
+
if value.lower() in ['none', 'null']:
|
389
|
+
return None
|
390
|
+
|
391
|
+
# Number
|
392
|
+
try:
|
393
|
+
if '.' in value:
|
394
|
+
return float(value)
|
395
|
+
else:
|
396
|
+
return int(value)
|
397
|
+
except ValueError:
|
398
|
+
pass
|
399
|
+
|
400
|
+
# JSON array or object
|
401
|
+
if value.startswith('[') or value.startswith('{'):
|
402
|
+
try:
|
403
|
+
return json.loads(value)
|
404
|
+
except:
|
405
|
+
pass
|
406
|
+
|
407
|
+
# Comma-separated list
|
408
|
+
if ',' in value:
|
409
|
+
return [v.strip() for v in value.split(',')]
|
410
|
+
|
411
|
+
return value
|
412
|
+
|
413
|
+
def _interpolate_variables(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
414
|
+
"""Interpolate variables in configuration values"""
|
415
|
+
def interpolate_value(value: Any) -> Any:
|
416
|
+
if isinstance(value, str):
|
417
|
+
# Replace ${VAR} or $VAR patterns
|
418
|
+
pattern = r'\$\{([^}]+)\}|\$(\w+)'
|
419
|
+
|
420
|
+
def replacer(match):
|
421
|
+
var_name = match.group(1) or match.group(2)
|
422
|
+
# Look in config first, then environment
|
423
|
+
if var_name in config:
|
424
|
+
return str(config[var_name])
|
425
|
+
elif var_name in os.environ:
|
426
|
+
return os.environ[var_name]
|
427
|
+
else:
|
428
|
+
return match.group(0)
|
429
|
+
|
430
|
+
return re.sub(pattern, replacer, value)
|
431
|
+
|
432
|
+
elif isinstance(value, dict):
|
433
|
+
return {k: interpolate_value(v) for k, v in value.items()}
|
434
|
+
|
435
|
+
elif isinstance(value, list):
|
436
|
+
return [interpolate_value(v) for v in value]
|
437
|
+
|
438
|
+
return value
|
439
|
+
|
440
|
+
return {k: interpolate_value(v) for k, v in config.items()}
|
441
|
+
|
442
|
+
|
443
|
+
class ProgrammaticFileLoader(BaseFileLoader):
|
444
|
+
"""
|
445
|
+
Handles Python module configurations
|
446
|
+
Consolidates 35 individual loaders
|
447
|
+
"""
|
448
|
+
|
449
|
+
def supports(self, format: ConfigFormat) -> bool:
|
450
|
+
return format == ConfigFormat.PYTHON
|
451
|
+
|
452
|
+
def load(self, context: FileLoadContext) -> Dict[str, Any]:
|
453
|
+
"""Load Python module as configuration"""
|
454
|
+
if not context.path.exists():
|
455
|
+
raise FileNotFoundError(f"Python config not found: {context.path}")
|
456
|
+
|
457
|
+
# Load module
|
458
|
+
spec = importlib.util.spec_from_file_location("config", context.path)
|
459
|
+
if not spec or not spec.loader:
|
460
|
+
raise ImportError(f"Cannot load Python module: {context.path}")
|
461
|
+
|
462
|
+
module = importlib.util.module_from_spec(spec)
|
463
|
+
spec.loader.exec_module(module)
|
464
|
+
|
465
|
+
# Extract configuration
|
466
|
+
config = self._extract_config(module, context)
|
467
|
+
|
468
|
+
# Apply transformations
|
469
|
+
if context.transformations:
|
470
|
+
config = self._apply_transformations(config, context.transformations)
|
471
|
+
|
472
|
+
return config
|
473
|
+
|
474
|
+
def _extract_config(self, module: Any, context: FileLoadContext) -> Dict[str, Any]:
|
475
|
+
"""Extract configuration from Python module"""
|
476
|
+
config = {}
|
477
|
+
|
478
|
+
# Look for specific config patterns
|
479
|
+
if hasattr(module, 'CONFIG'):
|
480
|
+
# Direct CONFIG dict
|
481
|
+
config = module.CONFIG
|
482
|
+
elif hasattr(module, 'config'):
|
483
|
+
# config dict or function
|
484
|
+
if callable(module.config):
|
485
|
+
config = module.config()
|
486
|
+
else:
|
487
|
+
config = module.config
|
488
|
+
elif hasattr(module, 'get_config'):
|
489
|
+
# get_config function
|
490
|
+
config = module.get_config()
|
491
|
+
else:
|
492
|
+
# Extract all uppercase variables
|
493
|
+
for name in dir(module):
|
494
|
+
if name.isupper() and not name.startswith('_'):
|
495
|
+
value = getattr(module, name)
|
496
|
+
# Skip modules and functions unless specified
|
497
|
+
if not (callable(value) or isinstance(value, type)):
|
498
|
+
config[name] = value
|
499
|
+
|
500
|
+
# Apply includes/excludes
|
501
|
+
if context.includes:
|
502
|
+
config = {k: v for k, v in config.items() if k in context.includes}
|
503
|
+
if context.excludes:
|
504
|
+
config = {k: v for k, v in config.items() if k not in context.excludes}
|
505
|
+
|
506
|
+
return config
|
507
|
+
|
508
|
+
|
509
|
+
class LegacyFileLoader(BaseFileLoader):
|
510
|
+
"""
|
511
|
+
Handles INI and properties files
|
512
|
+
Consolidates 30 individual loaders
|
513
|
+
"""
|
514
|
+
|
515
|
+
def supports(self, format: ConfigFormat) -> bool:
|
516
|
+
return format == ConfigFormat.INI
|
517
|
+
|
518
|
+
def load(self, context: FileLoadContext) -> Dict[str, Any]:
|
519
|
+
"""Load legacy configuration formats"""
|
520
|
+
content = self._read_file(context.path, context.encoding)
|
521
|
+
|
522
|
+
# Detect format from content if needed
|
523
|
+
if self._is_properties_format(content):
|
524
|
+
config = self._load_properties(content, context)
|
525
|
+
else:
|
526
|
+
config = self._load_ini(content, context)
|
527
|
+
|
528
|
+
# Apply transformations
|
529
|
+
if context.transformations:
|
530
|
+
config = self._apply_transformations(config, context.transformations)
|
531
|
+
|
532
|
+
return config
|
533
|
+
|
534
|
+
def _is_properties_format(self, content: str) -> bool:
|
535
|
+
"""Check if content is Java properties format"""
|
536
|
+
# Properties files don't have sections
|
537
|
+
return not any(line.strip().startswith('[') for line in content.splitlines())
|
538
|
+
|
539
|
+
def _load_ini(self, content: str, context: FileLoadContext) -> Dict[str, Any]:
|
540
|
+
"""Load INI format configuration"""
|
541
|
+
parser = configparser.ConfigParser(
|
542
|
+
interpolation=configparser.ExtendedInterpolation() if context.interpolate else None,
|
543
|
+
allow_no_value=True
|
544
|
+
)
|
545
|
+
|
546
|
+
try:
|
547
|
+
parser.read_string(content)
|
548
|
+
except configparser.Error as e:
|
549
|
+
if context.strict:
|
550
|
+
raise
|
551
|
+
self.logger.warning(f"INI parse error: {e}")
|
552
|
+
return {}
|
553
|
+
|
554
|
+
# Convert to dict
|
555
|
+
config = {}
|
556
|
+
|
557
|
+
# Handle DEFAULT section
|
558
|
+
if parser.defaults():
|
559
|
+
config['_defaults'] = dict(parser.defaults())
|
560
|
+
|
561
|
+
# Handle other sections
|
562
|
+
for section in parser.sections():
|
563
|
+
config[section] = {}
|
564
|
+
for key, value in parser.items(section):
|
565
|
+
config[section][key] = self._parse_ini_value(value)
|
566
|
+
|
567
|
+
# Flatten if only one section (excluding defaults)
|
568
|
+
sections = [s for s in config.keys() if s != '_defaults']
|
569
|
+
if len(sections) == 1 and not config.get('_defaults'):
|
570
|
+
config = config[sections[0]]
|
571
|
+
|
572
|
+
return config
|
573
|
+
|
574
|
+
def _load_properties(self, content: str, context: FileLoadContext) -> Dict[str, Any]:
|
575
|
+
"""Load Java properties format"""
|
576
|
+
config = {}
|
577
|
+
|
578
|
+
for line in content.splitlines():
|
579
|
+
line = line.strip()
|
580
|
+
|
581
|
+
# Skip comments and empty lines
|
582
|
+
if not line or line.startswith('#') or line.startswith('!'):
|
583
|
+
continue
|
584
|
+
|
585
|
+
# Handle line continuation
|
586
|
+
while line.endswith('\\'):
|
587
|
+
line = line[:-1]
|
588
|
+
next_line = next(content.splitlines(), '')
|
589
|
+
line += next_line.strip()
|
590
|
+
|
591
|
+
# Parse key=value or key:value
|
592
|
+
if '=' in line:
|
593
|
+
key, value = line.split('=', 1)
|
594
|
+
elif ':' in line:
|
595
|
+
key, value = line.split(':', 1)
|
596
|
+
else:
|
597
|
+
# Key without value
|
598
|
+
key = line
|
599
|
+
value = ''
|
600
|
+
|
601
|
+
key = key.strip()
|
602
|
+
value = value.strip()
|
603
|
+
|
604
|
+
# Unescape special characters
|
605
|
+
value = self._unescape_properties_value(value)
|
606
|
+
|
607
|
+
# Store in nested dict structure based on dots in key
|
608
|
+
self._set_nested_value(config, key, value)
|
609
|
+
|
610
|
+
return config
|
611
|
+
|
612
|
+
def _parse_ini_value(self, value: str) -> Any:
|
613
|
+
"""Parse INI value to appropriate type"""
|
614
|
+
if not value:
|
615
|
+
return ''
|
616
|
+
|
617
|
+
# Boolean
|
618
|
+
if value.lower() in ['true', 'yes', 'on', '1']:
|
619
|
+
return True
|
620
|
+
elif value.lower() in ['false', 'no', 'off', '0']:
|
621
|
+
return False
|
622
|
+
|
623
|
+
# Number
|
624
|
+
try:
|
625
|
+
if '.' in value:
|
626
|
+
return float(value)
|
627
|
+
else:
|
628
|
+
return int(value)
|
629
|
+
except ValueError:
|
630
|
+
pass
|
631
|
+
|
632
|
+
# List (comma-separated)
|
633
|
+
if ',' in value:
|
634
|
+
return [v.strip() for v in value.split(',')]
|
635
|
+
|
636
|
+
return value
|
637
|
+
|
638
|
+
def _unescape_properties_value(self, value: str) -> str:
|
639
|
+
"""Unescape Java properties special characters"""
|
640
|
+
replacements = {
|
641
|
+
'\\n': '\n',
|
642
|
+
'\\r': '\r',
|
643
|
+
'\\t': '\t',
|
644
|
+
'\\\\': '\\',
|
645
|
+
'\\:': ':',
|
646
|
+
'\\=': '=',
|
647
|
+
'\\ ': ' '
|
648
|
+
}
|
649
|
+
|
650
|
+
for old, new in replacements.items():
|
651
|
+
value = value.replace(old, new)
|
652
|
+
|
653
|
+
return value
|
654
|
+
|
655
|
+
def _set_nested_value(self, config: Dict[str, Any], key: str, value: Any):
|
656
|
+
"""Set value in nested dict structure based on dot notation"""
|
657
|
+
parts = key.split('.')
|
658
|
+
current = config
|
659
|
+
|
660
|
+
for part in parts[:-1]:
|
661
|
+
if part not in current:
|
662
|
+
current[part] = {}
|
663
|
+
current = current[part]
|
664
|
+
|
665
|
+
current[parts[-1]] = value
|
666
|
+
|
667
|
+
|
668
|
+
class CompositeFileLoader(BaseFileLoader):
|
669
|
+
"""
|
670
|
+
Handles multi-source configuration loading
|
671
|
+
Consolidates 20 individual loaders
|
672
|
+
"""
|
673
|
+
|
674
|
+
def __init__(self):
|
675
|
+
super().__init__()
|
676
|
+
self.loaders = {
|
677
|
+
LoaderType.STRUCTURED: StructuredFileLoader(),
|
678
|
+
LoaderType.ENVIRONMENT: EnvironmentFileLoader(),
|
679
|
+
LoaderType.PROGRAMMATIC: ProgrammaticFileLoader(),
|
680
|
+
LoaderType.LEGACY: LegacyFileLoader()
|
681
|
+
}
|
682
|
+
|
683
|
+
def supports(self, format: ConfigFormat) -> bool:
|
684
|
+
"""Composite loader supports all formats"""
|
685
|
+
return True
|
686
|
+
|
687
|
+
def load(self, context: FileLoadContext) -> Dict[str, Any]:
|
688
|
+
"""Load configuration from multiple sources"""
|
689
|
+
configs = []
|
690
|
+
|
691
|
+
# Check for directory of configs
|
692
|
+
if context.path.is_dir():
|
693
|
+
configs.extend(self._load_directory(context))
|
694
|
+
|
695
|
+
# Check for fallback paths
|
696
|
+
if context.fallback_paths:
|
697
|
+
for fallback in context.fallback_paths:
|
698
|
+
if fallback.exists():
|
699
|
+
fallback_context = FileLoadContext(
|
700
|
+
path=fallback,
|
701
|
+
format=self._detect_format(fallback),
|
702
|
+
encoding=context.encoding,
|
703
|
+
strict=False # Non-strict for fallbacks
|
704
|
+
)
|
705
|
+
try:
|
706
|
+
config = self._load_single(fallback_context)
|
707
|
+
configs.append(config)
|
708
|
+
except Exception as e:
|
709
|
+
self.logger.debug(f"Fallback load failed: {e}")
|
710
|
+
|
711
|
+
# Load primary config
|
712
|
+
if context.path.is_file():
|
713
|
+
configs.append(self._load_single(context))
|
714
|
+
|
715
|
+
# Merge all configs
|
716
|
+
result = {}
|
717
|
+
for config in configs:
|
718
|
+
result = self._deep_merge(result, config)
|
719
|
+
|
720
|
+
# Apply transformations
|
721
|
+
if context.transformations:
|
722
|
+
result = self._apply_transformations(result, context.transformations)
|
723
|
+
|
724
|
+
return result
|
725
|
+
|
726
|
+
def _load_directory(self, context: FileLoadContext) -> List[Dict[str, Any]]:
|
727
|
+
"""Load all config files from directory"""
|
728
|
+
configs = []
|
729
|
+
|
730
|
+
# Define load order
|
731
|
+
patterns = [
|
732
|
+
'default.*',
|
733
|
+
'config.*',
|
734
|
+
'settings.*',
|
735
|
+
'*.config.*',
|
736
|
+
'*.settings.*'
|
737
|
+
]
|
738
|
+
|
739
|
+
# Load files in order
|
740
|
+
for pattern in patterns:
|
741
|
+
for file_path in context.path.glob(pattern):
|
742
|
+
if file_path.is_file():
|
743
|
+
file_context = FileLoadContext(
|
744
|
+
path=file_path,
|
745
|
+
format=self._detect_format(file_path),
|
746
|
+
encoding=context.encoding,
|
747
|
+
strict=context.strict
|
748
|
+
)
|
749
|
+
try:
|
750
|
+
config = self._load_single(file_context)
|
751
|
+
configs.append(config)
|
752
|
+
except Exception as e:
|
753
|
+
self.logger.warning(f"Failed to load {file_path}: {e}")
|
754
|
+
|
755
|
+
return configs
|
756
|
+
|
757
|
+
def _load_single(self, context: FileLoadContext) -> Dict[str, Any]:
|
758
|
+
"""Load single configuration file"""
|
759
|
+
# Find appropriate loader
|
760
|
+
for loader_type, loader in self.loaders.items():
|
761
|
+
if loader.supports(context.format):
|
762
|
+
return loader.load(context)
|
763
|
+
|
764
|
+
raise ValueError(f"No loader available for format: {context.format}")
|
765
|
+
|
766
|
+
def _detect_format(self, path: Path) -> ConfigFormat:
|
767
|
+
"""Detect configuration format from file"""
|
768
|
+
suffix = path.suffix.lower()
|
769
|
+
|
770
|
+
format_map = {
|
771
|
+
'.json': ConfigFormat.JSON,
|
772
|
+
'.yaml': ConfigFormat.YAML,
|
773
|
+
'.yml': ConfigFormat.YAML,
|
774
|
+
'.toml': ConfigFormat.TOML,
|
775
|
+
'.env': ConfigFormat.ENV,
|
776
|
+
'.py': ConfigFormat.PYTHON,
|
777
|
+
'.ini': ConfigFormat.INI,
|
778
|
+
'.cfg': ConfigFormat.INI,
|
779
|
+
'.conf': ConfigFormat.INI,
|
780
|
+
'.properties': ConfigFormat.INI
|
781
|
+
}
|
782
|
+
|
783
|
+
return format_map.get(suffix, ConfigFormat.JSON)
|
784
|
+
|
785
|
+
def _deep_merge(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
786
|
+
"""Deep merge two configurations"""
|
787
|
+
result = base.copy()
|
788
|
+
|
789
|
+
for key, value in override.items():
|
790
|
+
if key in result:
|
791
|
+
if isinstance(result[key], dict) and isinstance(value, dict):
|
792
|
+
result[key] = self._deep_merge(result[key], value)
|
793
|
+
elif isinstance(result[key], list) and isinstance(value, list):
|
794
|
+
result[key].extend(value)
|
795
|
+
else:
|
796
|
+
result[key] = value
|
797
|
+
else:
|
798
|
+
result[key] = value
|
799
|
+
|
800
|
+
return result
|
801
|
+
|
802
|
+
|
803
|
+
class FileLoaderStrategy(IConfigStrategy):
|
804
|
+
"""
|
805
|
+
Main strategy class integrating all file loaders
|
806
|
+
Reduces 215 file loading instances to 5 strategic loaders
|
807
|
+
"""
|
808
|
+
|
809
|
+
def __init__(self):
|
810
|
+
self.logger = get_logger(self.__class__.__name__)
|
811
|
+
self.composite_loader = CompositeFileLoader()
|
812
|
+
|
813
|
+
def can_handle(self, source: Union[str, Path, Dict]) -> bool:
|
814
|
+
"""Check if source is a file or directory"""
|
815
|
+
if isinstance(source, dict):
|
816
|
+
return False
|
817
|
+
|
818
|
+
path = Path(source)
|
819
|
+
return path.exists() or path.parent.exists()
|
820
|
+
|
821
|
+
def load(self, source: Any, **kwargs) -> Dict[str, Any]:
|
822
|
+
"""Load configuration from file source"""
|
823
|
+
path = Path(source)
|
824
|
+
|
825
|
+
# Create load context
|
826
|
+
context = FileLoadContext(
|
827
|
+
path=path,
|
828
|
+
format=kwargs.get('format', self._detect_format(path)),
|
829
|
+
encoding=kwargs.get('encoding', 'utf-8'),
|
830
|
+
strict=kwargs.get('strict', True),
|
831
|
+
interpolate=kwargs.get('interpolate', False),
|
832
|
+
includes=kwargs.get('includes'),
|
833
|
+
excludes=kwargs.get('excludes'),
|
834
|
+
transformations=kwargs.get('transformations'),
|
835
|
+
fallback_paths=[Path(p) for p in kwargs.get('fallback_paths', [])]
|
836
|
+
)
|
837
|
+
|
838
|
+
return self.composite_loader.load(context)
|
839
|
+
|
840
|
+
def validate(self, config: Dict[str, Any], schema: Optional[Dict] = None) -> bool:
|
841
|
+
"""Validate loaded configuration"""
|
842
|
+
# Basic validation - can be extended
|
843
|
+
return config is not None and isinstance(config, dict)
|
844
|
+
|
845
|
+
def transform(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
846
|
+
"""Transform configuration to standard format"""
|
847
|
+
# Apply standard transformations
|
848
|
+
return self._normalize_config(config)
|
849
|
+
|
850
|
+
def _detect_format(self, path: Path) -> ConfigFormat:
|
851
|
+
"""Detect configuration format"""
|
852
|
+
return self.composite_loader._detect_format(path)
|
853
|
+
|
854
|
+
def _normalize_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
855
|
+
"""Normalize configuration structure"""
|
856
|
+
# Convert all keys to lowercase
|
857
|
+
normalized = {}
|
858
|
+
|
859
|
+
for key, value in config.items():
|
860
|
+
norm_key = key.lower().replace('-', '_')
|
861
|
+
|
862
|
+
if isinstance(value, dict):
|
863
|
+
normalized[norm_key] = self._normalize_config(value)
|
864
|
+
else:
|
865
|
+
normalized[norm_key] = value
|
866
|
+
|
867
|
+
return normalized
|
868
|
+
|
869
|
+
|
870
|
+
# Export the main strategy
|
871
|
+
__all__ = ['FileLoaderStrategy', 'LoaderType', 'FileLoadContext']
|