Skip to content

Commit 85ec75a

Browse files
committed
Refactor handling of Jinja2 templates and add filters
1 parent 7ce573b commit 85ec75a

File tree

15 files changed

+574
-27
lines changed

15 files changed

+574
-27
lines changed

infrahub_sdk/ctl/cli_commands.py

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from pathlib import Path
1010
from typing import TYPE_CHECKING, Any, Callable, Optional
1111

12-
import jinja2
1312
import typer
1413
import ujson
1514
from rich.console import Console
@@ -18,7 +17,6 @@
1817
from rich.panel import Panel
1918
from rich.pretty import Pretty
2019
from rich.table import Table
21-
from rich.traceback import Traceback
2220

2321
from .. import __version__ as sdk_version
2422
from ..async_typer import AsyncTyper
@@ -44,8 +42,14 @@
4442
)
4543
from ..ctl.validate import app as validate_app
4644
from ..exceptions import GraphQLError, ModuleImportError
47-
from ..jinja2 import identify_faulty_jinja_code
4845
from ..schema import MainSchemaTypesAll, SchemaRoot
46+
from ..template import Jinja2Template
47+
from ..template.exceptions import (
48+
JinjaTemplateError,
49+
JinjaTemplateNotFoundError,
50+
JinjaTemplateSyntaxError,
51+
JinjaTemplateUndefinedError,
52+
)
4953
from ..utils import get_branch, write_to_file
5054
from ..yaml import SchemaFile
5155
from .exporter import dump
@@ -174,37 +178,50 @@ async def run(
174178
await func(client=client, log=log, branch=branch, **variables_dict)
175179

176180

177-
def render_jinja2_template(template_path: Path, variables: dict[str, str], data: dict[str, Any]) -> str:
181+
async def render_jinja2_template(template_path: Path, variables: dict[str, Any], data: dict[str, Any]) -> str:
178182
if not template_path.is_file():
179183
console.print(f"[red]Unable to locate the template at {template_path}")
180184
raise typer.Exit(1)
181185

182-
templateLoader = jinja2.FileSystemLoader(searchpath=".")
183-
templateEnv = jinja2.Environment(loader=templateLoader, trim_blocks=True, lstrip_blocks=True)
184-
template = templateEnv.get_template(str(template_path))
185-
186+
variables["data"] = data
187+
jinja_template = Jinja2Template(template_directory=Path())
186188
try:
187-
rendered_tpl = template.render(**variables, data=data) # type: ignore[arg-type]
188-
except jinja2.TemplateSyntaxError as exc:
189-
console.print("[red]Syntax Error detected on the template")
190-
console.print(f"[yellow] {exc}")
189+
rendered_tpl = await jinja_template.render_from_file(template=template_path, variables=variables)
190+
except JinjaTemplateNotFoundError as exc:
191+
console.print("[red]An error occurred while rendering the jinja template")
192+
console.print("")
193+
if exc.base_template:
194+
console.print(f"Base template: [yellow]{exc.base_template}")
195+
console.print(f"Missing template: [yellow]{exc.filename}")
191196
raise typer.Exit(1) from exc
192197

193-
except jinja2.UndefinedError as exc:
198+
except JinjaTemplateUndefinedError as exc:
199+
console.print("[red]An error occurred while rendering the jinja template")
200+
for error in exc.errors:
201+
console.print(f"[yellow]{error.frame.filename} on line {error.frame.lineno}\n")
202+
console.print(error.syntax)
203+
console.print("")
204+
console.print(exc.message)
205+
raise typer.Exit(1) from exc
206+
except JinjaTemplateSyntaxError as exc:
207+
console.print("[red]A syntax error was encountered within the template")
208+
console.print("")
209+
if exc.filename:
210+
console.print(f"Filename: [yellow]{exc.filename}")
211+
console.print(f"Line number: [yellow]{exc.lineno}")
212+
console.print()
213+
console.print(exc.message)
214+
raise typer.Exit(1) from exc
215+
except JinjaTemplateError as exc:
194216
console.print("[red]An error occurred while rendering the jinja template")
195-
traceback = Traceback(show_locals=False)
196-
errors = identify_faulty_jinja_code(traceback=traceback)
197-
for frame, syntax in errors:
198-
console.print(f"[yellow]{frame.filename} on line {frame.lineno}\n")
199-
console.print(syntax)
200217
console.print("")
201-
console.print(traceback.trace.stacks[0].exc_value)
218+
console.print(f"[yellow]{exc.message}")
202219
raise typer.Exit(1) from exc
203220

204221
return rendered_tpl
205222

206223

207-
def _run_transform(
224+
async def _run_transform(
208225
query_name: str,
209226
variables: dict[str, Any],
210227
transform_func: Callable,
@@ -249,15 +266,15 @@ def _run_transform(
249266
raise typer.Abort()
250267

251268
if asyncio.iscoroutinefunction(transform_func):
252-
output = asyncio.run(transform_func(response))
269+
output = await transform_func(response)
253270
else:
254271
output = transform_func(response)
255272
return output
256273

257274

258275
@app.command(name="render")
259276
@catch_exception(console=console)
260-
def render(
277+
async def render(
261278
transform_name: str = typer.Argument(default="", help="Name of the Python transformation", show_default=False),
262279
variables: Optional[list[str]] = typer.Argument(
263280
None, help="Variables to pass along with the query. Format key=value key=value."
@@ -289,7 +306,7 @@ def render(
289306
transform_func = functools.partial(render_jinja2_template, transform_config.template_path, variables_dict)
290307

291308
# Query GQL and run the transform
292-
result = _run_transform(
309+
result = await _run_transform(
293310
query_name=transform_config.query,
294311
variables=variables_dict,
295312
transform_func=transform_func,

infrahub_sdk/pytest_plugin/items/jinja2_transform.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import difflib
5+
from pathlib import Path
46
from typing import TYPE_CHECKING, Any
57

68
import jinja2
@@ -10,13 +12,12 @@
1012
from rich.traceback import Traceback
1113

1214
from ...jinja2 import identify_faulty_jinja_code
15+
from ...template import Jinja2Template
1316
from ..exceptions import Jinja2TransformError, Jinja2TransformUndefinedError, OutputMatchError
1417
from ..models import InfrahubInputOutputTest, InfrahubTestExpectedResult
1518
from .base import InfrahubItem
1619

1720
if TYPE_CHECKING:
18-
from pathlib import Path
19-
2021
from pytest import ExceptionInfo
2122

2223

@@ -29,8 +30,12 @@ def get_jinja2_template(self) -> jinja2.Template:
2930
return self.get_jinja2_environment().get_template(str(self.resource_config.template_path)) # type: ignore[attr-defined]
3031

3132
def render_jinja2_template(self, variables: dict[str, Any]) -> str | None:
33+
jinja2_template = Jinja2Template(template_directory=Path(self.session.infrahub_config_path.parent)) # type: ignore[attr-defined]
34+
3235
try:
33-
return self.get_jinja2_template().render(**variables)
36+
return asyncio.run(
37+
jinja2_template.render_from_file(template=Path(self.resource_config.template_path), variables=variables) # type: ignore[attr-defined]
38+
)
3439
except jinja2.UndefinedError as exc:
3540
traceback = Traceback(show_locals=False)
3641
errors = identify_faulty_jinja_code(traceback=traceback)

infrahub_sdk/template/__init__.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from __future__ import annotations
2+
3+
import linecache
4+
from pathlib import Path
5+
from typing import Any, Callable, NoReturn
6+
7+
import jinja2
8+
from jinja2.sandbox import SandboxedEnvironment
9+
from netutils.utils import jinja2_convenience_function
10+
from rich.syntax import Syntax
11+
from rich.traceback import Traceback
12+
13+
from .constants import CURRATED_NETUTILS_FILTERS
14+
from .exceptions import (
15+
JinjaTemplateError,
16+
JinjaTemplateNotFoundError,
17+
JinjaTemplateSyntaxError,
18+
JinjaTemplateUndefinedError,
19+
)
20+
from .models import UndefinedJinja2Error
21+
22+
netutils_filters = jinja2_convenience_function()
23+
24+
25+
class Jinja2Template:
26+
FORBIDDEN_OPERATIONS: list[str] | None = None
27+
28+
def __init__(self, template_directory: Path | None = None) -> None:
29+
self._template_directory = template_directory
30+
self._filters: dict[str, Callable] = {}
31+
32+
self._filters.update(
33+
{name: jinja_filter for name, jinja_filter in netutils_filters.items() if name in CURRATED_NETUTILS_FILTERS}
34+
)
35+
36+
async def render_from_file(self, template: Path, variables: dict[str, Any]) -> str:
37+
template_loader = jinja2.FileSystemLoader(searchpath=str(self._template_directory))
38+
env = jinja2.Environment(loader=template_loader, trim_blocks=True, lstrip_blocks=True, enable_async=True)
39+
env.filters.update(self._filters)
40+
41+
try:
42+
jinja2_template = env.get_template(str(template))
43+
except jinja2.TemplateSyntaxError as exc:
44+
self._raise_template_syntax_error(error=exc)
45+
except jinja2.TemplateNotFound as exc:
46+
raise JinjaTemplateNotFoundError(message=exc.message, filename=str(exc.name))
47+
return await self._render(template=jinja2_template, variables=variables)
48+
49+
async def render_from_string(self, template: str, variables: dict[str, Any]) -> str:
50+
env = SandboxedEnvironment(enable_async=True, undefined=jinja2.StrictUndefined)
51+
env.filters.update(self._filters)
52+
try:
53+
jinja2_template = env.from_string(template)
54+
except jinja2.TemplateSyntaxError as exc:
55+
self._raise_template_syntax_error(error=exc)
56+
57+
return await self._render(template=jinja2_template, variables=variables)
58+
59+
async def _render(self, template: jinja2.Template, variables: dict[str, Any]) -> str:
60+
try:
61+
output = await template.render_async(variables)
62+
except jinja2.exceptions.TemplateNotFound as exc:
63+
raise JinjaTemplateNotFoundError(message=exc.message, filename=str(exc.name), base_template=template.name)
64+
except jinja2.TemplateSyntaxError as exc:
65+
self._raise_template_syntax_error(error=exc)
66+
except jinja2.UndefinedError as exc:
67+
traceback = Traceback(show_locals=False)
68+
errors = _identify_faulty_jinja_code(traceback=traceback)
69+
raise JinjaTemplateUndefinedError(message=exc.message, errors=errors)
70+
except Exception as exc:
71+
if error_message := getattr(exc, "message", None):
72+
message = error_message
73+
else:
74+
message = str(exc)
75+
raise JinjaTemplateError(message=message or "Unknown template error")
76+
77+
return output
78+
79+
def _raise_template_syntax_error(self, error: jinja2.TemplateSyntaxError) -> NoReturn:
80+
filename: str | None = None
81+
if error.filename and self._template_directory:
82+
filename = error.filename
83+
if error.filename.startswith(str(self._template_directory)):
84+
filename = error.filename[len(str(self._template_directory)) :]
85+
86+
raise JinjaTemplateSyntaxError(message=error.message, filename=filename, lineno=error.lineno)
87+
88+
89+
def _identify_faulty_jinja_code(traceback: Traceback, nbr_context_lines: int = 3) -> list[UndefinedJinja2Error]:
90+
"""This function identifies the faulty Jinja2 code and beautify it to provide meaningful information to the user.
91+
92+
We use the rich's Traceback to parse the complete stack trace and extract Frames for each exception found in the trace.
93+
"""
94+
response = []
95+
96+
# Extract only the Jinja related exception
97+
for frame in [frame for frame in traceback.trace.stacks[0].frames if not frame.filename.endswith(".py")]:
98+
code = "".join(linecache.getlines(frame.filename))
99+
if frame.filename == "<template>":
100+
lexer_name = "text"
101+
else:
102+
lexer_name = Traceback._guess_lexer(frame.filename, code)
103+
syntax = Syntax(
104+
code,
105+
lexer_name,
106+
line_numbers=True,
107+
line_range=(frame.lineno - nbr_context_lines, frame.lineno + nbr_context_lines),
108+
highlight_lines={frame.lineno},
109+
code_width=88,
110+
theme=traceback.theme,
111+
dedent=False,
112+
)
113+
response.append(UndefinedJinja2Error(frame=frame, syntax=syntax))
114+
115+
return response

infrahub_sdk/template/constants.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
CURRATED_NETUTILS_FILTERS = [
2+
"abbreviated_interface_name",
3+
"abbreviated_interface_name_list",
4+
"asn_to_int",
5+
"bits_to_name",
6+
"bytes_to_name",
7+
"canonical_interface_name",
8+
"canonical_interface_name_list",
9+
"cidr_to_netmask",
10+
"cidr_to_netmaskv6",
11+
"clean_config",
12+
"compare_version_loose",
13+
"compare_version_strict",
14+
"config_compliance",
15+
"config_section_not_parsed",
16+
"delimiter_change",
17+
"diff_network_config",
18+
"feature_compliance",
19+
"find_unordered_cfg_lines",
20+
"fqdn_to_ip",
21+
"get_all_host",
22+
"get_broadcast_address",
23+
"get_first_usable",
24+
"get_ips_sorted",
25+
"get_nist_urls",
26+
"get_nist_vendor_platform_urls",
27+
"get_oui",
28+
"get_peer_ip",
29+
"get_range_ips",
30+
"get_upgrade_path",
31+
"get_usable_range",
32+
"hash_data",
33+
"int_to_asdot",
34+
"interface_range_compress",
35+
"interface_range_expansion",
36+
"ip_addition",
37+
"ip_subtract",
38+
"ip_to_bin",
39+
"ip_to_hex",
40+
"ipaddress_address",
41+
"ipaddress_interface",
42+
"ipaddress_network",
43+
"is_classful",
44+
"is_fqdn_resolvable",
45+
"is_ip",
46+
"is_ip_range",
47+
"is_ip_within",
48+
"is_netmask",
49+
"is_network",
50+
"is_reversible_wildcardmask",
51+
"is_valid_mac",
52+
"longest_prefix_match",
53+
"mac_normalize",
54+
"mac_to_format",
55+
"mac_to_int",
56+
"mac_type",
57+
"name_to_bits",
58+
"name_to_bytes",
59+
"name_to_name",
60+
"netmask_to_cidr",
61+
"netmask_to_wildcardmask",
62+
"normalise_delimiter_caret_c",
63+
"paloalto_panos_brace_to_set",
64+
"paloalto_panos_clean_newlines",
65+
"regex_findall",
66+
"regex_match",
67+
"regex_search",
68+
"regex_split",
69+
"regex_sub",
70+
"sanitize_config",
71+
"section_config",
72+
"sort_interface_list",
73+
"split_interface",
74+
"uptime_seconds_to_string",
75+
"uptime_string_to_seconds",
76+
"version_metadata",
77+
"vlanconfig_to_list",
78+
"vlanlist_to_config",
79+
"wildcardmask_to_netmask",
80+
]
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from infrahub_sdk.exceptions import Error
6+
7+
if TYPE_CHECKING:
8+
from .models import UndefinedJinja2Error
9+
10+
11+
class JinjaTemplateError(Error):
12+
def __init__(self, message: str) -> None:
13+
self.message = message
14+
15+
16+
class JinjaTemplateNotFoundError(JinjaTemplateError):
17+
def __init__(self, message: str | None, filename: str, base_template: str | None = None) -> None:
18+
self.message = message or "Template Not Found"
19+
self.filename = filename
20+
self.base_template = base_template
21+
22+
23+
class JinjaTemplateSyntaxError(JinjaTemplateError):
24+
def __init__(self, message: str | None, lineno: int, filename: str | None = None) -> None:
25+
self.message = message or "Syntax Error"
26+
self.filename = filename
27+
self.lineno = lineno
28+
29+
30+
class JinjaTemplateUndefinedError(JinjaTemplateError):
31+
def __init__(self, message: str | None, errors: list[UndefinedJinja2Error]) -> None:
32+
self.message = message or "Undefined Error"
33+
self.errors = errors

0 commit comments

Comments
 (0)