Coverage for fastblocks / cli.py: 31%

471 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-26 03:58 -0800

1import asyncio 

2import logging 

3import os 

4import signal 

5import typing as t 

6from contextlib import suppress 

7from enum import Enum 

8from importlib.metadata import version as get_version 

9from pathlib import Path 

10from subprocess import DEVNULL 

11from subprocess import run as execute 

12from typing import Annotated 

13 

14with suppress(ImportError): 

15 from acb.logger import InterceptHandler 

16 

17 for logger_name in ("uvicorn", "uvicorn.access", "uvicorn.error"): 

18 logger = logging.getLogger(logger_name) 

19 logger.handlers.clear() 

20 logger.addHandler(InterceptHandler()) 

21 logger.setLevel(logging.DEBUG) 

22 logger.propagate = False 

23 

24import sys 

25 

26import nest_asyncio 

27import typer 

28import uvicorn 

29from acb.actions.encode import dump, load 

30from anyio import Path as AsyncPath 

31from granian import Granian 

32 

33try: 

34 from acb.console import console 

35except ImportError: 

36 # Fallback to regular rich console if acb.console is not available 

37 from rich.console import Console 

38 

39 console = Console() 

40 

41nest_asyncio.apply() 

42__all__ = ("cli", "components", "create", "dev", "run") 

43default_adapters = { 

44 "routes": "default", 

45 "templates": "jinja2", 

46 "auth": "basic", 

47 "sitemap": "asgi", 

48} 

49fastblocks_path = Path(__file__).parent 

50apps_path = Path.cwd() 

51 

52# Check if running in the FastBlocks library directory itself 

53# Skip this check during tests by examining if we're in a test environment 

54 

55is_testing = os.getenv("PYTEST_CURRENT_TEST") or "pytest" in sys.modules 

56if Path.cwd() == fastblocks_path and not is_testing: 

57 msg = "FastBlocks can not be run in the same directory as FastBlocks itself. Run `python -m fastblocks create`. Move into the app directory and try again." 

58 raise SystemExit( 

59 msg, 

60 ) 

61cli = typer.Typer(rich_markup_mode="rich") 

62 

63 

64class Styles(str, Enum): 

65 bulma = "bulma" 

66 webawesome = "webawesome" 

67 custom = "custom" 

68 

69 def __str__(self) -> str: 

70 return t.cast(str, self.value) 

71 

72 

73run_args: dict[str, t.Any] = {"app": "main:app"} 

74dev_args: dict[str, t.Any] = run_args | {"port": 8000, "reload": True} 

75granian_dev_args: dict[str, t.Any] = dev_args | { 

76 "address": "127.0.0.1", 

77 "reload_paths": [Path.cwd(), fastblocks_path], 

78 "interface": "asgi", 

79 "log_enabled": False, 

80 "log_access": True, 

81 "reload_ignore_dirs": ["tmp", "settings", "templates"], 

82} 

83uvicorn_dev_args: dict[str, t.Any] = dev_args | { 

84 "host": "127.0.0.1", 

85 "reload_includes": ["*.py", str(Path.cwd()), str(fastblocks_path)], 

86 "reload_excludes": ["tmp/*", "settings/*", "templates/*"], 

87 "lifespan": "on", 

88} 

89 

90 

91def setup_signal_handlers() -> None: 

92 import sys 

93 

94 def signal_handler(_signum: int, _frame: t.Any) -> None: 

95 sys.exit(0) 

96 

97 signal.signal(signal.SIGINT, signal_handler) 

98 signal.signal(signal.SIGTERM, signal_handler) 

99 

100 

101@cli.command() 

102def run(docker: bool = False, granian: bool = False, host: str = "127.0.0.1") -> None: 

103 if docker: 

104 execute( 

105 f"docker run -it -ePORT=8080 -p8080:8080 {Path.cwd().stem}".split(), 

106 ) 

107 else: 

108 setup_signal_handlers() 

109 if granian: 

110 from granian.constants import Interfaces 

111 

112 Granian("main:app", address=host, interface=Interfaces.ASGI).serve() 

113 else: 

114 uvicorn.run(app=run_args["app"], host=host, lifespan="on", log_config=None) 

115 

116 

117@cli.command() 

118def dev(granian: bool = False) -> None: 

119 setup_signal_handlers() 

120 if granian: 

121 from granian.constants import Interfaces 

122 

123 Granian( 

124 "main:app", 

125 address="127.0.0.1", 

126 port=8000, 

127 reload=True, 

128 reload_paths=[Path.cwd(), fastblocks_path], 

129 interface=Interfaces.ASGI, 

130 log_enabled=False, 

131 log_access=True, 

132 ).serve() 

133 else: 

134 uvicorn.run( 

135 app="main:app", 

136 host="127.0.0.1", 

137 port=8000, 

138 reload=True, 

139 reload_includes=["*.py", str(Path.cwd()), str(fastblocks_path)], 

140 reload_excludes=["tmp/*", "settings/*", "templates/*"], 

141 lifespan="on", 

142 log_config=None, 

143 ) 

144 

145 

146def _display_adapters() -> None: 

147 from acb.adapters import get_adapters 

148 

149 console.print("[bold green]Available Adapters:[/bold green]") 

150 adapters = get_adapters() 

151 if not adapters: 

152 console.print(" [dim]No adapters found[/dim]") 

153 return 

154 categories: dict[str, list[t.Any]] = {} 

155 for adapter in adapters: 

156 if adapter.category not in categories: 

157 categories[adapter.category] = [] 

158 categories[adapter.category].append(adapter) 

159 for category in sorted(categories.keys()): 

160 console.print(f"\n [bold cyan]{category.upper()}:[/bold cyan]") 

161 for adapter in sorted(categories[category], key=lambda a: a.name): 

162 _display_adapter_info(adapter) 

163 

164 

165def _display_adapter_info(adapter: t.Any) -> None: 

166 status = "[green]✓[/green]" if adapter.installed else "[red]✗[/red]" 

167 enabled = "[yellow]enabled[/yellow]" if adapter.enabled else "[dim]disabled[/dim]" 

168 console.print(f" {status} [white]{adapter.name}[/white] - {enabled}") 

169 if adapter.module: 

170 console.print(f" [dim]{adapter.module}[/dim]") 

171 

172 

173def _display_default_config() -> None: 

174 console.print("\n[bold green]FastBlocks Default Configuration:[/bold green]") 

175 for category, default_name in default_adapters.items(): 

176 console.print(f" [cyan]{category}[/cyan]: [white]{default_name}[/white]") 

177 

178 

179def _display_actions() -> None: 

180 console.print("\n[bold green]FastBlocks Actions:[/bold green]") 

181 try: 

182 from fastblocks.actions.minify import minify 

183 

184 console.print(" [cyan]minify[/cyan]:") 

185 console.print(f" [white]- css[/white] ([dim]{minify.css.__name__}[/dim])") 

186 console.print(f" [white]- js[/white] ([dim]{minify.js.__name__}[/dim])") 

187 except ImportError: 

188 console.print(" [dim]Minify actions not available[/dim]") 

189 

190 

191@cli.command() 

192def components() -> None: 

193 try: 

194 console.print("\n[bold blue]FastBlocks Components[/bold blue]\n") 

195 _display_adapters() 

196 _display_default_config() 

197 _display_actions() 

198 _display_htmy_commands() 

199 except Exception as e: 

200 console.print(f"[red]Error displaying components: {e}[/red]") 

201 console.print("[dim]Make sure you're in a FastBlocks project directory[/dim]") 

202 

203 

204def _display_htmy_commands() -> None: 

205 console.print("\n[bold green]HTMY Component Commands:[/bold green]") 

206 console.print(" [cyan]scaffold[/cyan]: Create new HTMY components") 

207 console.print(" [cyan]list[/cyan]: List all discovered components") 

208 console.print(" [cyan]validate[/cyan]: Validate component structure") 

209 console.print(" [cyan]info[/cyan]: Get component metadata") 

210 

211 

212# Component scaffolding helpers 

213_COMPONENT_TYPE_MAP = { 

214 "basic": "BASIC", 

215 "dataclass": "DATACLASS", 

216 "htmx": "HTMX", 

217 "composite": "COMPOSITE", 

218} 

219 

220_TYPE_MAP = { 

221 "str": str, 

222 "int": int, 

223 "float": float, 

224 "bool": bool, 

225 "list": list, 

226 "dict": dict, 

227} 

228 

229 

230def _parse_component_type(type_str: str) -> t.Any: 

231 """Parse component type string to ComponentType enum.""" 

232 from fastblocks.adapters.templates._htmy_components import ComponentType 

233 

234 type_name = _COMPONENT_TYPE_MAP.get(type_str.lower(), "DATACLASS") 

235 return getattr(ComponentType, type_name) 

236 

237 

238def _parse_component_props(props: str) -> dict[str, type]: 

239 """Parse props string into dict of name:type pairs.""" 

240 if not props: 

241 return {} 

242 

243 parsed = {} 

244 for prop_def in props.split(","): 

245 if ":" not in prop_def: 

246 continue 

247 prop_name, prop_type = prop_def.strip().split(":", 1) 

248 parsed[prop_name] = _TYPE_MAP.get(prop_type, str) 

249 return parsed 

250 

251 

252def _parse_component_children(children: str) -> list[str] | None: 

253 """Parse children string into list of component names.""" 

254 if not children: 

255 return None 

256 return [c.strip() for c in children.split(",") if c.strip()] 

257 

258 

259def _build_scaffold_kwargs( 

260 parsed_props: dict[str, t.Any], 

261 htmx: bool, 

262 component_type: t.Any, 

263 endpoint: str, 

264 trigger: str, 

265 target: str, 

266 parsed_children: list[str] | None, 

267) -> dict[str, t.Any]: 

268 """Build kwargs dict for component scaffolding.""" 

269 from fastblocks.adapters.templates._htmy_components import ComponentType 

270 

271 kwargs: dict[str, t.Any] = {} 

272 

273 if parsed_props: 

274 kwargs["props"] = parsed_props 

275 

276 if htmx or component_type == ComponentType.HTMX: 

277 kwargs["htmx_enabled"] = True 

278 if endpoint: 

279 kwargs["endpoint"] = endpoint 

280 kwargs["trigger"] = trigger 

281 kwargs["target"] = target 

282 

283 if parsed_children: 

284 kwargs["children"] = parsed_children 

285 

286 return kwargs 

287 

288 

289@cli.command() 

290def scaffold( 

291 name: Annotated[str, typer.Argument(help="Component name")], 

292 type: Annotated[ 

293 str, 

294 typer.Option( 

295 "--type", "-t", help="Component type: basic, dataclass, htmx, composite" 

296 ), 

297 ] = "dataclass", 

298 htmx: Annotated[ 

299 bool, typer.Option("--htmx", "-x", help="Enable HTMX features") 

300 ] = False, 

301 endpoint: Annotated[ 

302 str, typer.Option("--endpoint", "-e", help="HTMX endpoint URL") 

303 ] = "", 

304 trigger: Annotated[ 

305 str, typer.Option("--trigger", help="HTMX trigger event") 

306 ] = "click", 

307 target: Annotated[ 

308 str, typer.Option("--target", help="HTMX target selector") 

309 ] = "#content", 

310 props: Annotated[ 

311 str, 

312 typer.Option("--props", "-p", help="Component props as 'name:type,name:type'"), 

313 ] = "", 

314 children: Annotated[ 

315 str, typer.Option("--children", "-c", help="Child components as 'comp1,comp2'") 

316 ] = "", 

317 path: Annotated[str, typer.Option("--path", help="Custom component path")] = "", 

318) -> None: 

319 """Scaffold a new HTMY component.""" 

320 import asyncio 

321 from pathlib import Path 

322 

323 async def scaffold_component() -> None: 

324 try: 

325 from acb.depends import depends 

326 

327 # Get HTMY adapter 

328 htmy_adapter = await depends.get("htmy") 

329 if htmy_adapter is None: 

330 console.print( 

331 "[red]HTMY adapter not found. Make sure you're in a FastBlocks project.[/red]" 

332 ) 

333 return 

334 

335 # Parse inputs using helper functions 

336 component_type = _parse_component_type(type) 

337 parsed_props = _parse_component_props(props) 

338 parsed_children = _parse_component_children(children) 

339 

340 # Build kwargs 

341 kwargs = _build_scaffold_kwargs( 

342 parsed_props, 

343 htmx, 

344 component_type, 

345 endpoint, 

346 trigger, 

347 target, 

348 parsed_children, 

349 ) 

350 

351 # Custom path 

352 target_path = Path(path) if path else None 

353 

354 # Scaffold component 

355 created_path = await htmy_adapter.scaffold_component( 

356 name=name, 

357 component_type=component_type, 

358 target_path=target_path, 

359 **kwargs, 

360 ) 

361 

362 console.print( 

363 f"[green]✓[/green] Created {component_type.value} component '{name}' at {created_path}" 

364 ) 

365 

366 except Exception as e: 

367 console.print(f"[red]Error scaffolding component: {e}[/red]") 

368 

369 asyncio.run(scaffold_component()) 

370 

371 

372def _get_component_status_color(status_value: str) -> str: 

373 """Get the color for a component status.""" 

374 return { 

375 "discovered": "yellow", 

376 "validated": "green", 

377 "compiled": "blue", 

378 "ready": "green", 

379 "error": "red", 

380 "deprecated": "dim", 

381 }.get(status_value, "white") 

382 

383 

384def _display_component_entry(name: str, metadata: t.Any) -> None: 

385 """Display a single component entry with status and metadata.""" 

386 status_color = _get_component_status_color(metadata.status.value) 

387 

388 console.print( 

389 f" [{status_color}]●[/{status_color}] [white]{name}[/white] ({metadata.type.value})" 

390 ) 

391 console.print(f" [dim]{metadata.path}[/dim]") 

392 

393 if metadata.error_message: 

394 console.print(f" [red]Error: {metadata.error_message}[/red]") 

395 elif metadata.docstring: 

396 # Show first line of docstring 

397 first_line = metadata.docstring.split("\n")[0].strip() 

398 if first_line: 

399 console.print(f" [dim]{first_line}[/dim]") 

400 

401 

402@cli.command(name="list") 

403def list_components() -> None: 

404 """List all discovered HTMY components.""" 

405 import asyncio 

406 

407 async def list_all_components() -> None: 

408 try: 

409 from acb.depends import depends 

410 

411 htmy_adapter = await depends.get("htmy") 

412 if htmy_adapter is None: 

413 console.print( 

414 "[red]HTMY adapter not found. Make sure you're in a FastBlocks project.[/red]" 

415 ) 

416 return 

417 

418 components = await htmy_adapter.discover_components() 

419 

420 if not components: 

421 console.print("[dim]No components found.[/dim]") 

422 return 

423 

424 console.print( 

425 f"\n[bold green]Found {len(components)} HTMY components:[/bold green]\n" 

426 ) 

427 

428 for name, metadata in components.items(): 

429 _display_component_entry(name, metadata) 

430 

431 except Exception as e: 

432 console.print(f"[red]Error listing components: {e}[/red]") 

433 

434 asyncio.run(list_all_components()) 

435 

436 

437def _display_basic_metadata(component: str, metadata: t.Any) -> None: 

438 """Display basic component metadata.""" 

439 console.print(f"\n[bold blue]Component: {component}[/bold blue]") 

440 console.print(f" [cyan]Type:[/cyan] {metadata.type.value}") 

441 console.print(f" [cyan]Status:[/cyan] {metadata.status.value}") 

442 console.print(f" [cyan]Path:[/cyan] {metadata.path}") 

443 

444 

445def _display_optional_metadata(metadata: t.Any) -> None: 

446 """Display optional component metadata fields.""" 

447 if metadata.last_modified: 

448 console.print(f" [cyan]Modified:[/cyan] {metadata.last_modified}") 

449 

450 if metadata.docstring: 

451 console.print(f" [cyan]Description:[/cyan] {metadata.docstring}") 

452 

453 

454def _display_htmx_attributes(metadata: t.Any) -> None: 

455 """Display HTMX attributes if present.""" 

456 if metadata.htmx_attributes: 

457 console.print(" [cyan]HTMX Attributes:[/cyan]") 

458 for key, value in metadata.htmx_attributes.items(): 

459 console.print(f" {key}: {value}") 

460 

461 

462def _display_dependencies(metadata: t.Any) -> None: 

463 """Display component dependencies if present.""" 

464 if metadata.dependencies: 

465 console.print( 

466 f" [cyan]Dependencies:[/cyan] {', '.join(metadata.dependencies)}" 

467 ) 

468 

469 

470def _display_status_message(metadata: t.Any) -> None: 

471 """Display status-specific message.""" 

472 if metadata.status.value == "error": 

473 console.print(f" [red]Error:[/red] {metadata.error_message}") 

474 elif metadata.status.value == "ready": 

475 console.print(" [green]✓ Component is ready[/green]") 

476 

477 

478@cli.command() 

479def validate( 

480 component: Annotated[str, typer.Argument(help="Component name to validate")], 

481) -> None: 

482 """Validate a specific HTMY component.""" 

483 import asyncio 

484 

485 async def validate_component() -> None: 

486 try: 

487 from acb.depends import depends 

488 

489 htmy_adapter = await depends.get("htmy") 

490 if htmy_adapter is None: 

491 console.print( 

492 "[red]HTMY adapter not found. Make sure you're in a FastBlocks project.[/red]" 

493 ) 

494 return 

495 

496 metadata = await htmy_adapter.validate_component(component) 

497 

498 _display_basic_metadata(component, metadata) 

499 _display_optional_metadata(metadata) 

500 _display_htmx_attributes(metadata) 

501 _display_dependencies(metadata) 

502 _display_status_message(metadata) 

503 

504 except Exception as e: 

505 console.print(f"[red]Error validating component '{component}': {e}[/red]") 

506 

507 asyncio.run(validate_component()) 

508 

509 

510@cli.command() 

511def info( 

512 component: Annotated[str, typer.Argument(help="Component name to get info for")], 

513) -> None: 

514 """Get detailed information about an HTMY component.""" 

515 import asyncio 

516 

517 def _display_component_class_info( 

518 component_class: type, component_name: str 

519 ) -> None: 

520 """Display component class information including dataclass fields and HTMX status.""" 

521 console.print(f"\n[bold blue]Component: {component_name}[/bold blue]") 

522 console.print(f" [cyan]Class:[/cyan] {component_class.__name__}") 

523 console.print(f" [cyan]Module:[/cyan] {component_class.__module__}") 

524 

525 # Check if it's a dataclass 

526 from dataclasses import fields, is_dataclass 

527 

528 if is_dataclass(component_class): 

529 console.print(" [cyan]Fields:[/cyan]") 

530 for field in fields(component_class): 

531 console.print(f" {field.name}: {field.type}") 

532 

533 # Check for HTMX mixin 

534 from fastblocks.adapters.templates._htmy_components import HTMXComponentMixin 

535 

536 if issubclass(component_class, HTMXComponentMixin): 

537 console.print(" [cyan]HTMX Enabled:[/cyan] Yes") 

538 

539 def _display_component_metadata(metadata: t.Any) -> None: 

540 """Display component metadata information.""" 

541 console.print(f" [cyan]Type:[/cyan] {metadata.type.value}") 

542 console.print(f" [cyan]Status:[/cyan] {metadata.status.value}") 

543 console.print(f" [cyan]Path:[/cyan] {metadata.path}") 

544 

545 async def get_component_info() -> None: 

546 try: 

547 from acb.depends import depends 

548 

549 htmy_adapter = await depends.get("htmy") 

550 if htmy_adapter is None: 

551 console.print( 

552 "[red]HTMY adapter not found. Make sure you're in a FastBlocks project.[/red]" 

553 ) 

554 return 

555 

556 # Get component metadata 

557 metadata = await htmy_adapter.validate_component(component) 

558 

559 # Try to get component class for more info 

560 try: 

561 component_class = await htmy_adapter.get_component_class(component) 

562 _display_component_class_info(component_class, component) 

563 except Exception as e: 

564 console.print(f"[red]Could not load component class: {e}[/red]") 

565 

566 # Show metadata 

567 _display_component_metadata(metadata) 

568 

569 except Exception as e: 

570 console.print( 

571 f"[red]Error getting info for component '{component}': {e}[/red]" 

572 ) 

573 

574 asyncio.run(get_component_info()) 

575 

576 

577def _get_severity_color(severity: str) -> str: 

578 """Get the color for an error severity level.""" 

579 return { 

580 "error": "red", 

581 "warning": "yellow", 

582 "info": "blue", 

583 "hint": "dim", 

584 }.get(severity, "white") 

585 

586 

587def _display_syntax_error(error: t.Any) -> None: 

588 """Display a single syntax error with formatting.""" 

589 severity_color = _get_severity_color(error.severity) 

590 

591 console.print( 

592 f" [{severity_color}]{error.severity.upper()}[/{severity_color}] " 

593 f"Line {error.line + 1}, Column {error.column + 1}: {error.message}" 

594 ) 

595 

596 if error.fix_suggestion: 

597 console.print(f" [dim]Fix: {error.fix_suggestion}[/dim]") 

598 

599 if error.code: 

600 console.print(f" [dim]Code: {error.code}[/dim]") 

601 

602 

603def _display_syntax_errors(file_path: str, errors: list[t.Any]) -> None: 

604 """Display all syntax errors for a file.""" 

605 console.print(f"\n[bold red]Syntax errors found in {file_path}:[/bold red]") 

606 for error in errors: 

607 _display_syntax_error(error) 

608 

609 

610@cli.command() 

611def syntax_check( 

612 file_path: Annotated[ 

613 str, typer.Argument(help="Path to FastBlocks template file to check") 

614 ], 

615 format_output: Annotated[ 

616 bool, typer.Option("--format", help="Format the output for better readability") 

617 ] = False, 

618) -> None: 

619 """Check FastBlocks template syntax for errors and warnings.""" 

620 import asyncio 

621 

622 async def check_syntax() -> None: 

623 try: 

624 from pathlib import Path 

625 

626 from acb.depends import depends 

627 

628 syntax_support = await depends.get("syntax_support") 

629 if syntax_support is None: 

630 console.print( 

631 "[red]Syntax support not available. Make sure you're in a FastBlocks project.[/red]" 

632 ) 

633 return 

634 

635 template_path = Path(file_path) 

636 if not template_path.exists(): 

637 console.print(f"[red]File not found: {file_path}[/red]") 

638 return 

639 

640 content = template_path.read_text() 

641 errors = syntax_support.check_syntax(content, template_path) 

642 

643 if not errors: 

644 console.print(f"[green]✓ No syntax errors found in {file_path}[/green]") 

645 return 

646 

647 _display_syntax_errors(file_path, errors) 

648 

649 except Exception as e: 

650 console.print(f"[red]Error checking syntax: {e}[/red]") 

651 

652 asyncio.run(check_syntax()) 

653 

654 

655@cli.command() 

656def format_template( 

657 file_path: Annotated[ 

658 str, typer.Argument(help="Path to FastBlocks template file to format") 

659 ], 

660 in_place: Annotated[ 

661 bool, typer.Option("--in-place", "-i", help="Format file in place") 

662 ] = False, 

663) -> None: 

664 """Format a FastBlocks template file.""" 

665 import asyncio 

666 

667 async def format_file() -> None: 

668 try: 

669 from pathlib import Path 

670 

671 from acb.depends import depends 

672 

673 syntax_support = await depends.get("syntax_support") 

674 if syntax_support is None: 

675 console.print( 

676 "[red]Syntax support not available. Make sure you're in a FastBlocks project.[/red]" 

677 ) 

678 return 

679 

680 template_path = Path(file_path) 

681 if not template_path.exists(): 

682 console.print(f"[red]File not found: {file_path}[/red]") 

683 return 

684 

685 content = template_path.read_text() 

686 formatted = syntax_support.format_template(content) 

687 

688 if formatted == content: 

689 console.print(f"[green]✓ File {file_path} is already formatted[/green]") 

690 return 

691 

692 if in_place: 

693 template_path.write_text(formatted) 

694 console.print(f"[green]✓ Formatted {file_path} in place[/green]") 

695 else: 

696 console.print(formatted) 

697 

698 except Exception as e: 

699 console.print(f"[red]Error formatting template: {e}[/red]") 

700 

701 asyncio.run(format_file()) 

702 

703 

704@cli.command() 

705def generate_ide_config( 

706 output_dir: Annotated[ 

707 str, 

708 typer.Option("--output", "-o", help="Output directory for IDE configurations"), 

709 ] = ".vscode", 

710 ide: Annotated[ 

711 str, typer.Option(help="IDE to generate config for (vscode, vim, emacs)") 

712 ] = "vscode", 

713) -> None: 

714 """Generate IDE configuration files for FastBlocks syntax support.""" 

715 import asyncio 

716 import json 

717 

718 async def generate_config() -> None: 

719 try: 

720 from pathlib import Path 

721 

722 output_path = Path(output_dir) 

723 output_path.mkdir(exist_ok=True) 

724 

725 if ide == "vscode": 

726 # Generate VS Code extension configuration 

727 from fastblocks.adapters.templates._language_server import ( 

728 generate_textmate_grammar, 

729 generate_vscode_extension, 

730 ) 

731 

732 # Package.json for extension 

733 package_json = generate_vscode_extension() 

734 (output_path / "package.json").write_text( 

735 json.dumps(package_json, indent=2) 

736 ) 

737 

738 # TextMate grammar 

739 grammar = generate_textmate_grammar() 

740 syntaxes_dir = output_path / "syntaxes" 

741 syntaxes_dir.mkdir(exist_ok=True) 

742 (syntaxes_dir / "fastblocks.tmLanguage.json").write_text( 

743 json.dumps(grammar, indent=2) 

744 ) 

745 

746 # Language configuration 

747 lang_config = { 

748 "comments": {"blockComment": ["[#", "#]"]}, 

749 "brackets": [["[[", "]]"], ["[%", "%]"], ["[#", "#]"]], 

750 "autoClosingPairs": [ 

751 {"open": "[[", "close": "]]"}, 

752 {"open": "[%", "close": "%]"}, 

753 {"open": "[#", "close": "#]"}, 

754 {"open": '"', "close": '"'}, 

755 {"open": "'", "close": "'"}, 

756 ], 

757 "surroundingPairs": [ 

758 ["[[", "]]"], 

759 ["[%", "%]"], 

760 ["[#", "#]"], 

761 ['"', '"'], 

762 ["'", "'"], 

763 ], 

764 } 

765 (output_path / "language-configuration.json").write_text( 

766 json.dumps(lang_config, indent=2) 

767 ) 

768 

769 # Settings for FastBlocks language server 

770 settings = { 

771 "fastblocks.languageServer.enabled": True, 

772 "fastblocks.languageServer.port": 7777, 

773 "fastblocks.completion.enabled": True, 

774 "fastblocks.diagnostics.enabled": True, 

775 "files.associations": { 

776 "*.fb.html": "fastblocks", 

777 "*.fastblocks": "fastblocks", 

778 }, 

779 } 

780 (output_path / "settings.json").write_text( 

781 json.dumps(settings, indent=2) 

782 ) 

783 

784 console.print( 

785 f"[green]✓ VS Code configuration generated in {output_path}[/green]" 

786 ) 

787 console.print(" Files created:") 

788 console.print(" - package.json (extension manifest)") 

789 console.print(" - language-configuration.json") 

790 console.print(" - syntaxes/fastblocks.tmLanguage.json") 

791 console.print(" - settings.json") 

792 

793 elif ide == "vim": 

794 # Generate Vim configuration 

795 vim_syntax = """ 

796" Vim syntax file for FastBlocks templates 

797" Language: FastBlocks 

798" Maintainer: FastBlocks Team 

799 

800if exists("b:current_syntax") 

801 finish 

802endif 

803 

804" FastBlocks delimiters 

805syn region fastblocksVariable start="\\[\\[" end="\\]\\]" contains=fastblocksFilter,fastblocksString 

806syn region fastblocksBlock start="\\[%" end="%\\]" contains=fastblocksKeyword,fastblocksString 

807syn region fastblocksComment start="\\[#" end="#\\]" 

808 

809" Keywords 

810syn keyword fastblocksKeyword if else elif endif for endfor block endblock extends include set macro endmacro 

811 

812" Filters 

813syn match fastblocksFilter "|\\s*\\w\\+" contained 

814 

815" Strings 

816syn region fastblocksString start='"' end='"' contained 

817syn region fastblocksString start="'" end="'" contained 

818 

819" Highlighting 

820hi def link fastblocksVariable Special 

821hi def link fastblocksBlock Keyword 

822hi def link fastblocksComment Comment 

823hi def link fastblocksKeyword Statement 

824hi def link fastblocksFilter Function 

825hi def link fastblocksString String 

826 

827let b:current_syntax = "fastblocks" 

828""" 

829 (output_path / "fastblocks.vim").write_text(vim_syntax) 

830 console.print( 

831 f"[green]✓ Vim syntax file generated: {output_path}/fastblocks.vim[/green]" 

832 ) 

833 

834 elif ide == "emacs": 

835 # Generate Emacs configuration 

836 emacs_mode = """ 

837;;; fastblocks-mode.el --- Major mode for FastBlocks templates 

838 

839(defvar fastblocks-mode-syntax-table 

840 (let ((table (make-syntax-table))) 

841 (modify-syntax-entry ?\" "\\\"" table) 

842 (modify-syntax-entry ?\' "\\\"" table) 

843 table) 

844 "Syntax table for `fastblocks-mode'.") 

845 

846(defvar fastblocks-font-lock-keywords 

847 '(("\\\\[\\\\[.*?\\\\]\\\\]" . font-lock-variable-name-face) 

848 ("\\\\[%.*?%\\\\]" . font-lock-keyword-face) 

849 ("\\\\[#.*?#\\\\]" . font-lock-comment-face) 

850 ("\\\\b\\\\(if\\\\|else\\\\|elif\\\\|endif\\\\|for\\\\|endfor\\\\|block\\\\|endblock\\\\|extends\\\\|include\\\\|set\\\\|macro\\\\|endmacro\\\\)\\\\b" . font-lock-builtin-face)) 

851 "Font lock keywords for FastBlocks mode.") 

852 

853(define-derived-mode fastblocks-mode html-mode "FastBlocks" 

854 "Major mode for editing FastBlocks templates." 

855 (setq font-lock-defaults '(fastblocks-font-lock-keywords))) 

856 

857(add-to-list 'auto-mode-alist '("\\\\.fb\\\\.html\\\\'" . fastblocks-mode)) 

858(add-to-list 'auto-mode-alist '("\\\\.fastblocks\\\\'" . fastblocks-mode)) 

859 

860(provide 'fastblocks-mode) 

861;;; fastblocks-mode.el ends here 

862""" 

863 (output_path / "fastblocks-mode.el").write_text(emacs_mode) 

864 console.print( 

865 f"[green]✓ Emacs mode file generated: {output_path}/fastblocks-mode.el[/green]" 

866 ) 

867 

868 else: 

869 console.print(f"[red]Unsupported IDE: {ide}[/red]") 

870 console.print("Supported IDEs: vscode, vim, emacs") 

871 

872 except Exception as e: 

873 console.print(f"[red]Error generating IDE configuration: {e}[/red]") 

874 

875 asyncio.run(generate_config()) 

876 

877 

878@cli.command() 

879def start_language_server( 

880 port: Annotated[ 

881 int, typer.Option("--port", "-p", help="Port to run language server on") 

882 ] = 7777, 

883 host: Annotated[ 

884 str, typer.Option("--host", help="Host to bind language server to") 

885 ] = "localhost", 

886 stdio: Annotated[ 

887 bool, typer.Option("--stdio", help="Use stdio instead of TCP") 

888 ] = False, 

889) -> None: 

890 """Start the FastBlocks Language Server.""" 

891 import asyncio 

892 

893 async def start_server() -> None: 

894 try: 

895 from acb.depends import depends 

896 

897 language_server = await depends.get("language_server") 

898 if language_server is None: 

899 console.print( 

900 "[red]Language server not available. Make sure you're in a FastBlocks project.[/red]" 

901 ) 

902 return 

903 

904 if stdio: 

905 console.print( 

906 "[blue]Starting FastBlocks Language Server in stdio mode...[/blue]" 

907 ) 

908 # In a real implementation, this would handle stdio communication 

909 console.print( 

910 "[yellow]Stdio mode not yet implemented. Use TCP mode.[/yellow]" 

911 ) 

912 else: 

913 console.print( 

914 f"[blue]Starting FastBlocks Language Server on {host}:{port}...[/blue]" 

915 ) 

916 console.print("[green]Language Server started successfully![/green]") 

917 console.print(f"Connect your IDE to: {host}:{port}") 

918 

919 # Keep server running using event-based approach 

920 stop_event = asyncio.Event() 

921 try: 

922 await stop_event.wait() 

923 except KeyboardInterrupt: 

924 console.print("\n[yellow]Language server stopped.[/yellow]") 

925 

926 except Exception as e: 

927 console.print(f"[red]Error starting language server: {e}[/red]") 

928 

929 asyncio.run(start_server()) 

930 

931 

932@cli.command() 

933def create( 

934 app_name: Annotated[ 

935 str, 

936 typer.Option(prompt=True, help="Name of your application"), 

937 ], 

938 style: Annotated[ 

939 Styles, 

940 typer.Option( 

941 prompt=True, 

942 help="The style (css, or web component, framework) you want to use[{','.join(Styles._member_names_)}]", 

943 ), 

944 ] = Styles.bulma, 

945 domain: Annotated[ 

946 str, 

947 typer.Option(prompt=True, help="Application domain"), 

948 ] = "example.com", 

949) -> None: 

950 app_path = apps_path / app_name 

951 app_path.mkdir(exist_ok=True) 

952 os.chdir(app_path) 

953 templates = Path("templates") 

954 for p in ( 

955 templates / "base/blocks", 

956 templates / f"{style}/blocks", 

957 templates / f"{style}/theme", 

958 Path("adapters"), 

959 Path("actions"), 

960 ): 

961 p.mkdir(parents=True, exist_ok=True) 

962 for p in ( 

963 Path("models.py"), 

964 Path("routes.py"), 

965 Path("main.py"), 

966 Path(".envrc"), 

967 Path("pyproject.toml"), 

968 Path("__init__.py"), 

969 Path("adapters/__init__.py"), 

970 Path("actions/__init__.py"), 

971 ): 

972 p.touch() 

973 for template_file in ( 

974 "main.py.tmpl", 

975 ".envrc", 

976 "pyproject.toml.tmpl", 

977 "Procfile.tmpl", 

978 ): 

979 template_path = Path(template_file) 

980 target_path = Path(template_file.replace(".tmpl", "")) 

981 target_path.write_text( 

982 (fastblocks_path / template_path).read_text().replace("APP_NAME", app_name), 

983 ) 

984 commands = ( 

985 ["direnv", "allow", "."], 

986 ["pdm", "install"], 

987 ["python", "-m", "fastblocks", "run"], 

988 ) 

989 for command in commands: 

990 execute(command, stdout=DEVNULL, stderr=DEVNULL) 

991 

992 async def update_settings(settings: str, values: dict[str, t.Any]) -> None: 

993 settings_path = AsyncPath(app_path / "settings") 

994 settings_dict = await load.yaml(settings_path / f"{settings}.yml") 

995 settings_dict.update(values) 

996 await dump.yaml(settings_dict, settings_path / f"{settings}.yml") 

997 

998 async def update_configs() -> None: 

999 await update_settings("debug", {"fastblocks": False}) 

1000 await update_settings("adapters", default_adapters) 

1001 await update_settings( 

1002 "app", {"title": "Welcome to FastBlocks", "domain": domain} 

1003 ) 

1004 

1005 asyncio.run(update_configs()) 

1006 console.print( 

1007 f"\n[bold][white]Project is initialized. Please configure [green]'adapters.yml'[/] and [green]'app.yml'[/] in the [blue]'{app_name}/settings'[/] directory before running [magenta]`python -m fastblocks dev`[/] or [magenta]`python -m fastblocks run`[/] from the [blue]'{app_name}'[/] directory.[/][/]", 

1008 ) 

1009 console.print( 

1010 "\n[dim]Use [white]`python -m fastblocks components`[/white] to see available adapters and actions.[/dim]", 

1011 ) 

1012 raise SystemExit 

1013 

1014 

1015@cli.command() 

1016def version() -> None: 

1017 try: 

1018 __version__ = get_version("fastblocks") 

1019 console.print(f"FastBlocks v{__version__}") 

1020 except Exception: 

1021 console.print("Unable to determine FastBlocks version") 

1022 

1023 

1024@cli.command() 

1025def mcp( 

1026 port: Annotated[ 

1027 int, 

1028 typer.Option("--port", "-p", help="Port for MCP server (default: auto)"), 

1029 ] = 0, 

1030 host: Annotated[ 

1031 str, typer.Option("--host", help="Host to bind MCP server to") 

1032 ] = "localhost", 

1033) -> None: 

1034 """Start the FastBlocks MCP (Model Context Protocol) server. 

1035 

1036 Enables IDE/AI assistant integration for FastBlocks development 

1037 including template management, component creation, and adapter configuration. 

1038 """ 

1039 import asyncio 

1040 

1041 async def start_mcp_server() -> None: 

1042 try: 

1043 console.print("\n[bold blue]FastBlocks MCP Server[/bold blue]") 

1044 console.print("[dim]Model Context Protocol for IDE Integration[/dim]\n") 

1045 

1046 from .mcp import create_fastblocks_mcp_server 

1047 

1048 console.print("[yellow]Initializing MCP server...[/yellow]") 

1049 server = await create_fastblocks_mcp_server() 

1050 

1051 console.print("[green]✓ MCP server initialized[/green]") 

1052 

1053 if port: 

1054 console.print(f"[blue]Starting server on {host}:{port}...[/blue]") 

1055 else: 

1056 console.print("[blue]Starting server...[/blue]") 

1057 

1058 console.print("[green]✓ FastBlocks MCP server running[/green]") 

1059 console.print("\n[dim]Press Ctrl+C to stop[/dim]\n") 

1060 

1061 await server.start() 

1062 

1063 except KeyboardInterrupt: 

1064 console.print("\n[yellow]MCP server stopped by user[/yellow]") 

1065 except ImportError as e: 

1066 console.print(f"[red]MCP dependencies not available: {e}[/red]") 

1067 console.print( 

1068 "[dim]Make sure ACB is installed with MCP support (acb>=0.23.0)[/dim]" 

1069 ) 

1070 except Exception as e: 

1071 console.print(f"[red]Error starting MCP server: {e}[/red]") 

1072 

1073 asyncio.run(start_mcp_server())