diff --git a/.vale/styles/spelling-exceptions.txt b/.vale/styles/spelling-exceptions.txt index d018438b..904fc251 100644 --- a/.vale/styles/spelling-exceptions.txt +++ b/.vale/styles/spelling-exceptions.txt @@ -85,6 +85,7 @@ namespace namespaces Nautobot Netbox +Netutils Newsfragment Nornir npm diff --git a/changelog/+5660f1dc.added.md b/changelog/+5660f1dc.added.md new file mode 100644 index 00000000..0d51a3d9 --- /dev/null +++ b/changelog/+5660f1dc.added.md @@ -0,0 +1 @@ +Refactored management of Jinja2 templating to allow for filters within Infrahub. Builtin filters as well as those from Netutils are available. diff --git a/docs/_templates/sdk_template_reference.j2 b/docs/_templates/sdk_template_reference.j2 new file mode 100644 index 00000000..dcd59d78 --- /dev/null +++ b/docs/_templates/sdk_template_reference.j2 @@ -0,0 +1,27 @@ +--- +title: Python SDK Templating +--- +Filters can be used when defining [computed attributes](https://docs.infrahub.app/guides/computed-attributes) or [Jinja2 Transforms](https://docs.infrahub.app/guides/jinja2-transform) within Infrahub. + +## Builtin Jinja2 filters + +The following filters are those that are [shipped with Jinja2](https://jinja.palletsprojects.com/en/stable/templates/#list-of-builtin-filters) and enabled within Infrahub. The trusted column indicates if the filter is allowed for use with Infrahub's computed attributes when the server is configured in strict mode. + + +| Name | Trusted | +|----------|----------| +{% for filter in builtin %} +| {{ filter.name }} | {% if filter.trusted %}✅{% else %}❌{% endif %} | +{% endfor %} + + +## Netutils filters + +The following Jinja2 filters from Netutils are included within Infrahub. + +| Name | Trusted | +|----------|----------| +{% for filter in netutils %} +| {{ filter.name }} | {% if filter.trusted %}✅{% else %}❌{% endif %} | +{% endfor %} + diff --git a/docs/docs/python-sdk/reference/templating.mdx b/docs/docs/python-sdk/reference/templating.mdx new file mode 100644 index 00000000..62f1b8aa --- /dev/null +++ b/docs/docs/python-sdk/reference/templating.mdx @@ -0,0 +1,153 @@ +--- +title: Python SDK Templating +--- +Filters can be used when defining [computed attributes](https://docs.infrahub.app/guides/computed-attributes) or [Jinja2 Transforms](https://docs.infrahub.app/guides/jinja2-transform) within Infrahub. + +## Builtin Jinja2 filters + +The following filters are those that are [shipped with Jinja2](https://jinja.palletsprojects.com/en/stable/templates/#list-of-builtin-filters) and enabled within Infrahub. The trusted column indicates if the filter is allowed for use with Infrahub's computed attributes when the server is configured in strict mode. + + +| Name | Trusted | +|----------|----------| +| abs | ✅ | +| attr | ❌ | +| batch | ❌ | +| capitalize | ✅ | +| center | ✅ | +| count | ✅ | +| d | ✅ | +| default | ✅ | +| dictsort | ❌ | +| e | ✅ | +| escape | ✅ | +| filesizeformat | ✅ | +| first | ✅ | +| float | ✅ | +| forceescape | ✅ | +| format | ✅ | +| groupby | ❌ | +| indent | ✅ | +| int | ✅ | +| items | ❌ | +| join | ✅ | +| last | ✅ | +| length | ✅ | +| list | ✅ | +| lower | ✅ | +| map | ❌ | +| max | ✅ | +| min | ✅ | +| pprint | ❌ | +| random | ❌ | +| reject | ❌ | +| rejectattr | ❌ | +| replace | ✅ | +| reverse | ✅ | +| round | ✅ | +| safe | ❌ | +| select | ❌ | +| selectattr | ❌ | +| slice | ✅ | +| sort | ❌ | +| string | ✅ | +| striptags | ✅ | +| sum | ✅ | +| title | ✅ | +| tojson | ❌ | +| trim | ✅ | +| truncate | ✅ | +| unique | ❌ | +| upper | ✅ | +| urlencode | ✅ | +| urlize | ❌ | +| wordcount | ✅ | +| wordwrap | ✅ | +| xmlattr | ❌ | + + +## Netutils filters + +The following Jinja2 filters from Netutils are included within Infrahub. + +| Name | Trusted | +|----------|----------| +| abbreviated_interface_name | ✅ | +| abbreviated_interface_name_list | ✅ | +| asn_to_int | ✅ | +| bits_to_name | ✅ | +| bytes_to_name | ✅ | +| canonical_interface_name | ✅ | +| canonical_interface_name_list | ✅ | +| cidr_to_netmask | ✅ | +| cidr_to_netmaskv6 | ✅ | +| clean_config | ✅ | +| compare_version_loose | ✅ | +| compare_version_strict | ✅ | +| config_compliance | ✅ | +| config_section_not_parsed | ✅ | +| delimiter_change | ✅ | +| diff_network_config | ✅ | +| feature_compliance | ✅ | +| find_unordered_cfg_lines | ✅ | +| fqdn_to_ip | ❌ | +| get_all_host | ❌ | +| get_broadcast_address | ✅ | +| get_first_usable | ✅ | +| get_ips_sorted | ✅ | +| get_nist_urls | ✅ | +| get_nist_vendor_platform_urls | ✅ | +| get_oui | ✅ | +| get_peer_ip | ✅ | +| get_range_ips | ✅ | +| get_upgrade_path | ✅ | +| get_usable_range | ✅ | +| hash_data | ✅ | +| int_to_asdot | ✅ | +| interface_range_compress | ✅ | +| interface_range_expansion | ✅ | +| ip_addition | ✅ | +| ip_subtract | ✅ | +| ip_to_bin | ✅ | +| ip_to_hex | ✅ | +| ipaddress_address | ✅ | +| ipaddress_interface | ✅ | +| ipaddress_network | ✅ | +| is_classful | ✅ | +| is_fqdn_resolvable | ❌ | +| is_ip | ✅ | +| is_ip_range | ✅ | +| is_ip_within | ✅ | +| is_netmask | ✅ | +| is_network | ✅ | +| is_reversible_wildcardmask | ✅ | +| is_valid_mac | ✅ | +| longest_prefix_match | ✅ | +| mac_normalize | ✅ | +| mac_to_format | ✅ | +| mac_to_int | ✅ | +| mac_type | ✅ | +| name_to_bits | ✅ | +| name_to_bytes | ✅ | +| name_to_name | ✅ | +| netmask_to_cidr | ✅ | +| netmask_to_wildcardmask | ✅ | +| normalise_delimiter_caret_c | ✅ | +| paloalto_panos_brace_to_set | ✅ | +| paloalto_panos_clean_newlines | ✅ | +| regex_findall | ❌ | +| regex_match | ❌ | +| regex_search | ❌ | +| regex_split | ❌ | +| regex_sub | ❌ | +| sanitize_config | ✅ | +| section_config | ✅ | +| sort_interface_list | ✅ | +| split_interface | ✅ | +| uptime_seconds_to_string | ✅ | +| uptime_string_to_seconds | ✅ | +| version_metadata | ✅ | +| vlanconfig_to_list | ✅ | +| vlanlist_to_config | ✅ | +| wildcardmask_to_netmask | ✅ | + \ No newline at end of file diff --git a/docs/sidebars-python-sdk.ts b/docs/sidebars-python-sdk.ts index 8da5a81b..7cde4058 100644 --- a/docs/sidebars-python-sdk.ts +++ b/docs/sidebars-python-sdk.ts @@ -38,6 +38,7 @@ const sidebars: SidebarsConfig = { label: 'Reference', items: [ 'reference/config', + 'reference/templating', ], }, ], diff --git a/infrahub_sdk/ctl/cli_commands.py b/infrahub_sdk/ctl/cli_commands.py index 633ccd2c..0d9a850f 100644 --- a/infrahub_sdk/ctl/cli_commands.py +++ b/infrahub_sdk/ctl/cli_commands.py @@ -9,7 +9,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Optional -import jinja2 import typer import ujson from rich.console import Console @@ -18,7 +17,6 @@ from rich.panel import Panel from rich.pretty import Pretty from rich.table import Table -from rich.traceback import Traceback from .. import __version__ as sdk_version from ..async_typer import AsyncTyper @@ -31,7 +29,7 @@ from ..ctl.generator import run as run_generator from ..ctl.menu import app as menu_app from ..ctl.object import app as object_app -from ..ctl.render import list_jinja2_transforms +from ..ctl.render import list_jinja2_transforms, print_template_errors from ..ctl.repository import app as repository_app from ..ctl.repository import get_repository_config from ..ctl.schema import app as schema_app @@ -44,8 +42,9 @@ ) from ..ctl.validate import app as validate_app from ..exceptions import GraphQLError, ModuleImportError -from ..jinja2 import identify_faulty_jinja_code from ..schema import MainSchemaTypesAll, SchemaRoot +from ..template import Jinja2Template +from ..template.exceptions import JinjaTemplateError from ..utils import get_branch, write_to_file from ..yaml import SchemaFile from .exporter import dump @@ -168,43 +167,28 @@ async def run( raise typer.Abort(f"Unable to Load the method {method} in the Python script at {script}") client = initialize_client( - branch=branch, timeout=timeout, max_concurrent_execution=concurrent, identifier=module_name + branch=branch, + timeout=timeout, + max_concurrent_execution=concurrent, + identifier=module_name, ) func = getattr(module, method) await func(client=client, log=log, branch=branch, **variables_dict) -def render_jinja2_template(template_path: Path, variables: dict[str, str], data: dict[str, Any]) -> str: - if not template_path.is_file(): - console.print(f"[red]Unable to locate the template at {template_path}") - raise typer.Exit(1) - - templateLoader = jinja2.FileSystemLoader(searchpath=".") - templateEnv = jinja2.Environment(loader=templateLoader, trim_blocks=True, lstrip_blocks=True) - template = templateEnv.get_template(str(template_path)) - +async def render_jinja2_template(template_path: Path, variables: dict[str, Any], data: dict[str, Any]) -> str: + variables["data"] = data + jinja_template = Jinja2Template(template=Path(template_path), template_directory=Path()) try: - rendered_tpl = template.render(**variables, data=data) # type: ignore[arg-type] - except jinja2.TemplateSyntaxError as exc: - console.print("[red]Syntax Error detected on the template") - console.print(f"[yellow] {exc}") - raise typer.Exit(1) from exc - - except jinja2.UndefinedError as exc: - console.print("[red]An error occurred while rendering the jinja template") - traceback = Traceback(show_locals=False) - errors = identify_faulty_jinja_code(traceback=traceback) - for frame, syntax in errors: - console.print(f"[yellow]{frame.filename} on line {frame.lineno}\n") - console.print(syntax) - console.print("") - console.print(traceback.trace.stacks[0].exc_value) + rendered_tpl = await jinja_template.render(variables=variables) + except JinjaTemplateError as exc: + print_template_errors(error=exc, console=console) raise typer.Exit(1) from exc return rendered_tpl -def _run_transform( +async def _run_transform( query_name: str, variables: dict[str, Any], transform_func: Callable, @@ -227,7 +211,11 @@ def _run_transform( try: response = execute_graphql_query( - query=query_name, variables_dict=variables, branch=branch, debug=debug, repository_config=repository_config + query=query_name, + variables_dict=variables, + branch=branch, + debug=debug, + repository_config=repository_config, ) # TODO: response is a dict and can't be printed to the console in this way. @@ -249,7 +237,7 @@ def _run_transform( raise typer.Abort() if asyncio.iscoroutinefunction(transform_func): - output = asyncio.run(transform_func(response)) + output = await transform_func(response) else: output = transform_func(response) return output @@ -257,7 +245,7 @@ def _run_transform( @app.command(name="render") @catch_exception(console=console) -def render( +async def render( transform_name: str = typer.Argument(default="", help="Name of the Python transformation", show_default=False), variables: Optional[list[str]] = typer.Argument( None, help="Variables to pass along with the query. Format key=value key=value." @@ -289,7 +277,7 @@ def render( transform_func = functools.partial(render_jinja2_template, transform_config.template_path, variables_dict) # Query GQL and run the transform - result = _run_transform( + result = await _run_transform( query_name=transform_config.query, variables=variables_dict, transform_func=transform_func, @@ -410,7 +398,10 @@ def version() -> None: @app.command(name="info") @catch_exception(console=console) -def info(detail: bool = typer.Option(False, help="Display detailed information."), _: str = CONFIG_PARAM) -> None: # noqa: PLR0915 +def info( # noqa: PLR0915 + detail: bool = typer.Option(False, help="Display detailed information."), + _: str = CONFIG_PARAM, +) -> None: """Display the status of the Python SDK.""" info: dict[str, Any] = { @@ -476,10 +467,14 @@ def info(detail: bool = typer.Option(False, help="Display detailed information." infrahub_info = Table(show_header=False, box=None) if info["user_info"]: infrahub_info.add_row("User:", info["user_info"]["AccountProfile"]["display_label"]) - infrahub_info.add_row("Description:", info["user_info"]["AccountProfile"]["description"]["value"]) + infrahub_info.add_row( + "Description:", + info["user_info"]["AccountProfile"]["description"]["value"], + ) infrahub_info.add_row("Status:", info["user_info"]["AccountProfile"]["status"]["label"]) infrahub_info.add_row( - "Number of Groups:", str(info["user_info"]["AccountProfile"]["member_of_groups"]["count"]) + "Number of Groups:", + str(info["user_info"]["AccountProfile"]["member_of_groups"]["count"]), ) if groups := info["groups"]: diff --git a/infrahub_sdk/ctl/render.py b/infrahub_sdk/ctl/render.py index 05122102..cb1c962e 100644 --- a/infrahub_sdk/ctl/render.py +++ b/infrahub_sdk/ctl/render.py @@ -1,6 +1,12 @@ from rich.console import Console from ..schema.repository import InfrahubRepositoryConfig +from ..template.exceptions import ( + JinjaTemplateError, + JinjaTemplateNotFoundError, + JinjaTemplateSyntaxError, + JinjaTemplateUndefinedError, +) def list_jinja2_transforms(config: InfrahubRepositoryConfig) -> None: @@ -9,3 +15,36 @@ def list_jinja2_transforms(config: InfrahubRepositoryConfig) -> None: for transform in config.jinja2_transforms: console.print(f"{transform.name} ({transform.template_path})") + + +def print_template_errors(error: JinjaTemplateError, console: Console) -> None: + if isinstance(error, JinjaTemplateNotFoundError): + console.print("[red]An error occurred while rendering the jinja template") + console.print("") + if error.base_template: + console.print(f"Base template: [yellow]{error.base_template}") + console.print(f"Missing template: [yellow]{error.filename}") + return + + if isinstance(error, JinjaTemplateUndefinedError): + console.print("[red]An error occurred while rendering the jinja template") + for current_error in error.errors: + console.print(f"[yellow]{current_error.frame.filename} on line {current_error.frame.lineno}\n") + console.print(current_error.syntax) + console.print("") + console.print(error.message) + return + + if isinstance(error, JinjaTemplateSyntaxError): + console.print("[red]A syntax error was encountered within the template") + console.print("") + if error.filename: + console.print(f"Filename: [yellow]{error.filename}") + console.print(f"Line number: [yellow]{error.lineno}") + console.print() + console.print(error.message) + return + + console.print("[red]An error occurred while rendering the jinja template") + console.print("") + console.print(f"[yellow]{error.message}") diff --git a/infrahub_sdk/pytest_plugin/items/jinja2_transform.py b/infrahub_sdk/pytest_plugin/items/jinja2_transform.py index a5bba094..4ed2e2c5 100644 --- a/infrahub_sdk/pytest_plugin/items/jinja2_transform.py +++ b/infrahub_sdk/pytest_plugin/items/jinja2_transform.py @@ -1,51 +1,47 @@ from __future__ import annotations +import asyncio import difflib +from pathlib import Path from typing import TYPE_CHECKING, Any import jinja2 import ujson from httpx import HTTPStatusError -from rich.console import Console -from rich.traceback import Traceback -from ...jinja2 import identify_faulty_jinja_code -from ..exceptions import Jinja2TransformError, Jinja2TransformUndefinedError, OutputMatchError +from ...template import Jinja2Template +from ...template.exceptions import JinjaTemplateError +from ..exceptions import OutputMatchError from ..models import InfrahubInputOutputTest, InfrahubTestExpectedResult from .base import InfrahubItem if TYPE_CHECKING: - from pathlib import Path - from pytest import ExceptionInfo class InfrahubJinja2Item(InfrahubItem): + def _get_jinja2(self) -> Jinja2Template: + return Jinja2Template( + template=Path(self.resource_config.template_path), # type: ignore[attr-defined] + template_directory=Path(self.session.infrahub_config_path.parent), # type: ignore[attr-defined] + ) + def get_jinja2_environment(self) -> jinja2.Environment: - loader = jinja2.FileSystemLoader(self.session.infrahub_config_path.parent) # type: ignore[attr-defined] - return jinja2.Environment(loader=loader, trim_blocks=True, lstrip_blocks=True) + jinja2_template = self._get_jinja2() + return jinja2_template.get_environment() def get_jinja2_template(self) -> jinja2.Template: - return self.get_jinja2_environment().get_template(str(self.resource_config.template_path)) # type: ignore[attr-defined] + jinja2_template = self._get_jinja2() + return jinja2_template.get_template() def render_jinja2_template(self, variables: dict[str, Any]) -> str | None: + jinja2_template = self._get_jinja2() + try: - return self.get_jinja2_template().render(**variables) - except jinja2.UndefinedError as exc: - traceback = Traceback(show_locals=False) - errors = identify_faulty_jinja_code(traceback=traceback) - console = Console() - with console.capture() as capture: - console.print(f"An error occurred while rendering Jinja2 transform:{self.name!r}\n", soft_wrap=True) - console.print(f"{exc.message}\n", soft_wrap=True) - for frame, syntax in errors: - console.print(f"{frame.filename} on line {frame.lineno}\n", soft_wrap=True) - console.print(syntax, soft_wrap=True) - str_output = capture.get() + return asyncio.run(jinja2_template.render(variables=variables)) + except JinjaTemplateError as exc: if self.test.expect == InfrahubTestExpectedResult.PASS: - raise Jinja2TransformUndefinedError( - name=self.name, message=str_output, rtb=traceback, errors=errors - ) from exc + raise exc return None def get_result_differences(self, computed: Any) -> str | None: @@ -99,8 +95,8 @@ def runtest(self) -> None: raise OutputMatchError(name=self.name, differences=differences) def repr_failure(self, excinfo: ExceptionInfo, style: str | None = None) -> str: - if isinstance(excinfo.value, (Jinja2TransformUndefinedError, Jinja2TransformError)): - return excinfo.value.message + if isinstance(excinfo.value, (JinjaTemplateError)): + return str(excinfo.value.message) return super().repr_failure(excinfo, style=style) diff --git a/infrahub_sdk/template/__init__.py b/infrahub_sdk/template/__init__.py new file mode 100644 index 00000000..c43f7ad9 --- /dev/null +++ b/infrahub_sdk/template/__init__.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import linecache +from pathlib import Path +from typing import Any, Callable, NoReturn + +import jinja2 +from jinja2 import meta, nodes +from jinja2.sandbox import SandboxedEnvironment +from netutils.utils import jinja2_convenience_function +from rich.syntax import Syntax +from rich.traceback import Traceback + +from .exceptions import ( + JinjaTemplateError, + JinjaTemplateNotFoundError, + JinjaTemplateOperationViolationError, + JinjaTemplateSyntaxError, + JinjaTemplateUndefinedError, +) +from .filters import AVAILABLE_FILTERS +from .models import UndefinedJinja2Error + +netutils_filters = jinja2_convenience_function() + + +class Jinja2Template: + def __init__( + self, + template: str | Path, + template_directory: Path | None = None, + filters: dict[str, Callable] | None = None, + ) -> None: + self.is_string_based = isinstance(template, str) + self.is_file_based = isinstance(template, Path) + self._template = str(template) + self._template_directory = template_directory + self._environment: jinja2.Environment | None = None + + self._available_filters = [filter_definition.name for filter_definition in AVAILABLE_FILTERS] + self._trusted_filters = [ + filter_definition.name for filter_definition in AVAILABLE_FILTERS if filter_definition.trusted + ] + + self._filters = filters or {} + for user_filter in self._filters: + self._available_filters.append(user_filter) + self._trusted_filters.append(user_filter) + + self._template_definition: jinja2.Template | None = None + + def get_environment(self) -> jinja2.Environment: + if self._environment: + return self._environment + + if self.is_string_based: + return self._get_string_based_environment() + + return self._get_file_based_environment() + + def get_template(self) -> jinja2.Template: + if self._template_definition: + return self._template_definition + + try: + if self.is_string_based: + template = self._get_string_based_template() + else: + template = self._get_file_based_template() + except jinja2.TemplateSyntaxError as exc: + self._raise_template_syntax_error(error=exc) + except jinja2.TemplateNotFound as exc: + raise JinjaTemplateNotFoundError(message=exc.message, filename=str(exc.name)) + + return template + + def get_variables(self) -> list[str]: + env = self.get_environment() + + template_source = self._template + if self.is_file_based and env.loader: + template_source = env.loader.get_source(env, self._template)[0] + + template = env.parse(template_source) + + return sorted(meta.find_undeclared_variables(template)) + + def validate(self, restricted: bool = True) -> None: + allowed_list = self._available_filters + if restricted: + allowed_list = self._trusted_filters + + env = self.get_environment() + template_source = self._template + if self.is_file_based and env.loader: + template_source = env.loader.get_source(env, self._template)[0] + + template = env.parse(template_source) + for node in template.find_all(nodes.Filter): + if node.name not in allowed_list: + raise JinjaTemplateOperationViolationError(f"The '{node.name}' filter isn't allowed to be used") + + forbidden_operations = ["Call", "Import", "Include"] + if self.is_string_based and any(node.__class__.__name__ in forbidden_operations for node in template.body): + raise JinjaTemplateOperationViolationError( + f"These operations are forbidden for string based templates: {forbidden_operations}" + ) + + async def render(self, variables: dict[str, Any]) -> str: + template = self.get_template() + try: + output = await template.render_async(variables) + except jinja2.exceptions.TemplateNotFound as exc: + raise JinjaTemplateNotFoundError(message=exc.message, filename=str(exc.name), base_template=template.name) + except jinja2.TemplateSyntaxError as exc: + self._raise_template_syntax_error(error=exc) + except jinja2.UndefinedError as exc: + traceback = Traceback(show_locals=False) + errors = _identify_faulty_jinja_code(traceback=traceback) + raise JinjaTemplateUndefinedError(message=exc.message, errors=errors) + except Exception as exc: + if error_message := getattr(exc, "message", None): + message = error_message + else: + message = str(exc) + raise JinjaTemplateError(message=message or "Unknown template error") + + return output + + def _get_string_based_environment(self) -> jinja2.Environment: + env = SandboxedEnvironment(enable_async=True, undefined=jinja2.StrictUndefined) + self._set_filters(env=env) + self._environment = env + return self._environment + + def _get_file_based_environment(self) -> jinja2.Environment: + template_loader = jinja2.FileSystemLoader(searchpath=str(self._template_directory)) + env = jinja2.Environment( + loader=template_loader, + trim_blocks=True, + lstrip_blocks=True, + enable_async=True, + ) + self._set_filters(env=env) + self._environment = env + return self._environment + + def _set_filters(self, env: jinja2.Environment) -> None: + for default_filter in list(env.filters.keys()): + if default_filter not in self._available_filters: + del env.filters[default_filter] + + # Add filters from netutils + env.filters.update( + {name: jinja_filter for name, jinja_filter in netutils_filters.items() if name in self._available_filters} + ) + # Add user supplied filters + env.filters.update(self._filters) + + def _get_string_based_template(self) -> jinja2.Template: + env = self.get_environment() + self._template_definition = env.from_string(self._template) + return self._template_definition + + def _get_file_based_template(self) -> jinja2.Template: + env = self.get_environment() + self._template_definition = env.get_template(self._template) + return self._template_definition + + def _raise_template_syntax_error(self, error: jinja2.TemplateSyntaxError) -> NoReturn: + filename: str | None = None + if error.filename and self._template_directory: + filename = error.filename + if error.filename.startswith(str(self._template_directory)): + filename = error.filename[len(str(self._template_directory)) :] + + raise JinjaTemplateSyntaxError(message=error.message, filename=filename, lineno=error.lineno) + + +def _identify_faulty_jinja_code(traceback: Traceback, nbr_context_lines: int = 3) -> list[UndefinedJinja2Error]: + """This function identifies the faulty Jinja2 code and beautify it to provide meaningful information to the user. + + We use the rich's Traceback to parse the complete stack trace and extract Frames for each exception found in the trace. + """ + response = [] + + # Extract only the Jinja related exception + for frame in [frame for frame in traceback.trace.stacks[0].frames if not frame.filename.endswith(".py")]: + code = "".join(linecache.getlines(frame.filename)) + if frame.filename == "