Coverage for fastblocks / adapters / templates / _advanced_manager.py: 31%

406 statements  

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

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 # type: ignore[no-redef] 

46from jinja2.runtime import StrictUndefined as RuntimeStrictUndefined 

47 

48from .jinja2 import Templates, TemplatesSettings 

49 

50__all__ = [ 

51 "HybridTemplatesManager", 

52 "HybridTemplatesSettings", 

53 "AutocompleteItem", 

54 "FragmentInfo", 

55 "SecurityLevel", 

56 "TemplateError", 

57 "TemplateValidationResult", 

58 "ValidationLevel", 

59] 

60 

61# Module-level constants for autocomplete data 

62_JINJA2_BUILTIN_FILTERS = [ 

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

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

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

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

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

68 ("default", "filter", "Default value if undefined", "var|default('fallback')"), 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

107] 

108 

109_JINJA2_BUILTIN_FUNCTIONS = [ 

110 ( 

111 "range", 

112 "function", 

113 "Generate sequence of numbers", 

114 "range(10)", 

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

116 ), 

117 ( 

118 "lipsum", 

119 "function", 

120 "Generate lorem ipsum text", 

121 "lipsum(5)", 

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

123 ), 

124 ("dict", "function", "Create dictionary", "dict(key='value')", "dict(**kwargs)"), 

125 ( 

126 "cycler", 

127 "function", 

128 "Create value cycler", 

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

130 "cycler(*items)", 

131 ), 

132 ("joiner", "function", "Create joiner helper", "joiner(', ')", "joiner(sep=', ')"), 

133] 

134 

135_ADAPTER_AUTOCOMPLETE_FUNCTIONS = { 

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

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

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

139 "styles": [ 

140 "get_component_class", 

141 "get_utility_classes", 

142 "build_component_html", 

143 ], 

144} 

145 

146 

147class ValidationLevel(Enum): 

148 """Template validation levels.""" 

149 

150 SYNTAX_ONLY = "syntax_only" 

151 VARIABLES = "variables" 

152 FULL = "full" 

153 

154 

155class SecurityLevel(Enum): 

156 """Template security levels.""" 

157 

158 STANDARD = "standard" 

159 RESTRICTED = "restricted" 

160 SANDBOXED = "sandboxed" 

161 

162 

163@dataclass 

164class TemplateValidationError: 

165 """Represents a template validation error.""" 

166 

167 message: str 

168 line_number: int | None = None 

169 column_number: int | None = None 

170 error_type: str = "validation" 

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

172 template_name: str | None = None 

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

174 

175 

176@dataclass 

177class TemplateValidationResult: 

178 """Result of template validation.""" 

179 

180 is_valid: bool 

181 errors: list[TemplateValidationError] = field(default_factory=list) 

182 warnings: list[TemplateValidationError] = field(default_factory=list) 

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

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

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

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

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

188 

189 

190@dataclass 

191class FragmentInfo: 

192 """Information about a template fragment.""" 

193 

194 name: str 

195 template_path: str 

196 block_name: str | None = None 

197 start_line: int | None = None 

198 end_line: int | None = None 

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

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

201 

202 

203@dataclass 

204class AutocompleteItem: 

205 """Autocomplete suggestion item.""" 

206 

207 name: str 

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

209 description: str | None = None 

210 signature: str | None = None 

211 adapter_source: str | None = None 

212 example: str | None = None 

213 

214 

215def _default_sandbox_attributes() -> list[str]: 

216 """Get default allowed sandbox attributes.""" 

217 return [ 

218 "alt", 

219 "class", 

220 "id", 

221 "src", 

222 "href", 

223 "title", 

224 "width", 

225 "height", 

226 ] 

227 

228 

229def _default_sandbox_tags() -> list[str]: 

230 """Get default allowed sandbox tags.""" 

231 return [ 

232 "div", 

233 "span", 

234 "p", 

235 "a", 

236 "img", 

237 "h1", 

238 "h2", 

239 "h3", 

240 "h4", 

241 "h5", 

242 "h6", 

243 "ul", 

244 "ol", 

245 "li", 

246 "strong", 

247 "em", 

248 "br", 

249 "hr", 

250 ] 

251 

252 

253class HybridTemplatesSettings(TemplatesSettings): 

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

255 

256 # Validation settings 

257 validation_level: ValidationLevel = ValidationLevel.VARIABLES 

258 validate_on_load: bool = True 

259 strict_undefined: bool = True 

260 

261 # Security settings 

262 security_level: SecurityLevel = SecurityLevel.STANDARD 

263 sandbox_allowed_attributes: list[str] = field( 

264 default_factory=_default_sandbox_attributes 

265 ) 

266 sandbox_allowed_tags: list[str] = field(default_factory=_default_sandbox_tags) 

267 

268 # Fragment/Partial settings 

269 enable_fragments: bool = True 

270 fragment_prefix: str = "_" 

271 auto_discover_fragments: bool = True 

272 

273 # Autocomplete settings 

274 enable_autocomplete: bool = True 

275 scan_adapter_functions: bool = True 

276 cache_autocomplete: bool = True 

277 

278 # Performance settings 

279 enable_template_cache: bool = True 

280 template_cache_size: int = 1000 

281 enable_compiled_cache: bool = True 

282 precompile_templates: bool = False 

283 

284 # Advanced error handling 

285 detailed_errors: bool = True 

286 show_context_lines: int = 3 

287 enable_error_suggestions: bool = True 

288 

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

290 super().__init__(**data) 

291 

292 

293class HybridTemplatesManager: 

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

295 

296 def __init__(self, settings: HybridTemplatesSettings | None = None) -> None: 

297 self.settings = settings or HybridTemplatesSettings() 

298 self.base_templates: Templates | None = None 

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

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

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

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

303 

304 async def _initialize_base_templates(self) -> None: 

305 """Initialize base templates instance.""" 

306 try: 

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

308 except Exception: 

309 self.base_templates = Templates() 

310 if not self.base_templates.app: 

311 await self.base_templates.init() 

312 

313 async def _initialize_advanced_features(self) -> None: 

314 """Initialize advanced template features.""" 

315 if self.settings.enable_fragments: 

316 await self._discover_fragments() 

317 

318 if self.settings.enable_autocomplete: 

319 await self._build_autocomplete_index() 

320 

321 async def initialize(self) -> None: 

322 """Initialize the advanced template manager.""" 

323 await self._initialize_base_templates() 

324 await self._initialize_advanced_features() 

325 

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

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

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

329 raise RuntimeError("Base templates not initialized") 

330 

331 env = self.base_templates.app.env 

332 

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

334 # Create sandboxed environment 

335 sandbox_env = SandboxedEnvironment( 

336 loader=env.loader, 

337 extensions=t.cast(t.Any, list(env.extensions.values())), 

338 undefined=StrictUndefined 

339 if self.settings.strict_undefined 

340 else RuntimeStrictUndefined, 

341 ) 

342 

343 # Apply security restrictions (Jinja2 sandbox API) 

344 sandbox_env.allowed_tags = set(self.settings.sandbox_allowed_tags) # type: ignore[attr-defined] 

345 sandbox_env.allowed_attributes = set( # type: ignore[attr-defined] 

346 self.settings.sandbox_allowed_attributes 

347 ) 

348 

349 return sandbox_env 

350 

351 return t.cast(Environment, env) 

352 

353 async def validate_template( 

354 self, 

355 template_source: str, 

356 template_name: str = "unknown", 

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

358 ) -> TemplateValidationResult: 

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

360 # Check cache first 

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

362 if cache_key in self._validation_cache: 

363 return self._validation_cache[cache_key] 

364 

365 result = TemplateValidationResult(is_valid=True) 

366 env = self._get_template_environment() 

367 

368 try: 

369 # Parse template for syntax validation 

370 parsed = env.parse(template_source, template_name) 

371 

372 # Extract variables and blocks 

373 used_vars = meta.find_undeclared_variables(parsed) 

374 result.used_variables = used_vars 

375 

376 # Get available variables from context and adapters 

377 available_vars = self._get_available_variables(context) 

378 result.undefined_variables = used_vars - available_vars 

379 

380 # Get available filters and functions 

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

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

383 

384 # Validate variables if required 

385 if self.settings.validation_level in ( 

386 ValidationLevel.VARIABLES, 

387 ValidationLevel.FULL, 

388 ): 

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

390 

391 # Full validation includes template compilation 

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

393 await self._validate_compilation( 

394 result, template_source, template_name, env 

395 ) 

396 

397 except TemplateSyntaxError as e: 

398 result.is_valid = False 

399 error = TemplateValidationError( 

400 message=str(e), 

401 line_number=e.lineno, 

402 error_type="syntax", 

403 template_name=template_name, 

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

405 ) 

406 result.errors.append(error) 

407 

408 except Exception as e: 

409 result.is_valid = False 

410 error = TemplateValidationError( 

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

412 error_type="general", 

413 template_name=template_name, 

414 ) 

415 result.errors.append(error) 

416 

417 # Add suggestions for improvements 

418 if self.settings.enable_error_suggestions: 

419 await self._add_suggestions(result, template_source) 

420 

421 # Cache result 

422 self._validation_cache[cache_key] = result 

423 return result 

424 

425 @staticmethod 

426 def _get_available_variables(context: dict[str, t.Any] | None = None) -> set[str]: 

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

428 available = set() 

429 

430 # Add context variables 

431 if context: 

432 available.update(context.keys()) 

433 

434 # Add adapter variables 

435 available.update( 

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

437 ) 

438 

439 # Add adapter functions 

440 with suppress(Exception): 

441 for adapter_name in ( 

442 "images", 

443 "icons", 

444 "fonts", 

445 "styles", 

446 "cache", 

447 "storage", 

448 ): 

449 with suppress(Exception): 

450 adapter = depends.get_sync(adapter_name) 

451 if adapter: 

452 available.add(adapter_name) 

453 

454 return available 

455 

456 async def _validate_variables( 

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

458 ) -> None: 

459 """Validate variable usage in template.""" 

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

461 

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

463 # Find variable usage patterns 

464 var_pattern = re.compile( 

465 r"\[\[\s*([^|\[\]]+?)(?:\s*\|[^|\[\]]*?)?\s*\]\]" 

466 ) # REGEX OK: FastBlocks template variable syntax 

467 matches = var_pattern.finditer(line) 

468 

469 for match in matches: 

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

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

472 

473 if base_var in result.undefined_variables: 

474 error = TemplateValidationError( 

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

476 line_number=line_num, 

477 column_number=match.start(), 

478 error_type="undefined_variable", 

479 severity="warning" 

480 if self._is_safe_undefined(base_var) 

481 else "error", 

482 template_name=template_name, 

483 context=line.strip(), 

484 ) 

485 

486 if error.severity == "error": 

487 result.errors.append(error) 

488 result.is_valid = False 

489 else: 

490 result.warnings.append(error) 

491 

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

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

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

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

496 

497 async def _validate_compilation( 

498 self, 

499 result: TemplateValidationResult, 

500 template_source: str, 

501 template_name: str, 

502 env: Environment, 

503 ) -> None: 

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

505 try: 

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

507 

508 # Create mock context for testing 

509 mock_context = self._create_mock_context(result.used_variables) 

510 

511 # Try to render with mock context 

512 await asyncio.get_event_loop().run_in_executor( 

513 None, template.render, mock_context 

514 ) 

515 

516 except UndefinedError: 

517 # This is expected for some undefined variables 

518 pass 

519 except Exception as e: 

520 result.is_valid = False 

521 error = TemplateValidationError( 

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

523 error_type="compilation", 

524 template_name=template_name, 

525 ) 

526 result.errors.append(error) 

527 

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

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

530 mock_context: dict[str, t.Any] = {} 

531 

532 for var in variables: 

533 if "." in var: 

534 # Handle nested variables 

535 parts = var.split(".") 

536 current = mock_context 

537 for part in parts[:-1]: 

538 if part not in current: 

539 current[part] = {} 

540 current = current[part] 

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

542 else: 

543 mock_context[var] = "mock_value" 

544 

545 return mock_context 

546 

547 def _get_error_context( 

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

549 ) -> str | None: 

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

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

552 return None 

553 

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

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

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

557 

558 context_lines = [] 

559 for i in range(start, end): 

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

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

562 

563 return "\n".join(context_lines) 

564 

565 async def _add_suggestions( 

566 self, result: TemplateValidationResult, template_source: str 

567 ) -> None: 

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

569 suggestions = [] 

570 

571 # Suggest available alternatives for undefined variables 

572 for undefined_var in result.undefined_variables: 

573 available = result.used_variables - result.undefined_variables 

574 

575 # Simple fuzzy matching for suggestions 

576 for var in available: 

577 if self._is_similar(undefined_var, var): 

578 suggestions.append( 

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

580 ) 

581 break 

582 

583 # Suggest filters for common patterns 

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

585 tag in template_source for tag in ("<", ">") 

586 ): 

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

588 

589 # Suggest async patterns for image operations 

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

591 suggestions.append( 

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

593 ) 

594 

595 result.suggestions.extend(suggestions) 

596 

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

598 """Simple string similarity check.""" 

599 if not a or not b: 

600 return False 

601 

602 # Levenshtein distance approximation 

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

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

605 

606 if not longer: 

607 return True 

608 

609 # Simple similarity based on common characters 

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

611 similarity = common / len(longer) 

612 

613 return similarity >= threshold 

614 

615 async def _discover_fragments(self) -> None: 

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

617 if not self.base_templates: 

618 return 

619 

620 # Get all template paths 

621 env = self._get_template_environment() 

622 if not env.loader: 

623 return 

624 

625 try: 

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

627 None, env.loader.list_templates 

628 ) 

629 except Exception: 

630 return 

631 

632 for template_name in template_names: 

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

634 await self._analyze_fragment(template_name) 

635 

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

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

638 with suppress(Exception): 

639 env = self._get_template_environment() 

640 source, _, _ = env.loader.get_source(env, template_name) # type: ignore[union-attr,misc] 

641 

642 # Parse template to find blocks 

643 parsed = env.parse(source, template_name) 

644 

645 fragments = [] 

646 

647 # Extract block information 

648 for node in parsed.body: 

649 if hasattr(node, "name") and node.name: # type: ignore[attr-defined] 

650 fragment = FragmentInfo( 

651 name=node.name, # type: ignore[attr-defined] 

652 template_path=template_name, 

653 block_name=node.name, # type: ignore[attr-defined] 

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

655 ) 

656 

657 # Find variables used in this fragment 

658 fragment.variables = meta.find_undeclared_variables(parsed) 

659 fragments.append(fragment) 

660 

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

662 if not fragments: 

663 fragment = FragmentInfo( 

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

665 self.settings.fragment_prefix, "" 

666 ), 

667 template_path=template_name, 

668 variables=meta.find_undeclared_variables(parsed), 

669 ) 

670 fragments.append(fragment) 

671 

672 self._fragment_cache[template_name] = fragments 

673 

674 async def _build_autocomplete_index(self) -> None: 

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

676 autocomplete_items = [] 

677 

678 # Add built-in Jinja2 items 

679 autocomplete_items.extend(self._get_builtin_autocomplete()) 

680 

681 # Add adapter functions if enabled 

682 if self.settings.scan_adapter_functions: 

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

684 

685 # Add template-specific items 

686 autocomplete_items.extend(self._get_template_autocomplete()) 

687 

688 # Cache the results 

689 cache_key = "global" 

690 self._autocomplete_cache[cache_key] = autocomplete_items 

691 

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

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

694 # Add filters from module constant using list comprehension 

695 items = [ 

696 AutocompleteItem( 

697 name=name, 

698 type=item_type, 

699 description=description, 

700 example=example, 

701 adapter_source="jinja2", 

702 ) 

703 for name, item_type, description, example in _JINJA2_BUILTIN_FILTERS 

704 ] 

705 

706 # Add functions from module constant using list comprehension 

707 items.extend( 

708 AutocompleteItem( 

709 name=name, 

710 type=item_type, 

711 description=description, 

712 signature=signature, 

713 example=example, 

714 adapter_source="jinja2", 

715 ) 

716 for name, item_type, description, example, signature in _JINJA2_BUILTIN_FUNCTIONS 

717 ) 

718 

719 return items 

720 

721 def _add_filter_items( 

722 self, items: list[AutocompleteItem], filters: dict[str, t.Any], filter_type: str 

723 ) -> None: 

724 """Add filter autocomplete items from filter dictionary.""" 

725 for name, func in filters.items(): 

726 doc = func.__doc__ or "" 

727 description = ( 

728 doc.split("\n")[0] if doc else f"FastBlocks {name} {filter_type}" 

729 ) 

730 

731 items.append( 

732 AutocompleteItem( 

733 name=name, 

734 type="filter", 

735 description=description, 

736 adapter_source="fastblocks", 

737 example=self._extract_example_from_doc(doc), 

738 ) 

739 ) 

740 

741 @staticmethod 

742 def _add_adapter_function_items(items: list[AutocompleteItem]) -> None: 

743 """Add adapter function autocomplete items.""" 

744 for adapter_name, functions in _ADAPTER_AUTOCOMPLETE_FUNCTIONS.items(): 

745 with suppress(Exception): 

746 adapter = depends.get_sync(adapter_name) 

747 if adapter: 

748 for func_name in functions: 

749 if hasattr(adapter, func_name): 

750 items.append( 

751 AutocompleteItem( 

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

753 type="function", 

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

755 adapter_source=adapter_name, 

756 ) 

757 ) 

758 

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

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

761 items: list[AutocompleteItem] = [] 

762 

763 # FastBlocks-specific filters from our filter modules 

764 from ._async_filters import FASTBLOCKS_ASYNC_FILTERS 

765 from ._filters import FASTBLOCKS_FILTERS 

766 

767 # Add sync filters 

768 self._add_filter_items(items, FASTBLOCKS_FILTERS, "filter") 

769 

770 # Add async filters 

771 self._add_filter_items(items, FASTBLOCKS_ASYNC_FILTERS, "async filter") 

772 

773 # Add adapter-specific functions 

774 self._add_adapter_function_items(items) 

775 

776 return items 

777 

778 @staticmethod 

779 def _get_template_autocomplete() -> list[AutocompleteItem]: 

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

781 items = [] 

782 

783 # Common template variables 

784 common_vars = [ 

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

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

787 ("models", "variable", "Database models"), 

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

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

790 ] 

791 

792 for name, item_type, description in common_vars: 

793 items.append( 

794 AutocompleteItem( 

795 name=name, 

796 type=item_type, 

797 description=description, 

798 adapter_source="fastblocks", 

799 ) 

800 ) 

801 

802 return items 

803 

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

805 """Extract usage example from docstring.""" 

806 if not doc: 

807 return None 

808 

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

810 in_example = False 

811 example_lines = [] 

812 

813 for line in lines: 

814 line = line.strip() 

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

816 return line 

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

818 in_example = True 

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

820 example_lines.append(line) 

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

822 break 

823 

824 return example_lines[0] if example_lines else None 

825 

826 async def get_fragments_for_template( 

827 self, template_name: str 

828 ) -> list[FragmentInfo]: 

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

830 if template_name in self._fragment_cache: 

831 return self._fragment_cache[template_name] 

832 

833 # Try to discover fragments for this template 

834 await self._analyze_fragment(template_name) 

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

836 

837 async def get_autocomplete_suggestions( 

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

839 ) -> list[AutocompleteItem]: 

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

841 cache_key = "global" 

842 if cache_key not in self._autocomplete_cache: 

843 await self._build_autocomplete_index() 

844 

845 all_items = self._autocomplete_cache[cache_key] 

846 

847 # Extract the current word being typed 

848 before_cursor = context[:cursor_position] 

849 current_word = self._extract_current_word(before_cursor) 

850 

851 if not current_word: 

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

853 

854 # Filter suggestions based on current word 

855 filtered = [ 

856 item for item in all_items if current_word.lower() in item.name.lower() 

857 ] 

858 

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

860 filtered.sort( 

861 key=lambda x: ( 

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

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

864 x.name.lower(), 

865 ) 

866 ) 

867 

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

869 

870 @staticmethod 

871 def _extract_current_word(text: str) -> str: 

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

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

874 match = re.search( 

875 r"[\w.]+$", text 

876 ) # REGEX OK: extract word at cursor for autocomplete 

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

878 

879 async def render_fragment( 

880 self, 

881 fragment_name: str, 

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

883 template_name: str | None = None, 

884 secure: bool = False, 

885 ) -> str: 

886 """Render a specific template fragment.""" 

887 if not self.base_templates: 

888 raise RuntimeError("Templates not initialized") 

889 

890 # Find the fragment 

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

892 if not fragment_info: 

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

894 

895 env = self._get_template_environment(secure=secure) 

896 

897 try: 

898 if fragment_info.block_name: 

899 # Render specific block 

900 template = env.get_template(fragment_info.template_path) 

901 # render_block exists in Jinja2 runtime but not in type stubs 

902 return str( 

903 template.render_block( # type: ignore[attr-defined] 

904 fragment_info.block_name, context or {} 

905 ) 

906 ) 

907 else: 

908 # Render entire template 

909 template = env.get_template(fragment_info.template_path) 

910 return str(template.render(context or {})) 

911 

912 except Exception as e: 

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

914 

915 async def _find_fragment( 

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

917 ) -> FragmentInfo | None: 

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

919 # Search in specific template first 

920 if template_name and template_name in self._fragment_cache: 

921 for fragment in self._fragment_cache[template_name]: 

922 if fragment.name == fragment_name: 

923 return fragment 

924 

925 # Search across all fragments 

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

927 for fragment in fragments: 

928 if fragment.name == fragment_name: 

929 return fragment 

930 

931 return None 

932 

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

934 """Precompile templates for performance optimization.""" 

935 if not self.settings.precompile_templates: 

936 return {} 

937 

938 env = self._get_template_environment() 

939 if not env.loader: 

940 return {} 

941 

942 compiled_templates = {} 

943 

944 with suppress(Exception): 

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

946 None, env.loader.list_templates 

947 ) 

948 

949 for template_name in template_names: 

950 with suppress(Exception): 

951 template = env.get_template(template_name) 

952 compiled_templates[template_name] = template 

953 

954 return compiled_templates 

955 

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

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

958 if template_name in self._template_dependencies: 

959 return self._template_dependencies[template_name] 

960 

961 dependencies = set() 

962 env = self._get_template_environment() 

963 

964 with suppress(Exception): 

965 source, _, _ = env.loader.get_source(env, template_name) # type: ignore[union-attr,misc] 

966 parsed = env.parse(source, template_name) 

967 

968 # Find extends, includes, and imports 

969 node: t.Any 

970 # find_all accepts strings but type stubs expect Node types 

971 for node in parsed.find_all( 

972 t.cast(t.Any, ("Extends", "Include", "FromImport")) 

973 ): 

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

975 dependencies.add(node.template.value) 

976 

977 self._template_dependencies[template_name] = dependencies 

978 

979 return dependencies 

980 

981 def clear_caches(self) -> None: 

982 """Clear all internal caches.""" 

983 self._validation_cache.clear() 

984 self._fragment_cache.clear() 

985 self._autocomplete_cache.clear() 

986 self._template_dependencies.clear() 

987 

988 

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

990MODULE_STATUS = AdapterStatus.EXPERIMENTAL 

991 

992# Register the advanced manager 

993with suppress(Exception): 

994 depends.set("hybrid_template_manager", HybridTemplatesManager)