Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better Jinja2 error messages #100

Merged
merged 5 commits into from
Nov 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Features
- #96: Drop legacy task names
- #97: Better context class. This allows dotted keys!
- #99: Add support for Python 3.12 (and drop 3.6, 3.7)
- #100: Better error messages for Jinja2 errors


Fixes
Expand Down
8 changes: 4 additions & 4 deletions src/scriptengine/jinja.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,11 @@ def render_with_context(string_arg):
try:
# Render string in parameter environment using context
return _param_env.from_string(string_arg).render(context)
except jinja2.TemplateSyntaxError:
except jinja2.TemplateError as e:
raise ScriptEngineParseJinjaError(
"Syntax error while rendering template string "
f'"{string_arg}"'
f'{" in boolean context" if boolean else ""}'
f"Jinja2 {type(e).__name__} while parsing '{string_arg}'"
f"{' (in boolean context): ' if boolean else ': '}"
f"{e}"
)

if isinstance(arg, str):
Expand Down
15 changes: 6 additions & 9 deletions src/scriptengine/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,29 +75,26 @@ def todo(self, todo):
def when(self, context):
try:
return self._when is None or j2render(self._when, context, boolean=True)
except ScriptEngineParseJinjaError:
except ScriptEngineParseJinjaError as e:
self.log_error(
"Error while parsing (Jinja2) invalid when clause "
f'"{self._when}" with context "{context}"'
f"Jinja2 error in *when* clause '{self._when}' (full error: {e})"
)
raise ScriptEngineJobParseError

def loop_spec(self, context):
try:
iter = j2render(self._loop, context)
except ScriptEngineParseJinjaError:
except ScriptEngineParseJinjaError as e:
self.log_error(
"Error while parsing (Jinja2) invalid loop expression "
f'"{self._loop}" with context "{context}"'
f"Jinja2 error in *loop* spec '{self._loop}' (full error: {e})"
)
raise ScriptEngineJobParseError
if isinstance(iter, str):
try:
iter = ast.literal_eval(iter or "None")
except (SyntaxError, ValueError):
except (SyntaxError, ValueError) as e:
self.log_error(
"Error while evaluating (AST) invalid loop expression "
f'"{iter}" with context "{context}"'
f"AST evaluation error in *loop* spec '{iter}' (full error: {e})"
)
raise ScriptEngineJobParseError
if isinstance(iter, dict):
Expand Down
15 changes: 11 additions & 4 deletions src/scriptengine/tasks/base/template.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""Template task for ScriptEngine."""

from pathlib import Path
import os
import stat
from pathlib import Path

import jinja2

from scriptengine.tasks.core import Task, timed_runner
from scriptengine.jinja import render as j2render
from scriptengine.exceptions import ScriptEngineTaskRunError
from scriptengine.jinja import filters as j2filters
from scriptengine.jinja import render as j2render
from scriptengine.tasks.core import Task, timed_runner


class Template(Task):
Expand Down Expand Up @@ -54,7 +56,12 @@ def run(self, context):
for name, function in j2filters().items():
j2env.filters[name] = function

output = j2render(j2env.get_template(src).render(context), context)
try:
output = j2render(j2env.get_template(src).render(context), context)
except jinja2.TemplateError as e:
self.log_error(f"Jinja2 error {type(e).__name__}: {e}")
raise ScriptEngineTaskRunError

with dst.open(mode="w") as f:
f.write(output + "\n")

Expand Down
118 changes: 56 additions & 62 deletions src/scriptengine/tasks/core/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,27 @@

import logging
import uuid

import yaml

import scriptengine.jinja
from scriptengine.yaml.noparse_strings import (
NoParseJinjaString,
NoParseYamlString,
)
import scriptengine.yaml

from scriptengine.exceptions import ScriptEngineTaskArgumentInvalidError, \
ScriptEngineTaskArgumentMissingError

from scriptengine.exceptions import (
ScriptEngineParseJinjaError,
ScriptEngineTaskArgumentInvalidError,
ScriptEngineTaskArgumentMissingError,
)
from scriptengine.yaml.noparse_strings import NoParseJinjaString, NoParseYamlString

_SENTINEL = object()


class Task:

_reg_name = None
_invalid_arguments = ('run', 'id', )
_invalid_arguments = (
"run",
"id",
)

@classmethod
def check_arguments(cls, arguments):
Expand All @@ -34,50 +35,48 @@ def check_arguments(cls, arguments):
ScriptEngineTaskArgumentMissingError.
"""
for name in arguments:
if name in getattr(cls, '_invalid_arguments', ()):
logging.getLogger('se.task').error(
if name in getattr(cls, "_invalid_arguments", ()):
logging.getLogger("se.task").error(
(
f'Invalid argument "{name}" found while '
f'trying to create "{cls.__name__}" task'
),
extra={'id': 'no id', 'type': cls.__name__},
extra={"id": "no id", "type": cls.__name__},
)
raise ScriptEngineTaskArgumentInvalidError

for name in getattr(cls, '_required_arguments', ()):
for name in getattr(cls, "_required_arguments", ()):
if name not in arguments:
logging.getLogger('se.task').error(
logging.getLogger("se.task").error(
(
f'Missing required argument "{name}" while '
f'trying to create "{cls.__name__}" task'
),
extra={'id': 'no id', 'type': cls.__name__},
extra={"id": "no id", "type": cls.__name__},
)
raise ScriptEngineTaskArgumentMissingError

def __init__(self, arguments=None):

self._identifier = uuid.uuid4()

if arguments is not None:
Task.check_arguments(arguments)
for name, value in arguments.items():
if hasattr(self, name):
self.log_error(
f'Invalid (reserved name) task argument: {name}'
)
self.log_error(f"Invalid (reserved name) task argument: {name}")
raise ScriptEngineTaskArgumentInvalidError
setattr(self, name, value)
self.log_debug(f'Created task: {self}')
self.log_debug(f"Created task: {self}")

@classmethod
def register_name(cls, name):
cls._reg_name = name

@property
def reg_name(self):
return self._reg_name or \
f'{self.__class__.__module__}:{self.__class__.__name__}'
return (
self._reg_name or f"{self.__class__.__module__}:{self.__class__.__name__}"
)

@property
def id(self):
Expand All @@ -88,32 +87,33 @@ def shortid(self):
return self._identifier.hex[:10]

def __repr__(self):
params = {key: val for key, val in self.__dict__.items()
if not (isinstance(key, str) and key.startswith('_'))}
params = {
key: val
for key, val in self.__dict__.items()
if not (isinstance(key, str) and key.startswith("_"))
}
params_list = f'{", ".join([f"{k}={params[k]}" for k in params])}'
return f'{self.__class__.__name__}({params_list})'
return f"{self.__class__.__name__}({params_list})"

def run(self, context):
raise NotImplementedError(
'Base class function Task.run() must not be called'
)
raise NotImplementedError("Base class function Task.run() must not be called")

def getarg(self, name, context={}, *,
parse_jinja=True, parse_yaml=True, default=_SENTINEL):
def getarg(
self, name, context={}, *, parse_jinja=True, parse_yaml=True, default=_SENTINEL
):
"""Returns the value of argument 'name'.
The argument value is parsed with Jinja2 and the given context,
unless 'parse_jinja' is False. The result is parsed once more with
the YAML parser in order to get a correctly typed result. If
parse_yaml is not True, the extra YAML parsing is skipped.
Parsing with Jinja/YAML is also skipped, if the argument value is a
string that starts with '_noparse_', '_noparsejinja_',
'_noparseyaml_', respectively.
If argument 'name' does not exist, the function raises an
AttributeError, unless a 'default' value is given.
"""
The argument value is parsed with Jinja2 and the given context,
unless 'parse_jinja' is False. The result is parsed once more with
the YAML parser in order to get a correctly typed result. If
parse_yaml is not True, the extra YAML parsing is skipped.
Parsing with Jinja/YAML is also skipped, if the argument value is a
string that starts with '_noparse_', '_noparsejinja_',
'_noparseyaml_', respectively.
If argument 'name' does not exist, the function raises an
AttributeError, unless a 'default' value is given.
"""

def parse(arg_):

# Recursively parse list items
if isinstance(arg_, list):
return [parse(item) for item in arg_]
Expand All @@ -123,24 +123,24 @@ def parse(arg_):
return dict((key, parse(val)) for key, val in arg_.items())

if isinstance(arg_, str):
if parse_jinja and \
not isinstance(arg_, NoParseJinjaString):
# Make sure that a NoParseString is still a NoParseString
# after this!
arg_ = type(arg_)(scriptengine.jinja.render(arg_, context))

if parse_yaml and \
not isinstance(arg_, NoParseYamlString):
if parse_jinja and not isinstance(arg_, NoParseJinjaString):
try:
# Make sure that a NoParseString is still a NoParseString
# after this!
arg_ = type(arg_)(scriptengine.jinja.render(arg_, context))
except ScriptEngineParseJinjaError as e:
self.log_error(e)
raise ScriptEngineTaskArgumentInvalidError

if parse_yaml and not isinstance(arg_, NoParseYamlString):
try:
return yaml.full_load(arg_)
except (
yaml.scanner.ScannerError,
yaml.parser.ParserError,
yaml.constructor.ConstructorError,
):
self.log_debug(
f'Reparsing argument "{arg_}" with YAML failed'
)
self.log_debug(f'Reparsing argument "{arg_}" with YAML failed')

# Return plain strings, not NoParse*Strings
return str(arg_)
Expand All @@ -152,20 +152,14 @@ def parse(arg_):
arg = getattr(self, name)
except AttributeError:
if default is _SENTINEL:
self.log_error(
f'Trying to access missing task argument: {name}'
)
self.log_error(f"Trying to access missing task argument: {name}")
raise ScriptEngineTaskArgumentMissingError
arg = default
return parse(arg)

def _log(self, level, msg):
logger = logging.getLogger('se.task')
logger.log(
level,
msg,
extra={'type': self.reg_name, 'id': self.shortid}
)
logger = logging.getLogger("se.task")
logger.log(level, msg, extra={"type": self.reg_name, "id": self.shortid})

def log_debug(self, msg):
self._log(logging.DEBUG, msg)
Expand Down