Coverage for fastblocks/mcp/config_cli.py: 0%
328 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"""Interactive CLI for FastBlocks adapter configuration management."""
3import asyncio
4import json
5import os
6import sys
7import typing as t
8from pathlib import Path
9from typing import Any
11import click
12from rich.console import Console
13from rich.panel import Panel
14from rich.prompt import Confirm, Prompt
15from rich.table import Table
17from .configuration import (
18 ConfigurationManager,
19 ConfigurationProfile,
20 ConfigurationSchema,
21 ConfigurationStatus,
22 ConfigurationValidationResult,
23 EnvironmentVariable,
24)
25from .health import HealthCheckSystem
26from .registry import AdapterRegistry
28console = Console()
31class InteractiveConfigurationCLI:
32 """Interactive CLI for adapter configuration."""
34 def __init__(self) -> None:
35 """Initialize the interactive CLI."""
36 self.registry = AdapterRegistry()
37 self.health = HealthCheckSystem(self.registry)
38 self.config_manager = ConfigurationManager(self.registry)
40 async def initialize(self) -> None:
41 """Initialize all systems."""
42 await self.registry.initialize()
43 await self.config_manager.initialize()
45 async def run_configuration_wizard(self) -> ConfigurationSchema:
46 """Run the interactive configuration wizard."""
47 console.print(
48 Panel.fit(
49 "[bold blue]FastBlocks Adapter Configuration Wizard[/bold blue]\n"
50 "This wizard will help you configure FastBlocks adapters for your project.",
51 title="Welcome",
52 )
53 )
55 # Step 1: Select profile
56 profile = self._select_profile()
58 # Step 2: Select adapters
59 selected_adapters = await self._select_adapters()
61 # Step 3: Configure selected adapters
62 config = await self.config_manager.create_configuration(
63 profile=profile, adapters=selected_adapters
64 )
66 # Step 4: Configure each adapter
67 for adapter_name in selected_adapters:
68 await self._configure_adapter_interactive(config, adapter_name)
70 # Step 5: Configure global settings
71 await self._configure_global_settings(config)
73 # Step 6: Validate configuration
74 console.print("\n[yellow]Validating configuration...[/yellow]")
75 validation_result = await self.config_manager.validate_configuration(config)
76 self._display_validation_result(validation_result)
78 if validation_result.status == ConfigurationStatus.ERROR:
79 if not Confirm.ask("Configuration has errors. Continue anyway?"):
80 console.print("[red]Configuration cancelled.[/red]")
81 sys.exit(1)
83 # Step 7: Save configuration
84 config_name = Prompt.ask(
85 "Configuration name", default=f"{profile.value}_config"
86 )
88 config_file = await self.config_manager.save_configuration(config, config_name)
89 console.print(f"\n[green]✓[/green] Configuration saved to: {config_file}")
91 # Step 8: Generate environment file
92 if Confirm.ask("Generate .env file?", default=True):
93 env_file = await self.config_manager.generate_environment_file(config)
94 console.print(f"[green]✓[/green] Environment file created: {env_file}")
96 # Step 9: Create backup
97 if Confirm.ask("Create backup?", default=True):
98 backup_description = Prompt.ask(
99 "Backup description", default="Initial configuration"
100 )
101 backup = await self.config_manager.backup_configuration(
102 config, config_name, backup_description
103 )
104 console.print(f"[green]✓[/green] Backup created: {backup.id}")
106 return config
108 def _select_profile(self) -> ConfigurationProfile:
109 """Interactive profile selection."""
110 console.print("\n[bold]Select deployment profile:[/bold]")
112 profiles = {
113 "1": (
114 ConfigurationProfile.DEVELOPMENT,
115 "Development - Debug enabled, relaxed security",
116 ),
117 "2": (
118 ConfigurationProfile.STAGING,
119 "Staging - Production-like with debug options",
120 ),
121 "3": (
122 ConfigurationProfile.PRODUCTION,
123 "Production - Optimized for performance and security",
124 ),
125 }
127 for key, (profile, description) in profiles.items():
128 console.print(
129 f" {key}. [cyan]{profile.value.title()}[/cyan] - {description}"
130 )
132 choice = Prompt.ask(
133 "Choose profile", choices=list(profiles.keys()), default="1"
134 )
136 selected_profile, _ = profiles[choice]
137 console.print(
138 f"[green]✓[/green] Selected profile: [cyan]{selected_profile.value.title()}[/cyan]"
139 )
140 return selected_profile
142 def _group_adapters_by_category(
143 self, available_adapters: dict[str, Any]
144 ) -> dict[str, list[tuple[str, Any]]]:
145 """Group adapters by category.
147 Args:
148 available_adapters: Dict of adapter name -> adapter info
150 Returns:
151 Dict of category -> list of (adapter_name, adapter_info)
152 """
153 categories: dict[str, list[tuple[str, Any]]] = {}
155 for adapter_name, adapter_info in available_adapters.items():
156 category = adapter_info.category
157 if category not in categories:
158 categories[category] = []
159 categories[category].append((adapter_name, adapter_info))
161 return categories
163 def _display_adapter_choices(
164 self, categories: dict[str, list[tuple[str, Any]]]
165 ) -> dict[str, str]:
166 """Display adapters by category and build choice mapping.
168 Args:
169 categories: Dict of category -> list of (adapter_name, adapter_info)
171 Returns:
172 Dict mapping choice number to adapter name
173 """
174 adapter_choices = {}
175 choice_num = 1
177 for category, adapters in sorted(categories.items()):
178 console.print(f"\n[bold yellow]{category.title()}:[/bold yellow]")
179 for adapter_name, adapter_info in sorted(adapters):
180 status_color = (
181 "green" if adapter_info.module_status == "stable" else "yellow"
182 )
183 console.print(
184 f" {choice_num:2d}. [cyan]{adapter_name:<20}[/cyan] "
185 f"[{status_color}]{adapter_info.module_status:<12}[/{status_color}] "
186 f"{adapter_info.description or 'No description'}"
187 )
188 adapter_choices[str(choice_num)] = adapter_name
189 choice_num += 1
191 return adapter_choices
193 def _get_recommended_adapters(
194 self, available_adapters: dict[str, Any]
195 ) -> list[str]:
196 """Get recommended default adapters.
198 Args:
199 available_adapters: Dict of available adapters
201 Returns:
202 List of recommended adapter names
203 """
204 recommended = ["app", "templates", "routes"]
205 selected = [name for name in recommended if name in available_adapters]
206 console.print(
207 f"[green]✓[/green] Using recommended adapters: {', '.join(selected)}"
208 )
209 return selected
211 def _parse_adapter_selection(
212 self, selection: str, adapter_choices: dict[str, str]
213 ) -> list[str]:
214 """Parse user's adapter selection.
216 Args:
217 selection: Comma-separated choice numbers
218 adapter_choices: Dict mapping choice number to adapter name
220 Returns:
221 List of selected adapter names
222 """
223 selected_adapters = []
225 for choice in selection.split(","):
226 choice = choice.strip()
227 if choice in adapter_choices:
228 selected_adapters.append(adapter_choices[choice])
229 else:
230 console.print(f"[red]Warning:[/red] Invalid choice '{choice}' ignored")
232 # Print result
233 if selected_adapters:
234 console.print(
235 f"[green]✓[/green] Selected adapters: {', '.join(selected_adapters)}"
236 )
237 else:
238 console.print("[red]No adapters selected![/red]")
240 return selected_adapters
242 async def _select_adapters(self) -> list[str]:
243 """Interactive adapter selection."""
244 console.print("\n[bold]Available Adapters:[/bold]")
246 available_adapters = await self.config_manager.get_available_adapters()
248 # Group and display adapters
249 categories = self._group_adapters_by_category(available_adapters)
250 adapter_choices = self._display_adapter_choices(categories)
252 # Get user selection
253 console.print(
254 "\n[bold]Select adapters (comma-separated numbers, e.g., 1,3,5):[/bold]"
255 )
256 console.print("[dim]Leave empty for recommended defaults[/dim]")
257 selection = Prompt.ask("Adapter numbers", default="")
259 # Return recommended or parsed selection
260 if not selection.strip():
261 return self._get_recommended_adapters(available_adapters)
263 return self._parse_adapter_selection(selection, adapter_choices)
265 def _configure_adapter_env_vars(self, adapter_config: t.Any) -> None:
266 """Configure adapter environment variables."""
267 if adapter_config.environment_variables:
268 console.print("\n[yellow]Environment Variables:[/yellow]")
269 for env_var in adapter_config.environment_variables:
270 self._configure_environment_variable(env_var)
272 def _configure_required_settings(
273 self, adapter_config: t.Any, schema: dict[str, t.Any]
274 ) -> None:
275 """Configure required adapter settings."""
276 for setting in schema.get("required_settings", []):
277 value = Prompt.ask(
278 f"[red]*[/red] {setting['name']} ({setting['type']})",
279 default=str(setting.get("default", "")),
280 )
281 adapter_config.settings[setting["name"]] = self._parse_setting_value(
282 value, setting["type"]
283 )
285 def _configure_optional_settings(
286 self, adapter_config: t.Any, schema: dict[str, t.Any]
287 ) -> None:
288 """Configure optional adapter settings."""
289 if schema.get("optional_settings") and Confirm.ask(
290 "Configure optional settings?"
291 ):
292 for setting in schema.get("optional_settings", []):
293 if Confirm.ask(f"Configure {setting['name']}?"):
294 value = Prompt.ask(
295 f"{setting['name']} ({setting['type']})",
296 default=str(setting.get("default", "")),
297 )
298 adapter_config.settings[setting["name"]] = (
299 self._parse_setting_value(value, setting["type"])
300 )
302 async def _configure_adapter_interactive(
303 self, config: ConfigurationSchema, adapter_name: str
304 ) -> None:
305 """Configure a specific adapter interactively."""
306 console.print(f"\n[bold]Configuring {adapter_name} adapter:[/bold]")
308 adapter_config = config.adapters[adapter_name]
309 schema = await self.config_manager.get_adapter_configuration_schema(
310 adapter_name
311 )
313 # Show adapter information
314 console.print(f"[dim]Category: {schema.get('category', 'Unknown')}[/dim]")
315 if schema.get("description"):
316 console.print(f"[dim]Description: {schema['description']}[/dim]")
318 # Configure environment variables
319 self._configure_adapter_env_vars(adapter_config)
321 # Configure settings
322 if schema.get("required_settings") or schema.get("optional_settings"):
323 console.print("\n[yellow]Adapter Settings:[/yellow]")
324 self._configure_required_settings(adapter_config, schema)
325 self._configure_optional_settings(adapter_config, schema)
327 # Configure dependencies
328 if schema.get("dependencies"):
329 console.print(
330 f"\n[yellow]Dependencies: {', '.join(schema['dependencies'])}[/yellow]"
331 )
332 adapter_config.dependencies.update(schema["dependencies"])
334 def _configure_environment_variable(self, env_var: EnvironmentVariable) -> None:
335 """Configure a single environment variable."""
336 current_value = os.environ.get(
337 env_var.name, env_var.value or env_var.default or ""
338 )
340 if env_var.required:
341 prompt_text = f"[red]*[/red] {env_var.name}"
342 else:
343 prompt_text = env_var.name
345 if env_var.description:
346 console.print(f"[dim] {env_var.description}[/dim]")
348 if env_var.secret:
349 value = Prompt.ask(prompt_text, password=True, default=current_value)
350 else:
351 value = Prompt.ask(prompt_text, default=current_value)
353 env_var.value = value
355 def _parse_setting_value(self, value: str, type_name: str) -> Any:
356 """Parse setting value based on type."""
357 if not value:
358 return None
360 try:
361 if type_name in ("int", "integer"):
362 return int(value)
363 elif type_name in ("float", "number"):
364 return float(value)
365 elif type_name in ("bool", "boolean"):
366 return value.lower() in ("true", "1", "yes", "on")
367 elif type_name in ("list", "array"):
368 return [item.strip() for item in value.split(",") if item.strip()]
369 elif type_name in ("dict", "object"):
370 return json.loads(value)
372 return value
373 except (ValueError, json.JSONDecodeError):
374 console.print(
375 f"[red]Warning:[/red] Could not parse '{value}' as {type_name}, using as string"
376 )
377 return value
379 async def _configure_global_settings(self, config: ConfigurationSchema) -> None:
380 """Configure global settings."""
381 console.print("\n[bold]Global Settings:[/bold]")
383 if Confirm.ask("Configure global settings?"):
384 # Common global settings
385 settings_to_configure = [
386 ("debug", "bool", "Enable debug mode"),
387 ("log_level", "str", "Logging level (DEBUG, INFO, WARNING, ERROR)"),
388 ("secret_key", "str", "Application secret key"),
389 ("database_url", "str", "Database connection URL"),
390 ]
392 for setting_name, setting_type, description in settings_to_configure:
393 if Confirm.ask(f"Configure {setting_name}?"):
394 console.print(f"[dim]{description}[/dim]")
395 value = Prompt.ask(
396 setting_name,
397 password=(setting_name in ("secret_key", "database_url")),
398 )
399 config.global_settings[setting_name] = self._parse_setting_value(
400 value, setting_type
401 )
403 def _display_validation_result(self, result: ConfigurationValidationResult) -> None:
404 """Display configuration validation results."""
405 if result.status == ConfigurationStatus.VALID:
406 console.print("[green]✓ Configuration is valid[/green]")
407 elif result.status == ConfigurationStatus.WARNING:
408 console.print("[yellow]⚠ Configuration has warnings[/yellow]")
409 elif result.status == ConfigurationStatus.ERROR:
410 console.print("[red]✗ Configuration has errors[/red]")
412 if result.errors:
413 console.print("\n[red]Errors:[/red]")
414 for error in result.errors:
415 console.print(f" [red]•[/red] {error}")
417 if result.warnings:
418 console.print("\n[yellow]Warnings:[/yellow]")
419 for warning in result.warnings:
420 console.print(f" [yellow]•[/yellow] {warning}")
423# CLI Commands
426@click.group()
427def config_cli() -> None:
428 """FastBlocks Configuration Management CLI."""
429 pass
432@config_cli.command()
433def wizard() -> None:
434 """Launch the interactive configuration wizard."""
436 async def _wizard() -> None:
437 cli = InteractiveConfigurationCLI()
438 await cli.initialize()
439 await cli.run_configuration_wizard()
441 asyncio.run(_wizard())
444@config_cli.command()
445@click.option(
446 "--profile",
447 type=click.Choice(["development", "staging", "production"]),
448 default="development",
449 help="Configuration profile",
450)
451@click.option("--adapters", help="Comma-separated list of adapters to include")
452@click.option("--output", "-o", help="Output file path")
453def create(profile: str, adapters: str | None, output: str | None) -> None:
454 """Create a new configuration file."""
456 async def _create() -> None:
457 registry = AdapterRegistry()
458 config_manager = ConfigurationManager(registry)
459 await registry.initialize()
460 await config_manager.initialize()
462 profile_enum = ConfigurationProfile(profile)
463 adapter_list = adapters.split(",") if adapters else None
465 config = await config_manager.create_configuration(
466 profile=profile_enum, adapters=adapter_list
467 )
469 config_file = await config_manager.save_configuration(config, output)
470 console.print(f"[green]✓[/green] Configuration created: {config_file}")
472 asyncio.run(_create())
475def _format_json_output(result: ConfigurationValidationResult) -> None:
476 """Format validation result as JSON."""
477 output = {
478 "status": result.status.value,
479 "errors": result.errors,
480 "warnings": result.warnings,
481 "adapter_results": result.adapter_results,
482 }
483 click.echo(json.dumps(output, indent=2))
486def _format_text_output(result: ConfigurationValidationResult) -> None:
487 """Format validation result as human-readable text."""
488 # Print status
489 status_messages = {
490 ConfigurationStatus.VALID: "[green]✓ Configuration is valid[/green]",
491 ConfigurationStatus.WARNING: "[yellow]⚠ Configuration has warnings[/yellow]",
492 ConfigurationStatus.ERROR: "[red]✗ Configuration has errors[/red]",
493 }
494 console.print(status_messages.get(result.status, "Unknown status"))
496 # Print errors
497 if result.errors:
498 console.print("\n[red]Errors:[/red]")
499 for error in result.errors:
500 console.print(f" • {error}")
502 # Print warnings
503 if result.warnings:
504 console.print("\n[yellow]Warnings:[/yellow]")
505 for warning in result.warnings:
506 console.print(f" • {warning}")
509@config_cli.command()
510@click.argument("config_file")
511@click.option("--format", type=click.Choice(["text", "json"]), default="text")
512def validate(config_file: str, format: str) -> None:
513 """Validate a configuration file."""
515 async def _validate() -> None:
516 registry = AdapterRegistry()
517 config_manager = ConfigurationManager(registry)
518 await registry.initialize()
519 await config_manager.initialize()
521 try:
522 config = await config_manager.load_configuration(config_file)
523 result = await config_manager.validate_configuration(config)
525 # Format output based on requested format
526 if format == "json":
527 _format_json_output(result)
528 else:
529 _format_text_output(result)
531 except FileNotFoundError:
532 console.print(
533 f"[red]Error:[/red] Configuration file '{config_file}' not found"
534 )
535 sys.exit(1)
536 except Exception as e:
537 console.print(f"[red]Error:[/red] {e}")
538 sys.exit(1)
540 asyncio.run(_validate())
543@config_cli.command()
544@click.argument("config_file")
545@click.option("--output", "-o", help="Output .env file path")
546def generate_env(config_file: str, output: str | None) -> None:
547 """Generate .env file from configuration."""
549 async def _generate_env() -> None:
550 registry = AdapterRegistry()
551 config_manager = ConfigurationManager(registry)
552 await registry.initialize()
553 await config_manager.initialize()
555 try:
556 config = await config_manager.load_configuration(config_file)
557 output_path = Path(output) if output else None
558 env_file = await config_manager.generate_environment_file(
559 config, output_path
560 )
561 console.print(f"[green]✓[/green] Environment file created: {env_file}")
562 except Exception as e:
563 console.print(f"[red]Error:[/red] {e}")
564 sys.exit(1)
566 asyncio.run(_generate_env())
569@config_cli.command()
570def list_templates() -> None:
571 """List available configuration templates."""
573 async def _list_templates() -> None:
574 registry = AdapterRegistry()
575 config_manager = ConfigurationManager(registry)
576 await config_manager.initialize()
578 templates_dir = config_manager.templates_dir
579 if not templates_dir.exists():
580 console.print("[yellow]No templates directory found[/yellow]")
581 return
583 templates = list(templates_dir.glob("*.yaml"))
584 if not templates:
585 console.print("[yellow]No templates available[/yellow]")
586 return
588 console.print("[bold]Available Templates:[/bold]")
589 for template in sorted(templates):
590 console.print(f" • {template.stem}")
592 asyncio.run(_list_templates())
595@config_cli.command()
596@click.argument("name")
597@click.argument("description")
598def backup(name: str, description: str) -> None:
599 """Create a backup of current configuration."""
601 async def _backup() -> None:
602 # This would need to be implemented based on current active configuration
603 console.print(
604 "[yellow]Backup functionality needs active configuration context[/yellow]"
605 )
607 asyncio.run(_backup())
610@config_cli.command()
611def list_backups() -> None:
612 """List all configuration backups."""
614 async def _list_backups() -> None:
615 registry = AdapterRegistry()
616 config_manager = ConfigurationManager(registry)
617 await config_manager.initialize()
619 backups = await config_manager.list_backups()
621 if not backups:
622 console.print("[yellow]No backups found[/yellow]")
623 return
625 table = Table(title="Configuration Backups")
626 table.add_column("ID", width=8)
627 table.add_column("Name")
628 table.add_column("Profile")
629 table.add_column("Created")
630 table.add_column("Description")
632 for backup in backups:
633 table.add_row(
634 backup.id[:8],
635 backup.name,
636 backup.profile.value,
637 backup.created_at.strftime("%Y-%m-%d %H:%M"),
638 backup.description,
639 )
641 console.print(table)
643 asyncio.run(_list_backups())
646@config_cli.command()
647@click.argument("backup_id")
648@click.option("--output", "-o", help="Output file path")
649def restore(backup_id: str, output: str | None) -> None:
650 """Restore configuration from backup."""
652 async def _restore() -> None:
653 registry = AdapterRegistry()
654 config_manager = ConfigurationManager(registry)
655 await config_manager.initialize()
657 try:
658 config = await config_manager.restore_backup(backup_id)
660 if output:
661 config_file = await config_manager.save_configuration(config, output)
662 else:
663 config_file = await config_manager.save_configuration(config)
665 console.print(f"[green]✓[/green] Configuration restored to: {config_file}")
666 except Exception as e:
667 console.print(f"[red]Error:[/red] {e}")
668 sys.exit(1)
670 asyncio.run(_restore())
673if __name__ == "__main__":
674 config_cli()