Coverage for fastblocks/adapters/templates/advanced_manager.py: 27%

406 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-29 00:51 -0700

1"""Advanced Template Management System for FastBlocks Week 7-8. 

2 

3This module provides advanced Jinja2 template management with enhanced features: 

4- Template syntax checking and validation with line-by-line error reporting 

5- Fragment and partial template support for HTMX 

6- Template variable autocomplete for adapter functions 

7- Enhanced security with sandboxed environments 

8- Performance optimization with advanced caching 

9- Context-aware template suggestions 

10 

11Requirements: 

12- jinja2>=3.1.6 

13- jinja2-async-environment>=0.14.3 

14- starlette-async-jinja>=1.12.4 

15 

16Author: lesleslie <les@wedgwoodwebworks.com> 

17Created: 2025-01-12 

18""" 

19 

20import asyncio 

21import re 

22import typing as t 

23from contextlib import suppress 

24from dataclasses import dataclass, field 

25from enum import Enum 

26from uuid import UUID 

27 

28from acb.adapters import AdapterStatus 

29from acb.depends import depends 

30from jinja2 import ( 

31 Environment, 

32 StrictUndefined, 

33 Template, 

34 TemplateError, 

35 TemplateNotFound, 

36 TemplateSyntaxError, 

37 UndefinedError, 

38 meta, 

39) 

40 

41try: 

42 from jinja2.sandbox import SandboxedEnvironment 

43except ImportError: 

44 # Fallback for older Jinja2 versions 

45 SandboxedEnvironment = Environment 

46from jinja2.runtime import StrictUndefined as RuntimeStrictUndefined 

47 

48from .jinja2 import Templates, TemplatesSettings 

49 

50 

51class ValidationLevel(Enum): 

52 """Template validation levels.""" 

53 

54 SYNTAX_ONLY = "syntax_only" 

55 VARIABLES = "variables" 

56 FULL = "full" 

57 

58 

59class SecurityLevel(Enum): 

60 """Template security levels.""" 

61 

62 STANDARD = "standard" 

63 RESTRICTED = "restricted" 

64 SANDBOXED = "sandboxed" 

65 

66 

67@dataclass 

68class TemplateError: 

69 """Represents a template validation error.""" 

70 

71 message: str 

72 line_number: int | None = None 

73 column_number: int | None = None 

74 error_type: str = "validation" 

75 severity: str = "error" # error, warning, info 

76 template_name: str | None = None 

77 context: str | None = None # surrounding code context 

78 

79 

80@dataclass 

81class TemplateValidationResult: 

82 """Result of template validation.""" 

83 

84 is_valid: bool 

85 errors: list[TemplateError] = field(default_factory=list) 

86 warnings: list[TemplateError] = field(default_factory=list) 

87 suggestions: list[str] = field(default_factory=list) 

88 used_variables: set[str] = field(default_factory=set) 

89 undefined_variables: set[str] = field(default_factory=set) 

90 available_filters: set[str] = field(default_factory=set) 

91 available_functions: set[str] = field(default_factory=set) 

92 

93 

94@dataclass 

95class FragmentInfo: 

96 """Information about a template fragment.""" 

97 

98 name: str 

99 template_path: str 

100 block_name: str | None = None 

101 start_line: int | None = None 

102 end_line: int | None = None 

103 variables: set[str] = field(default_factory=set) 

104 dependencies: set[str] = field(default_factory=set) 

105 

106 

107@dataclass 

108class AutocompleteItem: 

109 """Autocomplete suggestion item.""" 

110 

111 name: str 

112 type: str # variable, filter, function, block 

113 description: str | None = None 

114 signature: str | None = None 

115 adapter_source: str | None = None 

116 example: str | None = None 

117 

118 

119class AdvancedTemplatesSettings(TemplatesSettings): 

120 """Advanced template settings with enhanced features.""" 

121 

122 # Validation settings 

123 validation_level: ValidationLevel = ValidationLevel.VARIABLES 

124 validate_on_load: bool = True 

125 strict_undefined: bool = True 

126 

127 # Security settings 

128 security_level: SecurityLevel = SecurityLevel.STANDARD 

129 sandbox_allowed_attributes: list[str] = field( 

130 default_factory=lambda: [ 

131 "alt", 

132 "class", 

133 "id", 

134 "src", 

135 "href", 

136 "title", 

137 "width", 

138 "height", 

139 ] 

140 ) 

141 sandbox_allowed_tags: list[str] = field( 

142 default_factory=lambda: [ 

143 "div", 

144 "span", 

145 "p", 

146 "a", 

147 "img", 

148 "h1", 

149 "h2", 

150 "h3", 

151 "h4", 

152 "h5", 

153 "h6", 

154 "ul", 

155 "ol", 

156 "li", 

157 "strong", 

158 "em", 

159 "br", 

160 "hr", 

161 ] 

162 ) 

163 

164 # Fragment/Partial settings 

165 enable_fragments: bool = True 

166 fragment_prefix: str = "_" 

167 auto_discover_fragments: bool = True 

168 

169 # Autocomplete settings 

170 enable_autocomplete: bool = True 

171 scan_adapter_functions: bool = True 

172 cache_autocomplete: bool = True 

173 

174 # Performance settings 

175 enable_template_cache: bool = True 

176 template_cache_size: int = 1000 

177 enable_compiled_cache: bool = True 

178 precompile_templates: bool = False 

179 

180 # Advanced error handling 

181 detailed_errors: bool = True 

182 show_context_lines: int = 3 

183 enable_error_suggestions: bool = True 

184 

185 def __init__(self, **data: t.Any) -> None: 

186 super().__init__(**data) 

187 

188 

189class AdvancedTemplateManager: 

190 """Advanced template management with validation, fragments, and autocomplete.""" 

191 

192 def __init__(self, settings: AdvancedTemplatesSettings | None = None) -> None: 

193 self.settings = settings or AdvancedTemplatesSettings() 

194 self.base_templates: Templates | None = None 

195 self._validation_cache: dict[str, TemplateValidationResult] = {} 

196 self._fragment_cache: dict[str, list[FragmentInfo]] = {} 

197 self._autocomplete_cache: dict[str, list[AutocompleteItem]] = {} 

198 self._template_dependencies: dict[str, set[str]] = {} 

199 

200 async def initialize(self) -> None: 

201 """Initialize the advanced template manager.""" 

202 # Get base templates instance 

203 try: 

204 self.base_templates = depends.get("templates") 

205 except Exception: 

206 self.base_templates = Templates() 

207 if not self.base_templates.app: 

208 await self.base_templates.init() 

209 

210 # Setup advanced features 

211 if self.settings.enable_fragments: 

212 await self._discover_fragments() 

213 

214 if self.settings.enable_autocomplete: 

215 await self._build_autocomplete_index() 

216 

217 def _get_template_environment(self, secure: bool = False) -> Environment: 

218 """Get Jinja2 environment with appropriate security settings.""" 

219 if not self.base_templates or not self.base_templates.app: 

220 raise RuntimeError("Base templates not initialized") 

221 

222 env = self.base_templates.app.env 

223 

224 if secure and self.settings.security_level == SecurityLevel.SANDBOXED: 

225 # Create sandboxed environment 

226 sandbox_env = SandboxedEnvironment( 

227 loader=env.loader, 

228 extensions=env.extensions, 

229 undefined=StrictUndefined 

230 if self.settings.strict_undefined 

231 else RuntimeStrictUndefined, 

232 ) 

233 

234 # Apply security restrictions 

235 sandbox_env.allowed_tags = set(self.settings.sandbox_allowed_tags) 

236 sandbox_env.allowed_attributes = set( 

237 self.settings.sandbox_allowed_attributes 

238 ) 

239 

240 return sandbox_env 

241 

242 return env 

243 

244 async def validate_template( 

245 self, 

246 template_source: str, 

247 template_name: str = "unknown", 

248 context: dict[str, t.Any] | None = None, 

249 ) -> TemplateValidationResult: 

250 """Validate template syntax and variables with detailed error reporting.""" 

251 # Check cache first 

252 cache_key = f"{template_name}:{hash(template_source)}" 

253 if cache_key in self._validation_cache: 

254 return self._validation_cache[cache_key] 

255 

256 result = TemplateValidationResult(is_valid=True) 

257 env = self._get_template_environment() 

258 

259 try: 

260 # Parse template for syntax validation 

261 parsed = env.parse(template_source, template_name) 

262 

263 # Extract variables and blocks 

264 used_vars = meta.find_undeclared_variables(parsed) 

265 result.used_variables = used_vars 

266 

267 # Get available variables from context and adapters 

268 available_vars = self._get_available_variables(context) 

269 result.undefined_variables = used_vars - available_vars 

270 

271 # Get available filters and functions 

272 result.available_filters = set(env.filters.keys()) 

273 result.available_functions = set(env.globals.keys()) 

274 

275 # Validate variables if required 

276 if self.settings.validation_level in ( 

277 ValidationLevel.VARIABLES, 

278 ValidationLevel.FULL, 

279 ): 

280 await self._validate_variables(result, template_source, template_name) 

281 

282 # Full validation includes template compilation 

283 if self.settings.validation_level == ValidationLevel.FULL: 

284 await self._validate_compilation( 

285 result, template_source, template_name, env 

286 ) 

287 

288 except TemplateSyntaxError as e: 

289 result.is_valid = False 

290 error = TemplateError( 

291 message=str(e), 

292 line_number=e.lineno, 

293 error_type="syntax", 

294 template_name=template_name, 

295 context=self._get_error_context(template_source, e.lineno), 

296 ) 

297 result.errors.append(error) 

298 

299 except Exception as e: 

300 result.is_valid = False 

301 error = TemplateError( 

302 message=f"Validation error: {e}", 

303 error_type="general", 

304 template_name=template_name, 

305 ) 

306 result.errors.append(error) 

307 

308 # Add suggestions for improvements 

309 if self.settings.enable_error_suggestions: 

310 await self._add_suggestions(result, template_source) 

311 

312 # Cache result 

313 self._validation_cache[cache_key] = result 

314 return result 

315 

316 def _get_available_variables( 

317 self, context: dict[str, t.Any] | None = None 

318 ) -> set[str]: 

319 """Get all available variables from context and adapters.""" 

320 available = set() 

321 

322 # Add context variables 

323 if context: 

324 available.update(context.keys()) 

325 

326 # Add adapter variables 

327 available.update( 

328 ["config", "request", "models", "render_block", "render_component"] 

329 ) 

330 

331 # Add adapter functions 

332 try: 

333 for adapter_name in [ 

334 "images", 

335 "icons", 

336 "fonts", 

337 "styles", 

338 "cache", 

339 "storage", 

340 ]: 

341 try: 

342 adapter = depends.get(adapter_name) 

343 if adapter: 

344 available.add(adapter_name) 

345 except Exception: 

346 pass 

347 except Exception: 

348 pass 

349 

350 return available 

351 

352 async def _validate_variables( 

353 self, result: TemplateValidationResult, template_source: str, template_name: str 

354 ) -> None: 

355 """Validate variable usage in template.""" 

356 lines = template_source.split("\n") 

357 

358 for line_num, line in enumerate(lines, 1): 

359 # Find variable usage patterns 

360 var_pattern = re.compile(r"\[\[\s*([^|\[\]]+?)(?:\s*\|[^|\[\]]*?)?\s*\]\]") 

361 matches = var_pattern.finditer(line) 

362 

363 for match in matches: 

364 var_expr = match.group(1).strip() 

365 base_var = var_expr.split(".")[0].split("(")[0].strip() 

366 

367 if base_var in result.undefined_variables: 

368 error = TemplateError( 

369 message=f"Undefined variable: {base_var}", 

370 line_number=line_num, 

371 column_number=match.start(), 

372 error_type="undefined_variable", 

373 severity="warning" 

374 if self._is_safe_undefined(base_var) 

375 else "error", 

376 template_name=template_name, 

377 context=line.strip(), 

378 ) 

379 

380 if error.severity == "error": 

381 result.errors.append(error) 

382 result.is_valid = False 

383 else: 

384 result.warnings.append(error) 

385 

386 def _is_safe_undefined(self, var_name: str) -> bool: 

387 """Check if undefined variable is potentially safe (like optional context).""" 

388 safe_patterns = ["user", "session", "flash", "messages", "csrf_token"] 

389 return any(pattern in var_name.lower() for pattern in safe_patterns) 

390 

391 async def _validate_compilation( 

392 self, 

393 result: TemplateValidationResult, 

394 template_source: str, 

395 template_name: str, 

396 env: Environment, 

397 ) -> None: 

398 """Validate template compilation with mock context.""" 

399 try: 

400 template = env.from_string(template_source, template_class=Template) 

401 

402 # Create mock context for testing 

403 mock_context = self._create_mock_context(result.used_variables) 

404 

405 # Try to render with mock context 

406 await asyncio.get_event_loop().run_in_executor( 

407 None, template.render, mock_context 

408 ) 

409 

410 except UndefinedError: 

411 # This is expected for some undefined variables 

412 pass 

413 except Exception as e: 

414 result.is_valid = False 

415 error = TemplateError( 

416 message=f"Compilation error: {e}", 

417 error_type="compilation", 

418 template_name=template_name, 

419 ) 

420 result.errors.append(error) 

421 

422 def _create_mock_context(self, variables: set[str]) -> dict[str, t.Any]: 

423 """Create mock context for template validation.""" 

424 mock_context = {} 

425 

426 for var in variables: 

427 if "." in var: 

428 # Handle nested variables 

429 parts = var.split(".") 

430 current = mock_context 

431 for part in parts[:-1]: 

432 if part not in current: 

433 current[part] = {} 

434 current = current[part] 

435 current[parts[-1]] = "mock_value" 

436 else: 

437 mock_context[var] = "mock_value" 

438 

439 return mock_context 

440 

441 def _get_error_context( 

442 self, template_source: str, line_number: int | None 

443 ) -> str | None: 

444 """Get surrounding lines for error context.""" 

445 if not line_number or not self.settings.detailed_errors: 

446 return None 

447 

448 lines = template_source.split("\n") 

449 start = max(0, line_number - self.settings.show_context_lines - 1) 

450 end = min(len(lines), line_number + self.settings.show_context_lines) 

451 

452 context_lines = [] 

453 for i in range(start, end): 

454 marker = ">>> " if i == line_number - 1 else " " 

455 context_lines.append(f"{marker}{i + 1:4d}: {lines[i]}") 

456 

457 return "\n".join(context_lines) 

458 

459 async def _add_suggestions( 

460 self, result: TemplateValidationResult, template_source: str 

461 ) -> None: 

462 """Add helpful suggestions for template improvements.""" 

463 suggestions = [] 

464 

465 # Suggest available alternatives for undefined variables 

466 for undefined_var in result.undefined_variables: 

467 available = result.used_variables - result.undefined_variables 

468 

469 # Simple fuzzy matching for suggestions 

470 for var in available: 

471 if self._is_similar(undefined_var, var): 

472 suggestions.append( 

473 f"Did you mean '{var}' instead of '{undefined_var}'?" 

474 ) 

475 break 

476 

477 # Suggest filters for common patterns 

478 if "| safe" not in template_source and any( 

479 tag in template_source for tag in ["<", ">"] 

480 ): 

481 suggestions.append("Consider using the '| safe' filter for HTML content") 

482 

483 # Suggest async patterns for image operations 

484 if "image_url(" in template_source and "await" not in template_source: 

485 suggestions.append( 

486 "Consider using 'await async_image_url()' for better performance" 

487 ) 

488 

489 result.suggestions.extend(suggestions) 

490 

491 def _is_similar(self, a: str, b: str, threshold: float = 0.6) -> bool: 

492 """Simple string similarity check.""" 

493 if not a or not b: 

494 return False 

495 

496 # Levenshtein distance approximation 

497 longer = a if len(a) > len(b) else b 

498 shorter = b if len(a) > len(b) else a 

499 

500 if len(longer) == 0: 

501 return True 

502 

503 # Simple similarity based on common characters 

504 common = sum(1 for char in shorter if char in longer) 

505 similarity = common / len(longer) 

506 

507 return similarity >= threshold 

508 

509 async def _discover_fragments(self) -> None: 

510 """Discover and index template fragments for HTMX support.""" 

511 if not self.base_templates: 

512 return 

513 

514 # Get all template paths 

515 env = self._get_template_environment() 

516 if not env.loader: 

517 return 

518 

519 try: 

520 template_names = await asyncio.get_event_loop().run_in_executor( 

521 None, env.loader.list_templates 

522 ) 

523 except Exception: 

524 return 

525 

526 for template_name in template_names: 

527 if template_name.startswith(self.settings.fragment_prefix): 

528 await self._analyze_fragment(template_name) 

529 

530 async def _analyze_fragment(self, template_name: str) -> None: 

531 """Analyze a template fragment and extract metadata.""" 

532 try: 

533 env = self._get_template_environment() 

534 source, _ = env.loader.get_source(env, template_name) 

535 

536 # Parse template to find blocks 

537 parsed = env.parse(source, template_name) 

538 

539 fragments = [] 

540 

541 # Extract block information 

542 for node in parsed.body: 

543 if hasattr(node, "name") and node.name: 

544 fragment = FragmentInfo( 

545 name=node.name, 

546 template_path=template_name, 

547 block_name=node.name, 

548 start_line=getattr(node, "lineno", None), 

549 ) 

550 

551 # Find variables used in this fragment 

552 fragment.variables = meta.find_undeclared_variables(parsed) 

553 fragments.append(fragment) 

554 

555 # If no blocks found, treat entire template as fragment 

556 if not fragments: 

557 fragment = FragmentInfo( 

558 name=template_name.replace(".html", "").replace( 

559 self.settings.fragment_prefix, "" 

560 ), 

561 template_path=template_name, 

562 variables=meta.find_undeclared_variables(parsed), 

563 ) 

564 fragments.append(fragment) 

565 

566 self._fragment_cache[template_name] = fragments 

567 

568 except Exception: 

569 # Log error but continue 

570 pass 

571 

572 async def _build_autocomplete_index(self) -> None: 

573 """Build autocomplete index for template variables and functions.""" 

574 autocomplete_items = [] 

575 

576 # Add built-in Jinja2 items 

577 autocomplete_items.extend(self._get_builtin_autocomplete()) 

578 

579 # Add adapter functions if enabled 

580 if self.settings.scan_adapter_functions: 

581 autocomplete_items.extend(await self._get_adapter_autocomplete()) 

582 

583 # Add template-specific items 

584 autocomplete_items.extend(self._get_template_autocomplete()) 

585 

586 # Cache the results 

587 cache_key = "global" 

588 self._autocomplete_cache[cache_key] = autocomplete_items 

589 

590 def _get_builtin_autocomplete(self) -> list[AutocompleteItem]: 

591 """Get autocomplete items for built-in Jinja2 features.""" 

592 items = [] 

593 

594 # Built-in filters 

595 builtin_filters = [ 

596 ("abs", "filter", "Return absolute value", "number|abs"), 

597 ("attr", "filter", "Get attribute by name", "obj|attr('name')"), 

598 ("batch", "filter", "Batch items into sublists", "items|batch(3)"), 

599 ("capitalize", "filter", "Capitalize first letter", "text|capitalize"), 

600 ("center", "filter", "Center text in field", "text|center(80)"), 

601 ( 

602 "default", 

603 "filter", 

604 "Default value if undefined", 

605 "var|default('fallback')", 

606 ), 

607 ("dictsort", "filter", "Sort dict by key/value", "dict|dictsort"), 

608 ("escape", "filter", "Escape HTML characters", "text|escape"), 

609 ("filesizeformat", "filter", "Format file size", "bytes|filesizeformat"), 

610 ("first", "filter", "Get first item", "items|first"), 

611 ("float", "filter", "Convert to float", "value|float"), 

612 ("format", "filter", "String formatting", "'{0}'.format(value)"), 

613 ("groupby", "filter", "Group by attribute", "items|groupby('category')"), 

614 ("indent", "filter", "Indent text", "text|indent(4)"), 

615 ("int", "filter", "Convert to integer", "value|int"), 

616 ("join", "filter", "Join list with separator", "items|join(', ')"), 

617 ("last", "filter", "Get last item", "items|last"), 

618 ("length", "filter", "Get length", "items|length"), 

619 ("list", "filter", "Convert to list", "value|list"), 

620 ("lower", "filter", "Convert to lowercase", "text|lower"), 

621 ("map", "filter", "Apply filter to each item", "items|map('upper')"), 

622 ("max", "filter", "Get maximum value", "numbers|max"), 

623 ("min", "filter", "Get minimum value", "numbers|min"), 

624 ("random", "filter", "Get random item", "items|random"), 

625 ("reject", "filter", "Reject items by test", "items|reject('odd')"), 

626 ("replace", "filter", "Replace substring", "text|replace('old', 'new')"), 

627 ("reverse", "filter", "Reverse order", "items|reverse"), 

628 ("round", "filter", "Round number", "number|round(2)"), 

629 ("safe", "filter", "Mark as safe HTML", "html|safe"), 

630 ("select", "filter", "Select items by test", "items|select('even')"), 

631 ("slice", "filter", "Slice sequence", "items|slice(3)"), 

632 ("sort", "filter", "Sort items", "items|sort"), 

633 ("string", "filter", "Convert to string", "value|string"), 

634 ("striptags", "filter", "Remove HTML tags", "html|striptags"), 

635 ("sum", "filter", "Sum numeric values", "numbers|sum"), 

636 ("title", "filter", "Title case", "text|title"), 

637 ("trim", "filter", "Strip whitespace", "text|trim"), 

638 ("truncate", "filter", "Truncate text", "text|truncate(50)"), 

639 ("unique", "filter", "Remove duplicates", "items|unique"), 

640 ("upper", "filter", "Convert to uppercase", "text|upper"), 

641 ("urlencode", "filter", "URL encode", "text|urlencode"), 

642 ("urlize", "filter", "Convert URLs to links", "text|urlize"), 

643 ("wordcount", "filter", "Count words", "text|wordcount"), 

644 ("wordwrap", "filter", "Wrap text", "text|wordwrap(80)"), 

645 ] 

646 

647 for name, item_type, description, example in builtin_filters: 

648 items.append( 

649 AutocompleteItem( 

650 name=name, 

651 type=item_type, 

652 description=description, 

653 example=example, 

654 adapter_source="jinja2", 

655 ) 

656 ) 

657 

658 # Built-in functions 

659 builtin_functions = [ 

660 ( 

661 "range", 

662 "function", 

663 "Generate sequence of numbers", 

664 "range(10)", 

665 "range(start, stop, step)", 

666 ), 

667 ( 

668 "lipsum", 

669 "function", 

670 "Generate lorem ipsum text", 

671 "lipsum(5)", 

672 "lipsum(n=5, html=True, min=20, max=100)", 

673 ), 

674 ( 

675 "dict", 

676 "function", 

677 "Create dictionary", 

678 "dict(key='value')", 

679 "dict(**kwargs)", 

680 ), 

681 ( 

682 "cycler", 

683 "function", 

684 "Create value cycler", 

685 "cycler('odd', 'even')", 

686 "cycler(*items)", 

687 ), 

688 ( 

689 "joiner", 

690 "function", 

691 "Create joiner helper", 

692 "joiner(', ')", 

693 "joiner(sep=', ')", 

694 ), 

695 ] 

696 

697 for name, item_type, description, example, signature in builtin_functions: 

698 items.append( 

699 AutocompleteItem( 

700 name=name, 

701 type=item_type, 

702 description=description, 

703 signature=signature, 

704 example=example, 

705 adapter_source="jinja2", 

706 ) 

707 ) 

708 

709 return items 

710 

711 async def _get_adapter_autocomplete(self) -> list[AutocompleteItem]: 

712 """Get autocomplete items for adapter functions.""" 

713 items = [] 

714 

715 # FastBlocks-specific filters from our filter modules 

716 from .async_filters import FASTBLOCKS_ASYNC_FILTERS 

717 from .filters import FASTBLOCKS_FILTERS 

718 

719 # Add sync filters 

720 for name, func in FASTBLOCKS_FILTERS.items(): 

721 doc = func.__doc__ or "" 

722 description = doc.split("\n")[0] if doc else f"FastBlocks {name} filter" 

723 

724 items.append( 

725 AutocompleteItem( 

726 name=name, 

727 type="filter", 

728 description=description, 

729 adapter_source="fastblocks", 

730 example=self._extract_example_from_doc(doc), 

731 ) 

732 ) 

733 

734 # Add async filters 

735 for name, func in FASTBLOCKS_ASYNC_FILTERS.items(): 

736 doc = func.__doc__ or "" 

737 description = ( 

738 doc.split("\n")[0] if doc else f"FastBlocks {name} async filter" 

739 ) 

740 

741 items.append( 

742 AutocompleteItem( 

743 name=name, 

744 type="filter", 

745 description=description, 

746 adapter_source="fastblocks", 

747 example=self._extract_example_from_doc(doc), 

748 ) 

749 ) 

750 

751 # Add adapter-specific functions 

752 adapter_functions = { 

753 "images": ["get_image_url", "get_img_tag", "get_placeholder_url"], 

754 "icons": ["get_icon_tag", "get_icon_with_text"], 

755 "fonts": ["get_font_import", "get_font_family"], 

756 "styles": [ 

757 "get_component_class", 

758 "get_utility_classes", 

759 "build_component_html", 

760 ], 

761 } 

762 

763 for adapter_name, functions in adapter_functions.items(): 

764 try: 

765 adapter = depends.get(adapter_name) 

766 if adapter: 

767 for func_name in functions: 

768 if hasattr(adapter, func_name): 

769 items.append( 

770 AutocompleteItem( 

771 name=f"{adapter_name}.{func_name}", 

772 type="function", 

773 description=f"{adapter_name.title()} adapter function", 

774 adapter_source=adapter_name, 

775 ) 

776 ) 

777 except Exception: 

778 pass 

779 

780 return items 

781 

782 def _get_template_autocomplete(self) -> list[AutocompleteItem]: 

783 """Get autocomplete items for template-specific variables.""" 

784 items = [] 

785 

786 # Common template variables 

787 common_vars = [ 

788 ("config", "variable", "Application configuration object"), 

789 ("request", "variable", "Current HTTP request object"), 

790 ("models", "variable", "Database models"), 

791 ("render_block", "function", "Render template block"), 

792 ("render_component", "function", "Render HTMY component"), 

793 ] 

794 

795 for name, item_type, description in common_vars: 

796 items.append( 

797 AutocompleteItem( 

798 name=name, 

799 type=item_type, 

800 description=description, 

801 adapter_source="fastblocks", 

802 ) 

803 ) 

804 

805 return items 

806 

807 def _extract_example_from_doc(self, doc: str) -> str | None: 

808 """Extract usage example from docstring.""" 

809 if not doc: 

810 return None 

811 

812 lines = doc.split("\n") 

813 in_example = False 

814 example_lines = [] 

815 

816 for line in lines: 

817 line = line.strip() 

818 if line.startswith("[[ ") and line.endswith(" ]]"): 

819 return line 

820 elif "Usage:" in line or "Example:" in line: 

821 in_example = True 

822 elif in_example and line.startswith("[["): 

823 example_lines.append(line) 

824 elif in_example and line and not line.startswith(" "): 

825 break 

826 

827 return example_lines[0] if example_lines else None 

828 

829 async def get_fragments_for_template( 

830 self, template_name: str 

831 ) -> list[FragmentInfo]: 

832 """Get fragments available for a specific template.""" 

833 if template_name in self._fragment_cache: 

834 return self._fragment_cache[template_name] 

835 

836 # Try to discover fragments for this template 

837 await self._analyze_fragment(template_name) 

838 return self._fragment_cache.get(template_name, []) 

839 

840 async def get_autocomplete_suggestions( 

841 self, context: str, cursor_position: int = 0, template_name: str = "unknown" 

842 ) -> list[AutocompleteItem]: 

843 """Get autocomplete suggestions for the given context.""" 

844 cache_key = "global" 

845 if cache_key not in self._autocomplete_cache: 

846 await self._build_autocomplete_index() 

847 

848 all_items = self._autocomplete_cache[cache_key] 

849 

850 # Extract the current word being typed 

851 before_cursor = context[:cursor_position] 

852 current_word = self._extract_current_word(before_cursor) 

853 

854 if not current_word: 

855 return all_items[:20] # Return top 20 suggestions 

856 

857 # Filter suggestions based on current word 

858 filtered = [] 

859 for item in all_items: 

860 if current_word.lower() in item.name.lower(): 

861 filtered.append(item) 

862 

863 # Sort by relevance (exact matches first, then starts with, then contains) 

864 filtered.sort( 

865 key=lambda x: ( 

866 not x.name.lower().startswith(current_word.lower()), 

867 not x.name.lower() == current_word.lower(), 

868 x.name.lower(), 

869 ) 

870 ) 

871 

872 return filtered[:10] # Return top 10 matches 

873 

874 def _extract_current_word(self, text: str) -> str: 

875 """Extract the current word being typed from template context.""" 

876 # Look for word characters at the end of the text 

877 match = re.search(r"[\w.]+$", text) 

878 return match.group(0) if match else "" 

879 

880 async def render_fragment( 

881 self, 

882 fragment_name: str, 

883 context: dict[str, t.Any] | None = None, 

884 template_name: str | None = None, 

885 secure: bool = False, 

886 ) -> str: 

887 """Render a specific template fragment.""" 

888 if not self.base_templates: 

889 raise RuntimeError("Templates not initialized") 

890 

891 # Find the fragment 

892 fragment_info = await self._find_fragment(fragment_name, template_name) 

893 if not fragment_info: 

894 raise TemplateNotFound(f"Fragment '{fragment_name}' not found") 

895 

896 env = self._get_template_environment(secure=secure) 

897 

898 try: 

899 if fragment_info.block_name: 

900 # Render specific block 

901 template = env.get_template(fragment_info.template_path) 

902 return template.render_block(fragment_info.block_name, context or {}) 

903 else: 

904 # Render entire template 

905 template = env.get_template(fragment_info.template_path) 

906 return template.render(context or {}) 

907 

908 except Exception as e: 

909 raise TemplateError(f"Error rendering fragment '{fragment_name}': {e}") 

910 

911 async def _find_fragment( 

912 self, fragment_name: str, template_name: str | None = None 

913 ) -> FragmentInfo | None: 

914 """Find fragment by name, optionally within a specific template.""" 

915 # Search in specific template first 

916 if template_name and template_name in self._fragment_cache: 

917 for fragment in self._fragment_cache[template_name]: 

918 if fragment.name == fragment_name: 

919 return fragment 

920 

921 # Search across all fragments 

922 for fragments in self._fragment_cache.values(): 

923 for fragment in fragments: 

924 if fragment.name == fragment_name: 

925 return fragment 

926 

927 return None 

928 

929 async def precompile_templates(self) -> dict[str, Template]: 

930 """Precompile templates for performance optimization.""" 

931 if not self.settings.precompile_templates: 

932 return {} 

933 

934 env = self._get_template_environment() 

935 if not env.loader: 

936 return {} 

937 

938 compiled_templates = {} 

939 

940 try: 

941 template_names = await asyncio.get_event_loop().run_in_executor( 

942 None, env.loader.list_templates 

943 ) 

944 

945 for template_name in template_names: 

946 try: 

947 template = env.get_template(template_name) 

948 compiled_templates[template_name] = template 

949 except Exception: 

950 # Skip templates that can't be compiled 

951 continue 

952 

953 except Exception: 

954 pass 

955 

956 return compiled_templates 

957 

958 async def get_template_dependencies(self, template_name: str) -> set[str]: 

959 """Get dependencies for a template (extends, includes, imports).""" 

960 if template_name in self._template_dependencies: 

961 return self._template_dependencies[template_name] 

962 

963 dependencies = set() 

964 env = self._get_template_environment() 

965 

966 try: 

967 source, _ = env.loader.get_source(env, template_name) 

968 parsed = env.parse(source, template_name) 

969 

970 # Find extends, includes, and imports 

971 for node in parsed.find_all(("Extends", "Include", "FromImport")): 

972 if hasattr(node, "template") and hasattr(node.template, "value"): 

973 dependencies.add(node.template.value) 

974 

975 self._template_dependencies[template_name] = dependencies 

976 

977 except Exception: 

978 pass 

979 

980 return dependencies 

981 

982 def clear_caches(self) -> None: 

983 """Clear all internal caches.""" 

984 self._validation_cache.clear() 

985 self._fragment_cache.clear() 

986 self._autocomplete_cache.clear() 

987 self._template_dependencies.clear() 

988 

989 

990MODULE_ID = UUID("01937d87-1234-7890-abcd-1234567890ab") 

991MODULE_STATUS = AdapterStatus.EXPERIMENTAL 

992 

993# Register the advanced manager 

994with suppress(Exception): 

995 depends.set("advanced_template_manager", AdvancedTemplateManager)