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
« 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.
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 "HybridTemplatesManager",
52 "HybridTemplatesSettings",
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 HybridTemplatesSettings(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 HybridTemplatesManager:
294 """Advanced template management with validation, fragments, and autocomplete."""
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]] = {}
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=t.cast(t.Any, list(env.extensions.values())),
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 @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()
430 # Add context variables
431 if context:
432 available.update(context.keys())
434 # Add adapter variables
435 available.update(
436 ["config", "request", "models", "render_block", "render_component"]
437 )
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)
454 return available
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")
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)
469 for match in matches:
470 var_expr = match.group(1).strip()
471 base_var = var_expr.split(".")[0].split("(")[0].strip()
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 )
486 if error.severity == "error":
487 result.errors.append(error)
488 result.is_valid = False
489 else:
490 result.warnings.append(error)
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)
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)
508 # Create mock context for testing
509 mock_context = self._create_mock_context(result.used_variables)
511 # Try to render with mock context
512 await asyncio.get_event_loop().run_in_executor(
513 None, template.render, mock_context
514 )
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)
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] = {}
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"
545 return mock_context
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
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)
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]}")
563 return "\n".join(context_lines)
565 async def _add_suggestions(
566 self, result: TemplateValidationResult, template_source: str
567 ) -> None:
568 """Add helpful suggestions for template improvements."""
569 suggestions = []
571 # Suggest available alternatives for undefined variables
572 for undefined_var in result.undefined_variables:
573 available = result.used_variables - result.undefined_variables
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
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")
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 )
595 result.suggestions.extend(suggestions)
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
602 # Levenshtein distance approximation
603 longer = a if len(a) > len(b) else b
604 shorter = b if len(a) > len(b) else a
606 if not longer:
607 return True
609 # Simple similarity based on common characters
610 common = sum(1 for char in shorter if char in longer)
611 similarity = common / len(longer)
613 return similarity >= threshold
615 async def _discover_fragments(self) -> None:
616 """Discover and index template fragments for HTMX support."""
617 if not self.base_templates:
618 return
620 # Get all template paths
621 env = self._get_template_environment()
622 if not env.loader:
623 return
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
632 for template_name in template_names:
633 if template_name.startswith(self.settings.fragment_prefix):
634 await self._analyze_fragment(template_name)
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]
642 # Parse template to find blocks
643 parsed = env.parse(source, template_name)
645 fragments = []
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 )
657 # Find variables used in this fragment
658 fragment.variables = meta.find_undeclared_variables(parsed)
659 fragments.append(fragment)
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)
672 self._fragment_cache[template_name] = fragments
674 async def _build_autocomplete_index(self) -> None:
675 """Build autocomplete index for template variables and functions."""
676 autocomplete_items = []
678 # Add built-in Jinja2 items
679 autocomplete_items.extend(self._get_builtin_autocomplete())
681 # Add adapter functions if enabled
682 if self.settings.scan_adapter_functions:
683 autocomplete_items.extend(await self._get_adapter_autocomplete())
685 # Add template-specific items
686 autocomplete_items.extend(self._get_template_autocomplete())
688 # Cache the results
689 cache_key = "global"
690 self._autocomplete_cache[cache_key] = autocomplete_items
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 ]
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 )
719 return items
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 )
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 )
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 )
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 @staticmethod
779 def _get_template_autocomplete() -> list[AutocompleteItem]:
780 """Get autocomplete items for template-specific variables."""
781 items = []
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 ]
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 )
802 return items
804 def _extract_example_from_doc(self, doc: str) -> str | None:
805 """Extract usage example from docstring."""
806 if not doc:
807 return None
809 lines = doc.split("\n")
810 in_example = False
811 example_lines = []
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
824 return example_lines[0] if example_lines else None
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]
833 # Try to discover fragments for this template
834 await self._analyze_fragment(template_name)
835 return self._fragment_cache.get(template_name, [])
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()
845 all_items = self._autocomplete_cache[cache_key]
847 # Extract the current word being typed
848 before_cursor = context[:cursor_position]
849 current_word = self._extract_current_word(before_cursor)
851 if not current_word:
852 return all_items[:20] # Return top 20 suggestions
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 ]
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 )
868 return filtered[:10] # Return top 10 matches
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 ""
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")
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")
895 env = self._get_template_environment(secure=secure)
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 {}))
912 except Exception as e:
913 raise TemplateError(f"Error rendering fragment '{fragment_name}': {e}")
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
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
931 return None
933 async def precompile_templates(self) -> dict[str, Template]:
934 """Precompile templates for performance optimization."""
935 if not self.settings.precompile_templates:
936 return {}
938 env = self._get_template_environment()
939 if not env.loader:
940 return {}
942 compiled_templates = {}
944 with suppress(Exception):
945 template_names = await asyncio.get_event_loop().run_in_executor(
946 None, env.loader.list_templates
947 )
949 for template_name in template_names:
950 with suppress(Exception):
951 template = env.get_template(template_name)
952 compiled_templates[template_name] = template
954 return compiled_templates
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]
961 dependencies = set()
962 env = self._get_template_environment()
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)
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)
977 self._template_dependencies[template_name] = dependencies
979 return dependencies
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()
989MODULE_ID = UUID("01937d87-1234-7890-abcd-1234567890ab")
990MODULE_STATUS = AdapterStatus.EXPERIMENTAL
992# Register the advanced manager
993with suppress(Exception):
994 depends.set("hybrid_template_manager", HybridTemplatesManager)