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
« 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.
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
11Requirements:
12- jinja2>=3.1.6
13- jinja2-async-environment>=0.14.3
14- starlette-async-jinja>=1.12.4
16Author: lesleslie <les@wedgwoodwebworks.com>
17Created: 2025-01-12
18"""
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
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)
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
48from .jinja2 import Templates, TemplatesSettings
50__all__ = [
51 "AdvancedTemplateManager",
52 "AdvancedTemplatesSettings",
53 "AutocompleteItem",
54 "FragmentInfo",
55 "SecurityLevel",
56 "TemplateError",
57 "TemplateValidationResult",
58 "ValidationLevel",
59]
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]
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]
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}
147class ValidationLevel(Enum):
148 """Template validation levels."""
150 SYNTAX_ONLY = "syntax_only"
151 VARIABLES = "variables"
152 FULL = "full"
155class SecurityLevel(Enum):
156 """Template security levels."""
158 STANDARD = "standard"
159 RESTRICTED = "restricted"
160 SANDBOXED = "sandboxed"
163@dataclass
164class TemplateValidationError:
165 """Represents a template validation error."""
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
176@dataclass
177class TemplateValidationResult:
178 """Result of template validation."""
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)
190@dataclass
191class FragmentInfo:
192 """Information about a template fragment."""
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)
203@dataclass
204class AutocompleteItem:
205 """Autocomplete suggestion item."""
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
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 ]
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 ]
253class AdvancedTemplatesSettings(TemplatesSettings):
254 """Advanced template settings with enhanced features."""
256 # Validation settings
257 validation_level: ValidationLevel = ValidationLevel.VARIABLES
258 validate_on_load: bool = True
259 strict_undefined: bool = True
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)
268 # Fragment/Partial settings
269 enable_fragments: bool = True
270 fragment_prefix: str = "_"
271 auto_discover_fragments: bool = True
273 # Autocomplete settings
274 enable_autocomplete: bool = True
275 scan_adapter_functions: bool = True
276 cache_autocomplete: bool = True
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
284 # Advanced error handling
285 detailed_errors: bool = True
286 show_context_lines: int = 3
287 enable_error_suggestions: bool = True
289 def __init__(self, **data: t.Any) -> None:
290 super().__init__(**data)
293class AdvancedTemplateManager:
294 """Advanced template management with validation, fragments, and autocomplete."""
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]] = {}
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()
313 async def _initialize_advanced_features(self) -> None:
314 """Initialize advanced template features."""
315 if self.settings.enable_fragments:
316 await self._discover_fragments()
318 if self.settings.enable_autocomplete:
319 await self._build_autocomplete_index()
321 async def initialize(self) -> None:
322 """Initialize the advanced template manager."""
323 await self._initialize_base_templates()
324 await self._initialize_advanced_features()
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")
331 env = self.base_templates.app.env
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 )
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 )
349 return sandbox_env
351 return t.cast(Environment, env)
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]
365 result = TemplateValidationResult(is_valid=True)
366 env = self._get_template_environment()
368 try:
369 # Parse template for syntax validation
370 parsed = env.parse(template_source, template_name)
372 # Extract variables and blocks
373 used_vars = meta.find_undeclared_variables(parsed)
374 result.used_variables = used_vars
376 # Get available variables from context and adapters
377 available_vars = self._get_available_variables(context)
378 result.undefined_variables = used_vars - available_vars
380 # Get available filters and functions
381 result.available_filters = set(env.filters.keys())
382 result.available_functions = set(env.globals.keys())
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)
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 )
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)
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)
417 # Add suggestions for improvements
418 if self.settings.enable_error_suggestions:
419 await self._add_suggestions(result, template_source)
421 # Cache result
422 self._validation_cache[cache_key] = result
423 return result
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()
431 # Add context variables
432 if context:
433 available.update(context.keys())
435 # Add adapter variables
436 available.update(
437 ["config", "request", "models", "render_block", "render_component"]
438 )
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)
455 return available
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")
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)
470 for match in matches:
471 var_expr = match.group(1).strip()
472 base_var = var_expr.split(".")[0].split("(")[0].strip()
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 )
487 if error.severity == "error":
488 result.errors.append(error)
489 result.is_valid = False
490 else:
491 result.warnings.append(error)
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)
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)
509 # Create mock context for testing
510 mock_context = self._create_mock_context(result.used_variables)
512 # Try to render with mock context
513 await asyncio.get_event_loop().run_in_executor(
514 None, template.render, mock_context
515 )
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)
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] = {}
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"
546 return mock_context
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
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)
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]}")
564 return "\n".join(context_lines)
566 async def _add_suggestions(
567 self, result: TemplateValidationResult, template_source: str
568 ) -> None:
569 """Add helpful suggestions for template improvements."""
570 suggestions = []
572 # Suggest available alternatives for undefined variables
573 for undefined_var in result.undefined_variables:
574 available = result.used_variables - result.undefined_variables
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
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")
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 )
596 result.suggestions.extend(suggestions)
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
603 # Levenshtein distance approximation
604 longer = a if len(a) > len(b) else b
605 shorter = b if len(a) > len(b) else a
607 if not longer:
608 return True
610 # Simple similarity based on common characters
611 common = sum(1 for char in shorter if char in longer)
612 similarity = common / len(longer)
614 return similarity >= threshold
616 async def _discover_fragments(self) -> None:
617 """Discover and index template fragments for HTMX support."""
618 if not self.base_templates:
619 return
621 # Get all template paths
622 env = self._get_template_environment()
623 if not env.loader:
624 return
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
633 for template_name in template_names:
634 if template_name.startswith(self.settings.fragment_prefix):
635 await self._analyze_fragment(template_name)
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]
643 # Parse template to find blocks
644 parsed = env.parse(source, template_name)
646 fragments = []
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 )
658 # Find variables used in this fragment
659 fragment.variables = meta.find_undeclared_variables(parsed)
660 fragments.append(fragment)
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)
673 self._fragment_cache[template_name] = fragments
675 async def _build_autocomplete_index(self) -> None:
676 """Build autocomplete index for template variables and functions."""
677 autocomplete_items = []
679 # Add built-in Jinja2 items
680 autocomplete_items.extend(self._get_builtin_autocomplete())
682 # Add adapter functions if enabled
683 if self.settings.scan_adapter_functions:
684 autocomplete_items.extend(await self._get_adapter_autocomplete())
686 # Add template-specific items
687 autocomplete_items.extend(self._get_template_autocomplete())
689 # Cache the results
690 cache_key = "global"
691 self._autocomplete_cache[cache_key] = autocomplete_items
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 ]
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 )
720 return items
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 )
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 )
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 )
759 async def _get_adapter_autocomplete(self) -> list[AutocompleteItem]:
760 """Get autocomplete items for adapter functions."""
761 items: list[AutocompleteItem] = []
763 # FastBlocks-specific filters from our filter modules
764 from .async_filters import FASTBLOCKS_ASYNC_FILTERS
765 from .filters import FASTBLOCKS_FILTERS
767 # Add sync filters
768 self._add_filter_items(items, FASTBLOCKS_FILTERS, "filter")
770 # Add async filters
771 self._add_filter_items(items, FASTBLOCKS_ASYNC_FILTERS, "async filter")
773 # Add adapter-specific functions
774 self._add_adapter_function_items(items)
776 return items
778 def _get_template_autocomplete(self) -> list[AutocompleteItem]:
779 """Get autocomplete items for template-specific variables."""
780 items = []
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 ]
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 )
801 return items
803 def _extract_example_from_doc(self, doc: str) -> str | None:
804 """Extract usage example from docstring."""
805 if not doc:
806 return None
808 lines = doc.split("\n")
809 in_example = False
810 example_lines = []
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
823 return example_lines[0] if example_lines else None
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]
832 # Try to discover fragments for this template
833 await self._analyze_fragment(template_name)
834 return self._fragment_cache.get(template_name, [])
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()
844 all_items = self._autocomplete_cache[cache_key]
846 # Extract the current word being typed
847 before_cursor = context[:cursor_position]
848 current_word = self._extract_current_word(before_cursor)
850 if not current_word:
851 return all_items[:20] # Return top 20 suggestions
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 ]
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 )
867 return filtered[:10] # Return top 10 matches
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 ""
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")
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")
893 env = self._get_template_environment(secure=secure)
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 {}))
910 except Exception as e:
911 raise TemplateError(f"Error rendering fragment '{fragment_name}': {e}")
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
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
929 return None
931 async def precompile_templates(self) -> dict[str, Template]:
932 """Precompile templates for performance optimization."""
933 if not self.settings.precompile_templates:
934 return {}
936 env = self._get_template_environment()
937 if not env.loader:
938 return {}
940 compiled_templates = {}
942 with suppress(Exception):
943 template_names = await asyncio.get_event_loop().run_in_executor(
944 None, env.loader.list_templates
945 )
947 for template_name in template_names:
948 with suppress(Exception):
949 template = env.get_template(template_name)
950 compiled_templates[template_name] = template
952 return compiled_templates
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]
959 dependencies = set()
960 env = self._get_template_environment()
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)
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)
973 self._template_dependencies[template_name] = dependencies
975 return dependencies
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()
985MODULE_ID = UUID("01937d87-1234-7890-abcd-1234567890ab")
986MODULE_STATUS = AdapterStatus.EXPERIMENTAL
988# Register the advanced manager
989with suppress(Exception):
990 depends.set("advanced_template_manager", AdvancedTemplateManager)