Coverage for fastblocks/mcp/configuration.py: 0%
318 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
1"""Advanced adapter configuration management for FastBlocks MCP system."""
3import json
4import os
5from contextlib import suppress
6from dataclasses import dataclass, field
7from datetime import datetime
8from enum import Enum
9from pathlib import Path
10from typing import Any
11from uuid import uuid4
13import yaml
14from pydantic import BaseModel, Field, ValidationError, validator
16from .discovery import AdapterInfo
17from .registry import AdapterRegistry
20class ConfigurationProfile(str, Enum):
21 """Configuration deployment profiles."""
23 DEVELOPMENT = "development"
24 STAGING = "staging"
25 PRODUCTION = "production"
28class ConfigurationStatus(str, Enum):
29 """Configuration validation status."""
31 VALID = "valid"
32 WARNING = "warning"
33 ERROR = "error"
34 UNKNOWN = "unknown"
37@dataclass
38class EnvironmentVariable:
39 """Environment variable configuration."""
41 name: str
42 value: str | None = None
43 required: bool = True
44 description: str = ""
45 secret: bool = False
46 default: str | None = None
47 validator_pattern: str | None = None
50@dataclass
51class AdapterConfiguration:
52 """Individual adapter configuration."""
54 name: str
55 enabled: bool = True
56 settings: dict[str, Any] = field(default_factory=dict)
57 environment_variables: list[EnvironmentVariable] = field(default_factory=list)
58 dependencies: set[str] = field(default_factory=set)
59 profile_overrides: dict[ConfigurationProfile, dict[str, Any]] = field(
60 default_factory=dict
61 )
62 health_check_config: dict[str, Any] = field(default_factory=dict)
63 metadata: dict[str, Any] = field(default_factory=dict)
66class ConfigurationSchema(BaseModel):
67 """Pydantic schema for configuration validation."""
69 version: str = "1.0"
70 profile: ConfigurationProfile = ConfigurationProfile.DEVELOPMENT
71 created_at: datetime = Field(default_factory=datetime.now)
72 updated_at: datetime = Field(default_factory=datetime.now)
73 adapters: dict[str, AdapterConfiguration] = Field(default_factory=dict)
74 global_settings: dict[str, Any] = Field(default_factory=dict)
75 global_environment: list[EnvironmentVariable] = Field(default_factory=list)
77 @validator("adapters", pre=True)
78 def validate_adapters(cls, v: Any) -> dict[str, AdapterConfiguration]:
79 if isinstance(v, dict):
80 # Convert dict values to AdapterConfiguration objects if needed
81 result: dict[str, AdapterConfiguration] = {}
82 for key, value in v.items():
83 if isinstance(value, dict):
84 result[key] = AdapterConfiguration(name=key, **value)
85 else:
86 result[key] = value
87 return result
88 # v should already be dict[str, AdapterConfiguration] if not dict
89 return v if isinstance(v, dict) else {}
91 class Config:
92 arbitrary_types_allowed = True
95@dataclass
96class ConfigurationValidationResult:
97 """Result of configuration validation."""
99 status: ConfigurationStatus
100 errors: list[str] = field(default_factory=list)
101 warnings: list[str] = field(default_factory=list)
102 info: dict[str, Any] = field(default_factory=dict)
103 adapter_results: dict[str, dict[str, Any]] = field(default_factory=dict)
106@dataclass
107class ConfigurationBackup:
108 """Configuration backup metadata."""
110 id: str
111 name: str
112 description: str
113 created_at: datetime
114 profile: ConfigurationProfile
115 file_path: Path
116 checksum: str
119class ConfigurationManager:
120 """Advanced configuration management for FastBlocks adapters."""
122 def __init__(self, registry: AdapterRegistry, base_path: Path | None = None):
123 """Initialize configuration manager."""
124 self.registry = registry
125 self.base_path = base_path or Path.cwd() / ".fastblocks"
126 self.config_dir = self.base_path / "config"
127 self.backup_dir = self.base_path / "backups"
128 self.templates_dir = self.base_path / "templates"
130 # Ensure directories exist
131 for directory in (self.config_dir, self.backup_dir, self.templates_dir):
132 directory.mkdir(parents=True, exist_ok=True)
134 async def initialize(self) -> None:
135 """Initialize configuration manager."""
136 await self.registry.initialize()
137 await self._ensure_default_templates()
139 async def get_available_adapters(self) -> dict[str, AdapterInfo]:
140 """Get all available adapters for configuration."""
141 return await self.registry.list_available_adapters()
143 async def get_adapter_configuration_schema(
144 self, adapter_name: str
145 ) -> dict[str, Any]:
146 """Get configuration schema for a specific adapter."""
147 adapter_info = await self.registry.get_adapter_info(adapter_name)
148 if not adapter_info:
149 raise ValueError(f"Adapter '{adapter_name}' not found")
151 # Build base schema
152 schema = self._build_base_schema(adapter_name, adapter_info)
154 # Try to introspect adapter settings
155 with suppress(Exception):
156 adapter = await self.registry.get_adapter(adapter_name)
157 self._introspect_adapter_settings(adapter, schema)
159 return schema
161 def _build_base_schema(
162 self, adapter_name: str, adapter_info: AdapterInfo
163 ) -> dict[str, Any]:
164 """Build base schema structure."""
165 return {
166 "name": adapter_name,
167 "description": adapter_info.description,
168 "category": adapter_info.category,
169 "required_settings": [],
170 "optional_settings": [],
171 "environment_variables": [],
172 "dependencies": [],
173 }
175 def _introspect_adapter_settings(
176 self, adapter: Any, schema: dict[str, Any]
177 ) -> None:
178 """Introspect adapter settings and populate schema."""
179 if not adapter or not hasattr(adapter, "settings"):
180 return
182 settings = adapter.settings
183 if not hasattr(settings, "__dict__"):
184 return
186 # Categorize settings by requirement
187 categorized = self._categorize_settings(settings.__dict__)
188 schema["required_settings"] = categorized["required"]
189 schema["optional_settings"] = categorized["optional"]
191 def _categorize_settings(
192 self, settings_dict: dict[str, Any]
193 ) -> dict[str, list[dict[str, Any]]]:
194 """Categorize settings into required and optional."""
195 categorized: dict[str, list[dict[str, Any]]] = {
196 "required": [],
197 "optional": [],
198 }
200 for key, value in settings_dict.items():
201 if key.startswith("_"):
202 continue
204 setting_info = {
205 "name": key,
206 "type": type(value).__name__,
207 "default": value,
208 "required": value is None,
209 }
211 category = "required" if setting_info["required"] else "optional"
212 categorized[category].append(setting_info)
214 return categorized
216 async def create_configuration(
217 self,
218 profile: ConfigurationProfile = ConfigurationProfile.DEVELOPMENT,
219 adapters: list[str] | None = None,
220 ) -> ConfigurationSchema:
221 """Create a new configuration."""
222 config = ConfigurationSchema(profile=profile)
224 if adapters:
225 for adapter_name in adapters:
226 adapter_config = await self._create_adapter_configuration(adapter_name)
227 config.adapters[adapter_name] = adapter_config
229 return config
231 async def _create_adapter_configuration(
232 self, adapter_name: str
233 ) -> AdapterConfiguration:
234 """Create configuration for a specific adapter."""
235 schema = await self.get_adapter_configuration_schema(adapter_name)
237 adapter_config = AdapterConfiguration(name=adapter_name)
239 # Set up environment variables based on schema
240 for setting in schema.get("required_settings", []):
241 env_var = EnvironmentVariable(
242 name=f"FB_{adapter_name.upper()}_{setting['name'].upper()}",
243 required=True,
244 description=f"Required setting for {adapter_name}: {setting['name']}",
245 )
246 adapter_config.environment_variables.append(env_var)
248 for setting in schema.get("optional_settings", []):
249 env_var = EnvironmentVariable(
250 name=f"FB_{adapter_name.upper()}_{setting['name'].upper()}",
251 required=False,
252 default=str(setting.get("default", "")),
253 description=f"Optional setting for {adapter_name}: {setting['name']}",
254 )
255 adapter_config.environment_variables.append(env_var)
257 return adapter_config
259 async def validate_configuration(
260 self, config: ConfigurationSchema
261 ) -> ConfigurationValidationResult:
262 """Validate a configuration comprehensively."""
263 result = ConfigurationValidationResult(status=ConfigurationStatus.VALID)
265 try:
266 # Validate configuration schema
267 config_dict = config.dict() if hasattr(config, "dict") else config.__dict__
268 ConfigurationSchema(**config_dict)
269 except ValidationError as e:
270 result.status = ConfigurationStatus.ERROR
271 result.errors.extend([str(error) for error in e.errors()])
272 except Exception as e:
273 result.status = ConfigurationStatus.ERROR
274 result.errors.append(f"Configuration validation error: {e}")
276 # Validate individual adapters
277 for adapter_name, adapter_config in config.adapters.items():
278 adapter_result = await self._validate_adapter_configuration(
279 adapter_name, adapter_config
280 )
281 result.adapter_results[adapter_name] = adapter_result
283 if adapter_result.get("errors"):
284 result.errors.extend(
285 f"{adapter_name}: {error}" for error in adapter_result["errors"]
286 )
287 result.status = ConfigurationStatus.ERROR
289 if adapter_result.get("warnings"):
290 result.warnings.extend(
291 f"{adapter_name}: {warning}"
292 for warning in adapter_result["warnings"]
293 )
294 if result.status == ConfigurationStatus.VALID:
295 result.status = ConfigurationStatus.WARNING
297 # Validate dependencies
298 await self._validate_dependencies(config, result)
300 # Validate environment variables
301 await self._validate_environment_variables(config, result)
303 return result
305 async def _validate_adapter_configuration(
306 self, adapter_name: str, adapter_config: AdapterConfiguration
307 ) -> dict[str, Any]:
308 """Validate individual adapter configuration."""
309 validation_result = await self.registry.validate_adapter(adapter_name)
311 result = {
312 "valid": validation_result.get("valid", False),
313 "errors": validation_result.get("errors", []),
314 "warnings": validation_result.get("warnings", []),
315 "info": validation_result.get("info", {}),
316 }
318 # Additional configuration-specific validations
319 if adapter_config.enabled:
320 # Check required environment variables
321 for env_var in adapter_config.environment_variables:
322 if env_var.required and not env_var.value and not env_var.default:
323 if env_var.name not in os.environ:
324 result["warnings"].append(
325 f"Required environment variable {env_var.name} is not set"
326 )
328 return result
330 async def _validate_dependencies(
331 self, config: ConfigurationSchema, result: ConfigurationValidationResult
332 ) -> None:
333 """Validate adapter dependencies."""
334 enabled_adapters = {
335 name for name, adapter in config.adapters.items() if adapter.enabled
336 }
338 for adapter_name, adapter_config in config.adapters.items():
339 if not adapter_config.enabled:
340 continue
342 for dependency in adapter_config.dependencies:
343 if dependency not in enabled_adapters:
344 result.errors.append(
345 f"Adapter '{adapter_name}' depends on '{dependency}' which is not enabled"
346 )
347 result.status = ConfigurationStatus.ERROR
349 def _check_duplicate_env_vars(
350 self,
351 config: ConfigurationSchema,
352 result: ConfigurationValidationResult,
353 all_env_vars: set[str],
354 ) -> None:
355 """Check for duplicate environment variable names."""
356 for adapter_config in config.adapters.values():
357 for env_var in adapter_config.environment_variables:
358 if env_var.name in all_env_vars:
359 result.warnings.append(
360 f"Duplicate environment variable: {env_var.name}"
361 )
362 all_env_vars.add(env_var.name)
364 def _is_env_var_missing(self, env_var: EnvironmentVariable) -> bool:
365 """Check if a required environment variable is missing."""
366 return (
367 env_var.required
368 and not env_var.value
369 and not env_var.default
370 and env_var.name not in os.environ
371 )
373 def _check_missing_required_vars(
374 self, config: ConfigurationSchema, result: ConfigurationValidationResult
375 ) -> None:
376 """Check for missing required environment variables."""
377 for adapter_name, adapter_config in config.adapters.items():
378 if not adapter_config.enabled:
379 continue
381 for env_var in adapter_config.environment_variables:
382 if self._is_env_var_missing(env_var):
383 result.warnings.append(
384 f"Required environment variable {env_var.name} for {adapter_name} is not set"
385 )
387 async def _validate_environment_variables(
388 self, config: ConfigurationSchema, result: ConfigurationValidationResult
389 ) -> None:
390 """Validate environment variable configuration."""
391 all_env_vars: set[str] = set()
393 # Check for duplicate environment variable names
394 self._check_duplicate_env_vars(config, result, all_env_vars)
396 # Check for missing required variables
397 self._check_missing_required_vars(config, result)
399 async def save_configuration(
400 self, config: ConfigurationSchema, name: str | None = None
401 ) -> Path:
402 """Save configuration to YAML file."""
403 if not name:
404 name = f"{config.profile.value}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
406 config_file = self.config_dir / f"{name}.yaml"
408 # Convert to serializable dict
409 config_dict = self._serialize_configuration(config)
411 with config_file.open("w") as f:
412 yaml.dump(config_dict, f, default_flow_style=False, sort_keys=False)
414 return config_file
416 async def load_configuration(self, name_or_path: str | Path) -> ConfigurationSchema:
417 """Load configuration from YAML file."""
418 if isinstance(name_or_path, str):
419 config_file = self.config_dir / f"{name_or_path}.yaml"
420 if not config_file.exists():
421 config_file = Path(name_or_path)
422 else:
423 config_file = name_or_path
425 if not config_file.exists():
426 raise FileNotFoundError(f"Configuration file not found: {config_file}")
428 with config_file.open() as f:
429 config_dict = yaml.safe_load(f)
431 return self._deserialize_configuration(config_dict)
433 def _serialize_configuration(self, config: ConfigurationSchema) -> dict[str, Any]:
434 """Convert configuration to serializable dictionary."""
435 result = {
436 "version": config.version,
437 "profile": config.profile.value,
438 "created_at": config.created_at.isoformat(),
439 "updated_at": config.updated_at.isoformat(),
440 "global_settings": config.global_settings,
441 "global_environment": [
442 {
443 "name": env_var.name,
444 "value": env_var.value,
445 "required": env_var.required,
446 "description": env_var.description,
447 "secret": env_var.secret,
448 "default": env_var.default,
449 "validator_pattern": env_var.validator_pattern,
450 }
451 for env_var in config.global_environment
452 ],
453 "adapters": {},
454 }
456 adapters_dict: dict[str, Any] = {}
457 for adapter_name, adapter_config in config.adapters.items():
458 adapters_dict[adapter_name] = {
459 "enabled": adapter_config.enabled,
460 "settings": adapter_config.settings,
461 "environment_variables": [
462 {
463 "name": env_var.name,
464 "value": env_var.value,
465 "required": env_var.required,
466 "description": env_var.description,
467 "secret": env_var.secret,
468 "default": env_var.default,
469 "validator_pattern": env_var.validator_pattern,
470 }
471 for env_var in adapter_config.environment_variables
472 ],
473 "dependencies": list(adapter_config.dependencies),
474 "profile_overrides": {
475 profile.value: overrides
476 for profile, overrides in adapter_config.profile_overrides.items()
477 },
478 "health_check_config": adapter_config.health_check_config,
479 "metadata": adapter_config.metadata,
480 }
482 result["adapters"] = adapters_dict
483 return result
485 def _deserialize_configuration(
486 self, config_dict: dict[str, Any]
487 ) -> ConfigurationSchema:
488 """Convert dictionary to configuration object."""
489 # Convert string dates back to datetime objects
490 if "created_at" in config_dict:
491 config_dict["created_at"] = datetime.fromisoformat(
492 config_dict["created_at"]
493 )
494 if "updated_at" in config_dict:
495 config_dict["updated_at"] = datetime.fromisoformat(
496 config_dict["updated_at"]
497 )
499 # Convert profile string to enum
500 if "profile" in config_dict:
501 config_dict["profile"] = ConfigurationProfile(config_dict["profile"])
503 # Convert global environment variables
504 global_env = [
505 EnvironmentVariable(**env_data)
506 for env_data in config_dict.get("global_environment", [])
507 ]
508 config_dict["global_environment"] = global_env
510 # Convert adapter configurations
511 adapters = {}
512 for adapter_name, adapter_data in config_dict.get("adapters", {}).items():
513 # Convert environment variables
514 env_vars = [
515 EnvironmentVariable(**env_data)
516 for env_data in adapter_data.get("environment_variables", [])
517 ]
518 adapter_data["environment_variables"] = env_vars
520 # Convert profile overrides
521 profile_overrides = {}
522 for profile_str, overrides in adapter_data.get(
523 "profile_overrides", {}
524 ).items():
525 profile_overrides[ConfigurationProfile(profile_str)] = overrides
526 adapter_data["profile_overrides"] = profile_overrides
528 # Convert dependencies to set
529 adapter_data["dependencies"] = set(adapter_data.get("dependencies", []))
531 adapters[adapter_name] = AdapterConfiguration(
532 name=adapter_name, **adapter_data
533 )
535 config_dict["adapters"] = adapters
537 return ConfigurationSchema(**config_dict)
539 async def generate_environment_file(
540 self, config: ConfigurationSchema, output_path: Path | None = None
541 ) -> Path:
542 """Generate .env file from configuration."""
543 if not output_path:
544 output_path = self.base_path / f".env.{config.profile.value}"
546 # Add global environment variables
547 env_vars = [
548 self._format_env_var(env_var) for env_var in config.global_environment
549 ]
551 # Add adapter environment variables
552 for adapter_name, adapter_config in config.adapters.items():
553 if not adapter_config.enabled:
554 continue
556 env_vars.append(f"\n# {adapter_name.upper()} ADAPTER")
557 for env_var in adapter_config.environment_variables:
558 env_vars.append(self._format_env_var(env_var))
560 with output_path.open("w") as f:
561 f.write("\n".join(env_vars))
563 return output_path
565 def _format_env_var(self, env_var: EnvironmentVariable) -> str:
566 """Format environment variable for .env file."""
567 lines = []
569 if env_var.description:
570 lines.append(f"# {env_var.description}")
572 if env_var.required:
573 lines.append("# REQUIRED")
575 value = env_var.value or env_var.default or ""
576 if env_var.secret and value:
577 value = "***REDACTED***"
579 lines.append(f"{env_var.name}={value}")
581 return "\n".join(lines)
583 async def backup_configuration(
584 self, config: ConfigurationSchema, name: str, description: str = ""
585 ) -> ConfigurationBackup:
586 """Create a backup of the configuration."""
587 backup_id = str(uuid4())
588 backup_file = self.backup_dir / f"{backup_id}_{name}.yaml"
590 # Save configuration
591 config_dict = self._serialize_configuration(config)
592 with backup_file.open("w") as f:
593 yaml.dump(config_dict, f, default_flow_style=False)
595 # Calculate checksum
596 import hashlib
598 with backup_file.open("rb") as fb:
599 checksum = hashlib.sha256(fb.read()).hexdigest()
601 backup = ConfigurationBackup(
602 id=backup_id,
603 name=name,
604 description=description,
605 created_at=datetime.now(),
606 profile=config.profile,
607 file_path=backup_file,
608 checksum=checksum,
609 )
611 # Save backup metadata
612 metadata_file = self.backup_dir / f"{backup_id}_metadata.json"
613 with metadata_file.open("w") as f:
614 json.dump(
615 {
616 "id": backup.id,
617 "name": backup.name,
618 "description": backup.description,
619 "created_at": backup.created_at.isoformat(),
620 "profile": backup.profile.value,
621 "file_path": str(backup.file_path),
622 "checksum": backup.checksum,
623 },
624 f,
625 indent=2,
626 )
628 return backup
630 async def list_backups(self) -> list[ConfigurationBackup]:
631 """List all configuration backups."""
632 backups = []
634 for metadata_file in self.backup_dir.glob("*_metadata.json"):
635 try:
636 with metadata_file.open() as f:
637 data = json.load(f)
639 backup = ConfigurationBackup(
640 id=data["id"],
641 name=data["name"],
642 description=data["description"],
643 created_at=datetime.fromisoformat(data["created_at"]),
644 profile=ConfigurationProfile(data["profile"]),
645 file_path=Path(data["file_path"]),
646 checksum=data["checksum"],
647 )
649 # Verify file still exists
650 if backup.file_path.exists():
651 backups.append(backup)
652 except Exception:
653 # Skip corrupted metadata files
654 continue
656 return sorted(backups, key=lambda b: b.created_at, reverse=True)
658 async def restore_backup(self, backup_id: str) -> ConfigurationSchema:
659 """Restore configuration from backup."""
660 backups = await self.list_backups()
661 backup = next((b for b in backups if b.id == backup_id), None)
663 if not backup:
664 raise ValueError(f"Backup '{backup_id}' not found")
666 return await self.load_configuration(backup.file_path)
668 async def _ensure_default_templates(self) -> None:
669 """Ensure default configuration templates exist."""
670 templates = {
671 "minimal.yaml": self._create_minimal_template(),
672 "development.yaml": self._create_development_template(),
673 "production.yaml": self._create_production_template(),
674 }
676 for template_name, template_config in templates.items():
677 template_file = self.templates_dir / template_name
678 if not template_file.exists():
679 config_dict = self._serialize_configuration(template_config)
680 with template_file.open("w") as f:
681 yaml.dump(config_dict, f, default_flow_style=False)
683 def _create_minimal_template(self) -> ConfigurationSchema:
684 """Create minimal configuration template."""
685 return ConfigurationSchema(
686 profile=ConfigurationProfile.DEVELOPMENT,
687 global_settings={"debug": True, "log_level": "INFO"},
688 )
690 def _create_development_template(self) -> ConfigurationSchema:
691 """Create development configuration template."""
692 config = ConfigurationSchema(
693 profile=ConfigurationProfile.DEVELOPMENT,
694 global_settings={"debug": True, "log_level": "DEBUG", "hot_reload": True},
695 )
697 # Add common development adapters
698 config.adapters["app"] = AdapterConfiguration(
699 name="app", enabled=True, settings={"debug": True}
700 )
702 return config
704 def _create_production_template(self) -> ConfigurationSchema:
705 """Create production configuration template."""
706 config = ConfigurationSchema(
707 profile=ConfigurationProfile.PRODUCTION,
708 global_settings={
709 "debug": False,
710 "log_level": "WARNING",
711 "hot_reload": False,
712 },
713 )
715 return config