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

402 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 03:37 -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 # type: ignore[no-redef] 

46from jinja2.runtime import StrictUndefined as RuntimeStrictUndefined 

47 

48from .jinja2 import Templates, TemplatesSettings 

49 

50__all__ = [ 

51 "AdvancedTemplateManager", 

52 "AdvancedTemplatesSettings", 

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 AdvancedTemplatesSettings(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 AdvancedTemplateManager: 

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

295 

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

297 self.settings = settings or AdvancedTemplatesSettings() 

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=list(env.extensions.values()), # type: ignore[arg-type] 

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 def _get_available_variables( 

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

427 ) -> set[str]: 

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

429 available = set() 

430 

431 # Add context variables 

432 if context: 

433 available.update(context.keys()) 

434 

435 # Add adapter variables 

436 available.update( 

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

438 ) 

439 

440 # Add adapter functions 

441 with suppress(Exception): 

442 for adapter_name in ( 

443 "images", 

444 "icons", 

445 "fonts", 

446 "styles", 

447 "cache", 

448 "storage", 

449 ): 

450 with suppress(Exception): 

451 adapter = depends.get(adapter_name) 

452 if adapter: 

453 available.add(adapter_name) 

454 

455 return available 

456 

457 async def _validate_variables( 

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

459 ) -> None: 

460 """Validate variable usage in template.""" 

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

462 

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

464 # Find variable usage patterns 

465 var_pattern = re.compile( 

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

467 ) # REGEX OK: FastBlocks template variable syntax 

468 matches = var_pattern.finditer(line) 

469 

470 for match in matches: 

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

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

473 

474 if base_var in result.undefined_variables: 

475 error = TemplateValidationError( 

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

477 line_number=line_num, 

478 column_number=match.start(), 

479 error_type="undefined_variable", 

480 severity="warning" 

481 if self._is_safe_undefined(base_var) 

482 else "error", 

483 template_name=template_name, 

484 context=line.strip(), 

485 ) 

486 

487 if error.severity == "error": 

488 result.errors.append(error) 

489 result.is_valid = False 

490 else: 

491 result.warnings.append(error) 

492 

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

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

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

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

497 

498 async def _validate_compilation( 

499 self, 

500 result: TemplateValidationResult, 

501 template_source: str, 

502 template_name: str, 

503 env: Environment, 

504 ) -> None: 

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

506 try: 

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

508 

509 # Create mock context for testing 

510 mock_context = self._create_mock_context(result.used_variables) 

511 

512 # Try to render with mock context 

513 await asyncio.get_event_loop().run_in_executor( 

514 None, template.render, mock_context 

515 ) 

516 

517 except UndefinedError: 

518 # This is expected for some undefined variables 

519 pass 

520 except Exception as e: 

521 result.is_valid = False 

522 error = TemplateValidationError( 

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

524 error_type="compilation", 

525 template_name=template_name, 

526 ) 

527 result.errors.append(error) 

528 

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

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

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

532 

533 for var in variables: 

534 if "." in var: 

535 # Handle nested variables 

536 parts = var.split(".") 

537 current = mock_context 

538 for part in parts[:-1]: 

539 if part not in current: 

540 current[part] = {} 

541 current = current[part] 

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

543 else: 

544 mock_context[var] = "mock_value" 

545 

546 return mock_context 

547 

548 def _get_error_context( 

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

550 ) -> str | None: 

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

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

553 return None 

554 

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

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

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

558 

559 context_lines = [] 

560 for i in range(start, end): 

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

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

563 

564 return "\n".join(context_lines) 

565 

566 async def _add_suggestions( 

567 self, result: TemplateValidationResult, template_source: str 

568 ) -> None: 

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

570 suggestions = [] 

571 

572 # Suggest available alternatives for undefined variables 

573 for undefined_var in result.undefined_variables: 

574 available = result.used_variables - result.undefined_variables 

575 

576 # Simple fuzzy matching for suggestions 

577 for var in available: 

578 if self._is_similar(undefined_var, var): 

579 suggestions.append( 

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

581 ) 

582 break 

583 

584 # Suggest filters for common patterns 

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

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

587 ): 

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

589 

590 # Suggest async patterns for image operations 

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

592 suggestions.append( 

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

594 ) 

595 

596 result.suggestions.extend(suggestions) 

597 

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

599 """Simple string similarity check.""" 

600 if not a or not b: 

601 return False 

602 

603 # Levenshtein distance approximation 

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

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

606 

607 if not longer: 

608 return True 

609 

610 # Simple similarity based on common characters 

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

612 similarity = common / len(longer) 

613 

614 return similarity >= threshold 

615 

616 async def _discover_fragments(self) -> None: 

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

618 if not self.base_templates: 

619 return 

620 

621 # Get all template paths 

622 env = self._get_template_environment() 

623 if not env.loader: 

624 return 

625 

626 try: 

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

628 None, env.loader.list_templates 

629 ) 

630 except Exception: 

631 return 

632 

633 for template_name in template_names: 

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

635 await self._analyze_fragment(template_name) 

636 

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

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

639 with suppress(Exception): 

640 env = self._get_template_environment() 

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

642 

643 # Parse template to find blocks 

644 parsed = env.parse(source, template_name) 

645 

646 fragments = [] 

647 

648 # Extract block information 

649 for node in parsed.body: 

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

651 fragment = FragmentInfo( 

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

653 template_path=template_name, 

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

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

656 ) 

657 

658 # Find variables used in this fragment 

659 fragment.variables = meta.find_undeclared_variables(parsed) 

660 fragments.append(fragment) 

661 

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

663 if not fragments: 

664 fragment = FragmentInfo( 

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

666 self.settings.fragment_prefix, "" 

667 ), 

668 template_path=template_name, 

669 variables=meta.find_undeclared_variables(parsed), 

670 ) 

671 fragments.append(fragment) 

672 

673 self._fragment_cache[template_name] = fragments 

674 

675 async def _build_autocomplete_index(self) -> None: 

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

677 autocomplete_items = [] 

678 

679 # Add built-in Jinja2 items 

680 autocomplete_items.extend(self._get_builtin_autocomplete()) 

681 

682 # Add adapter functions if enabled 

683 if self.settings.scan_adapter_functions: 

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

685 

686 # Add template-specific items 

687 autocomplete_items.extend(self._get_template_autocomplete()) 

688 

689 # Cache the results 

690 cache_key = "global" 

691 self._autocomplete_cache[cache_key] = autocomplete_items 

692 

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

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

695 # Add filters from module constant using list comprehension 

696 items = [ 

697 AutocompleteItem( 

698 name=name, 

699 type=item_type, 

700 description=description, 

701 example=example, 

702 adapter_source="jinja2", 

703 ) 

704 for name, item_type, description, example in _JINJA2_BUILTIN_FILTERS 

705 ] 

706 

707 # Add functions from module constant using list comprehension 

708 items.extend( 

709 AutocompleteItem( 

710 name=name, 

711 type=item_type, 

712 description=description, 

713 signature=signature, 

714 example=example, 

715 adapter_source="jinja2", 

716 ) 

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

718 ) 

719 

720 return items 

721 

722 def _add_filter_items( 

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

724 ) -> None: 

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

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

727 doc = func.__doc__ or "" 

728 description = ( 

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

730 ) 

731 

732 items.append( 

733 AutocompleteItem( 

734 name=name, 

735 type="filter", 

736 description=description, 

737 adapter_source="fastblocks", 

738 example=self._extract_example_from_doc(doc), 

739 ) 

740 ) 

741 

742 def _add_adapter_function_items(self, 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(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 def _get_template_autocomplete(self) -> list[AutocompleteItem]: 

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

780 items = [] 

781 

782 # Common template variables 

783 common_vars = [ 

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

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

786 ("models", "variable", "Database models"), 

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

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

789 ] 

790 

791 for name, item_type, description in common_vars: 

792 items.append( 

793 AutocompleteItem( 

794 name=name, 

795 type=item_type, 

796 description=description, 

797 adapter_source="fastblocks", 

798 ) 

799 ) 

800 

801 return items 

802 

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

804 """Extract usage example from docstring.""" 

805 if not doc: 

806 return None 

807 

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

809 in_example = False 

810 example_lines = [] 

811 

812 for line in lines: 

813 line = line.strip() 

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

815 return line 

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

817 in_example = True 

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

819 example_lines.append(line) 

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

821 break 

822 

823 return example_lines[0] if example_lines else None 

824 

825 async def get_fragments_for_template( 

826 self, template_name: str 

827 ) -> list[FragmentInfo]: 

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

829 if template_name in self._fragment_cache: 

830 return self._fragment_cache[template_name] 

831 

832 # Try to discover fragments for this template 

833 await self._analyze_fragment(template_name) 

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

835 

836 async def get_autocomplete_suggestions( 

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

838 ) -> list[AutocompleteItem]: 

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

840 cache_key = "global" 

841 if cache_key not in self._autocomplete_cache: 

842 await self._build_autocomplete_index() 

843 

844 all_items = self._autocomplete_cache[cache_key] 

845 

846 # Extract the current word being typed 

847 before_cursor = context[:cursor_position] 

848 current_word = self._extract_current_word(before_cursor) 

849 

850 if not current_word: 

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

852 

853 # Filter suggestions based on current word 

854 filtered = [ 

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

856 ] 

857 

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

859 filtered.sort( 

860 key=lambda x: ( 

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

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

863 x.name.lower(), 

864 ) 

865 ) 

866 

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

868 

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

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

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

872 match = re.search( 

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

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

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

876 

877 async def render_fragment( 

878 self, 

879 fragment_name: str, 

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

881 template_name: str | None = None, 

882 secure: bool = False, 

883 ) -> str: 

884 """Render a specific template fragment.""" 

885 if not self.base_templates: 

886 raise RuntimeError("Templates not initialized") 

887 

888 # Find the fragment 

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

890 if not fragment_info: 

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

892 

893 env = self._get_template_environment(secure=secure) 

894 

895 try: 

896 if fragment_info.block_name: 

897 # Render specific block 

898 template = env.get_template(fragment_info.template_path) 

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

900 return str( 

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

902 fragment_info.block_name, context or {} 

903 ) 

904 ) 

905 else: 

906 # Render entire template 

907 template = env.get_template(fragment_info.template_path) 

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

909 

910 except Exception as e: 

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

912 

913 async def _find_fragment( 

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

915 ) -> FragmentInfo | None: 

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

917 # Search in specific template first 

918 if template_name and template_name in self._fragment_cache: 

919 for fragment in self._fragment_cache[template_name]: 

920 if fragment.name == fragment_name: 

921 return fragment 

922 

923 # Search across all fragments 

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

925 for fragment in fragments: 

926 if fragment.name == fragment_name: 

927 return fragment 

928 

929 return None 

930 

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

932 """Precompile templates for performance optimization.""" 

933 if not self.settings.precompile_templates: 

934 return {} 

935 

936 env = self._get_template_environment() 

937 if not env.loader: 

938 return {} 

939 

940 compiled_templates = {} 

941 

942 with suppress(Exception): 

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

944 None, env.loader.list_templates 

945 ) 

946 

947 for template_name in template_names: 

948 with suppress(Exception): 

949 template = env.get_template(template_name) 

950 compiled_templates[template_name] = template 

951 

952 return compiled_templates 

953 

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

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

956 if template_name in self._template_dependencies: 

957 return self._template_dependencies[template_name] 

958 

959 dependencies = set() 

960 env = self._get_template_environment() 

961 

962 with suppress(Exception): 

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

964 parsed = env.parse(source, template_name) 

965 

966 # Find extends, includes, and imports 

967 node: t.Any 

968 # find_all accepts strings but type stubs expect Node types 

969 for node in parsed.find_all(("Extends", "Include", "FromImport")): # type: ignore[arg-type] 

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

971 dependencies.add(node.template.value) 

972 

973 self._template_dependencies[template_name] = dependencies 

974 

975 return dependencies 

976 

977 def clear_caches(self) -> None: 

978 """Clear all internal caches.""" 

979 self._validation_cache.clear() 

980 self._fragment_cache.clear() 

981 self._autocomplete_cache.clear() 

982 self._template_dependencies.clear() 

983 

984 

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

986MODULE_STATUS = AdapterStatus.EXPERIMENTAL 

987 

988# Register the advanced manager 

989with suppress(Exception): 

990 depends.set("advanced_template_manager", AdvancedTemplateManager)