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

1"""Interactive CLI for FastBlocks adapter configuration management.""" 

2 

3import asyncio 

4import json 

5import os 

6import sys 

7import typing as t 

8from pathlib import Path 

9from typing import Any 

10 

11import click 

12from rich.console import Console 

13from rich.panel import Panel 

14from rich.prompt import Confirm, Prompt 

15from rich.table import Table 

16 

17from .configuration import ( 

18 ConfigurationManager, 

19 ConfigurationProfile, 

20 ConfigurationSchema, 

21 ConfigurationStatus, 

22 ConfigurationValidationResult, 

23 EnvironmentVariable, 

24) 

25from .health import HealthCheckSystem 

26from .registry import AdapterRegistry 

27 

28console = Console() 

29 

30 

31class InteractiveConfigurationCLI: 

32 """Interactive CLI for adapter configuration.""" 

33 

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) 

39 

40 async def initialize(self) -> None: 

41 """Initialize all systems.""" 

42 await self.registry.initialize() 

43 await self.config_manager.initialize() 

44 

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 ) 

54 

55 # Step 1: Select profile 

56 profile = self._select_profile() 

57 

58 # Step 2: Select adapters 

59 selected_adapters = await self._select_adapters() 

60 

61 # Step 3: Configure selected adapters 

62 config = await self.config_manager.create_configuration( 

63 profile=profile, adapters=selected_adapters 

64 ) 

65 

66 # Step 4: Configure each adapter 

67 for adapter_name in selected_adapters: 

68 await self._configure_adapter_interactive(config, adapter_name) 

69 

70 # Step 5: Configure global settings 

71 await self._configure_global_settings(config) 

72 

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) 

77 

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) 

82 

83 # Step 7: Save configuration 

84 config_name = Prompt.ask( 

85 "Configuration name", default=f"{profile.value}_config" 

86 ) 

87 

88 config_file = await self.config_manager.save_configuration(config, config_name) 

89 console.print(f"\n[green]✓[/green] Configuration saved to: {config_file}") 

90 

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}") 

95 

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}") 

105 

106 return config 

107 

108 def _select_profile(self) -> ConfigurationProfile: 

109 """Interactive profile selection.""" 

110 console.print("\n[bold]Select deployment profile:[/bold]") 

111 

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 } 

126 

127 for key, (profile, description) in profiles.items(): 

128 console.print( 

129 f" {key}. [cyan]{profile.value.title()}[/cyan] - {description}" 

130 ) 

131 

132 choice = Prompt.ask( 

133 "Choose profile", choices=list(profiles.keys()), default="1" 

134 ) 

135 

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 

141 

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. 

146 

147 Args: 

148 available_adapters: Dict of adapter name -> adapter info 

149 

150 Returns: 

151 Dict of category -> list of (adapter_name, adapter_info) 

152 """ 

153 categories: dict[str, list[tuple[str, Any]]] = {} 

154 

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)) 

160 

161 return categories 

162 

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. 

167 

168 Args: 

169 categories: Dict of category -> list of (adapter_name, adapter_info) 

170 

171 Returns: 

172 Dict mapping choice number to adapter name 

173 """ 

174 adapter_choices = {} 

175 choice_num = 1 

176 

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 

190 

191 return adapter_choices 

192 

193 def _get_recommended_adapters( 

194 self, available_adapters: dict[str, Any] 

195 ) -> list[str]: 

196 """Get recommended default adapters. 

197 

198 Args: 

199 available_adapters: Dict of available adapters 

200 

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 

210 

211 def _parse_adapter_selection( 

212 self, selection: str, adapter_choices: dict[str, str] 

213 ) -> list[str]: 

214 """Parse user's adapter selection. 

215 

216 Args: 

217 selection: Comma-separated choice numbers 

218 adapter_choices: Dict mapping choice number to adapter name 

219 

220 Returns: 

221 List of selected adapter names 

222 """ 

223 selected_adapters = [] 

224 

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") 

231 

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]") 

239 

240 return selected_adapters 

241 

242 async def _select_adapters(self) -> list[str]: 

243 """Interactive adapter selection.""" 

244 console.print("\n[bold]Available Adapters:[/bold]") 

245 

246 available_adapters = await self.config_manager.get_available_adapters() 

247 

248 # Group and display adapters 

249 categories = self._group_adapters_by_category(available_adapters) 

250 adapter_choices = self._display_adapter_choices(categories) 

251 

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="") 

258 

259 # Return recommended or parsed selection 

260 if not selection.strip(): 

261 return self._get_recommended_adapters(available_adapters) 

262 

263 return self._parse_adapter_selection(selection, adapter_choices) 

264 

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) 

271 

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 ) 

284 

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 ) 

301 

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]") 

307 

308 adapter_config = config.adapters[adapter_name] 

309 schema = await self.config_manager.get_adapter_configuration_schema( 

310 adapter_name 

311 ) 

312 

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]") 

317 

318 # Configure environment variables 

319 self._configure_adapter_env_vars(adapter_config) 

320 

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) 

326 

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"]) 

333 

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 ) 

339 

340 if env_var.required: 

341 prompt_text = f"[red]*[/red] {env_var.name}" 

342 else: 

343 prompt_text = env_var.name 

344 

345 if env_var.description: 

346 console.print(f"[dim] {env_var.description}[/dim]") 

347 

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) 

352 

353 env_var.value = value 

354 

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 

359 

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) 

371 

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 

378 

379 async def _configure_global_settings(self, config: ConfigurationSchema) -> None: 

380 """Configure global settings.""" 

381 console.print("\n[bold]Global Settings:[/bold]") 

382 

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 ] 

391 

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 ) 

402 

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]") 

411 

412 if result.errors: 

413 console.print("\n[red]Errors:[/red]") 

414 for error in result.errors: 

415 console.print(f" [red]•[/red] {error}") 

416 

417 if result.warnings: 

418 console.print("\n[yellow]Warnings:[/yellow]") 

419 for warning in result.warnings: 

420 console.print(f" [yellow]•[/yellow] {warning}") 

421 

422 

423# CLI Commands 

424 

425 

426@click.group() 

427def config_cli() -> None: 

428 """FastBlocks Configuration Management CLI.""" 

429 pass 

430 

431 

432@config_cli.command() 

433def wizard() -> None: 

434 """Launch the interactive configuration wizard.""" 

435 

436 async def _wizard() -> None: 

437 cli = InteractiveConfigurationCLI() 

438 await cli.initialize() 

439 await cli.run_configuration_wizard() 

440 

441 asyncio.run(_wizard()) 

442 

443 

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.""" 

455 

456 async def _create() -> None: 

457 registry = AdapterRegistry() 

458 config_manager = ConfigurationManager(registry) 

459 await registry.initialize() 

460 await config_manager.initialize() 

461 

462 profile_enum = ConfigurationProfile(profile) 

463 adapter_list = adapters.split(",") if adapters else None 

464 

465 config = await config_manager.create_configuration( 

466 profile=profile_enum, adapters=adapter_list 

467 ) 

468 

469 config_file = await config_manager.save_configuration(config, output) 

470 console.print(f"[green]✓[/green] Configuration created: {config_file}") 

471 

472 asyncio.run(_create()) 

473 

474 

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)) 

484 

485 

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")) 

495 

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}") 

501 

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}") 

507 

508 

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.""" 

514 

515 async def _validate() -> None: 

516 registry = AdapterRegistry() 

517 config_manager = ConfigurationManager(registry) 

518 await registry.initialize() 

519 await config_manager.initialize() 

520 

521 try: 

522 config = await config_manager.load_configuration(config_file) 

523 result = await config_manager.validate_configuration(config) 

524 

525 # Format output based on requested format 

526 if format == "json": 

527 _format_json_output(result) 

528 else: 

529 _format_text_output(result) 

530 

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) 

539 

540 asyncio.run(_validate()) 

541 

542 

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.""" 

548 

549 async def _generate_env() -> None: 

550 registry = AdapterRegistry() 

551 config_manager = ConfigurationManager(registry) 

552 await registry.initialize() 

553 await config_manager.initialize() 

554 

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) 

565 

566 asyncio.run(_generate_env()) 

567 

568 

569@config_cli.command() 

570def list_templates() -> None: 

571 """List available configuration templates.""" 

572 

573 async def _list_templates() -> None: 

574 registry = AdapterRegistry() 

575 config_manager = ConfigurationManager(registry) 

576 await config_manager.initialize() 

577 

578 templates_dir = config_manager.templates_dir 

579 if not templates_dir.exists(): 

580 console.print("[yellow]No templates directory found[/yellow]") 

581 return 

582 

583 templates = list(templates_dir.glob("*.yaml")) 

584 if not templates: 

585 console.print("[yellow]No templates available[/yellow]") 

586 return 

587 

588 console.print("[bold]Available Templates:[/bold]") 

589 for template in sorted(templates): 

590 console.print(f"{template.stem}") 

591 

592 asyncio.run(_list_templates()) 

593 

594 

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.""" 

600 

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 ) 

606 

607 asyncio.run(_backup()) 

608 

609 

610@config_cli.command() 

611def list_backups() -> None: 

612 """List all configuration backups.""" 

613 

614 async def _list_backups() -> None: 

615 registry = AdapterRegistry() 

616 config_manager = ConfigurationManager(registry) 

617 await config_manager.initialize() 

618 

619 backups = await config_manager.list_backups() 

620 

621 if not backups: 

622 console.print("[yellow]No backups found[/yellow]") 

623 return 

624 

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") 

631 

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 ) 

640 

641 console.print(table) 

642 

643 asyncio.run(_list_backups()) 

644 

645 

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.""" 

651 

652 async def _restore() -> None: 

653 registry = AdapterRegistry() 

654 config_manager = ConfigurationManager(registry) 

655 await config_manager.initialize() 

656 

657 try: 

658 config = await config_manager.restore_backup(backup_id) 

659 

660 if output: 

661 config_file = await config_manager.save_configuration(config, output) 

662 else: 

663 config_file = await config_manager.save_configuration(config) 

664 

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) 

669 

670 asyncio.run(_restore()) 

671 

672 

673if __name__ == "__main__": 

674 config_cli()