import os
import sys
from functools import reduce
from pathlib import Path
from typing import Dict, List, Union
import jinja2
from markupsafe import Markup
from .timetools import (add_to_datetime, strftime, to_fv3time, to_isotime,
to_julian, to_timedelta, to_YMD, to_YMDH)
__all__ = ['Jinja', 'parse_j2tmpl']
@jinja2.pass_eval_context
class SilentUndefined(jinja2.Undefined):
"""
Description
-----------
A Jinja2 undefined that does not raise an error when it is used in a
template. Instead, it returns the template back when the variable is not found
This class is not to be used outside of this file
Its purpose is to return the template instead of an empty string
Presently, it also does not return the filter applied to the variable.
This will be added later when a use case for it presents itself.
"""
def __str__(self):
return "{{ " + self._undefined_name + " }}"
def __add__(self, other):
return str(self) + other
def __radd__(self, other):
return other + str(self)
def __mod__(self, other):
return str(self) % other
def __call__(self, *args, **kwargs):
return Markup("{{ " + self._undefined_name + " }}")
[docs]
class Jinja:
"""
Description
-----------
A wrapper around jinja2 to render templates
"""
def __init__(self, template_path_or_string: str,
data: Dict,
allow_missing: bool = True,
searchpath: Union[str, List] = '/') -> None:
"""
Description
-----------
Given a path to a (jinja2) template and a data object, substitute the
template file with data.
Allow for retaining missing or undefined variables.
Also provide additional search paths for templates that may be included in the main template
Parameters
----------
template_path_or_string : str
Path to the template file or a templated string
data : dict
Data to be substituted into the template
TODO: make "data" optional so that the user can render the same template with different data
allow_missing : bool
If True, allow for missing or undefined variables
searchpath: str | list
Additional search paths for templates (default '/')
"""
self.jinja2_version = jinja2.__version__
self.data = data
self.undefined = SilentUndefined if allow_missing else jinja2.StrictUndefined
self.template_searchpath = searchpath if isinstance(searchpath, list) else [searchpath]
# Add a default search path if the user has not provided one
if '/' not in self.template_searchpath:
self.template_searchpath.insert(0, '/')
if os.path.isfile(template_path_or_string):
self.template_type = 'file'
template_path = Path(template_path_or_string)
template_dir = template_path.parent
self.template_file = str(template_path.relative_to(template_dir))
self.template_searchpath.append(str(template_dir))
else:
self.template_type = 'stream'
self.template_stream = template_path_or_string
[docs]
def get_set_env(self, loader: jinja2.BaseLoader, filters: Dict[str, callable] = None) -> jinja2.Environment:
"""
Description
-----------
Define the environment for the jinja2 template
Any number of filters can be added here.
Optionally, a dictionary of external filters can be passed in
Currently, the following filters are defined:
strftime: convert a datetime object to a string with a user defined format
to_isotime: convert a datetime object to an ISO 8601 string
to_fv3time: convert a datetime object to a FV3 time string
to_YMDH: convert a datetime object to a YYYYMMDDHH string
to_YMD: convert a datetime object to a YYYYMMDD string
to_julian: convert a datetime object to a julian day
to_f90bool: convert a boolean to a fortran boolean
relpath: convert a full path to a relative path based on an input root_path
getenv: read variable from environment if defined, else UNDEFINED
to_timedelta: convert a string to a timedelta object
add_to_datetime: add time to a datetime, return new datetime object
replace_tmpl: replace substrings of an input string with replacements specified by an input dictionary
The Expression Statement extension "jinja2.ext.do", which enables
{% do ... %} statements. These are useful for appending to lists.
e.g. {{ bar.append(foo) }} would print "None" to the parsed jinja
template, but {% do bar.append(foo) %} would not.
Parameters
----------
loader: jinja2.BaseLoader
An instance of class jinja2.BaseLoader
filters: Dict[str, callable] (optional)
A dictionary of filters to be added to the environment
Returns
-------
env: jinja2.Environment
"""
env = jinja2.Environment(loader=loader, undefined=self.undefined)
env.add_extension("jinja2.ext.do")
env.filters["strftime"] = lambda dt, fmt: strftime(dt, fmt)
env.filters["to_isotime"] = lambda dt: to_isotime(dt) if not isinstance(dt, SilentUndefined) else dt
env.filters["to_fv3time"] = lambda dt: to_fv3time(dt) if not isinstance(dt, SilentUndefined) else dt
env.filters["to_YMDH"] = lambda dt: to_YMDH(dt) if not isinstance(dt, SilentUndefined) else dt
env.filters["to_YMD"] = lambda dt: to_YMD(dt) if not isinstance(dt, SilentUndefined) else dt
env.filters["to_julian"] = lambda dt: to_julian(dt) if not isinstance(dt, SilentUndefined) else dt
env.filters["to_f90bool"] = lambda bool: ".true." if bool else ".false."
env.filters['getenv'] = lambda name, default='UNDEFINED': os.environ.get(name, default)
env.filters["relpath"] = lambda pathname, start=os.curdir: os.path.relpath(pathname, start)
env.filters["add_to_datetime"] = (
lambda dt, delta: add_to_datetime(dt, delta)
if not (isinstance(dt, SilentUndefined) or isinstance(delta, SilentUndefined))
else dt if isinstance(dt, SilentUndefined) else delta)
env.filters["to_timedelta"] = lambda delta_str: to_timedelta(delta_str) if not isinstance(delta_str, SilentUndefined) else delta_str
env.filters["replace_tmpl"] = lambda string, tmpl_dict: reduce(lambda ss, kk: ss.replace(kk, tmpl_dict[kk]), tmpl_dict, string)
env.filters["path_exists"] = lambda path: Path(path).exists()
# Add any additional filters
if filters is not None:
for filter_name, filter_func in filters.items():
env = self.add_filter_to_env(env, filter_name, filter_func)
return env
[docs]
@staticmethod
def add_filter_to_env(env: jinja2.Environment, filter_name: str, filter_func: callable) -> jinja2.Environment:
"""
Description
-----------
Add a custom filter to the jinja2 environment
Parameters
----------
env: jinja2.Environment
Active jinja2 environment
filter_name: str
name of the filter
filter_func: callable
function that will be called
Returns
-------
env: jinja2.Environment
Active jinja2 environment with the new filter added
"""
env.filters[filter_name] = filter_func
return env
@property
def render(self) -> str:
"""
Description
-----------
Render the Jinja2 template with the data
Parameters
----------
None
Returns
-------
rendered: str
Rendered template into text
"""
render_map = {'stream': self._render_stream,
'file': self._render_file}
return render_map[self.template_type]()
def _render_stream(self) -> str:
loader = jinja2.BaseLoader()
env = self.get_set_env(loader)
template = env.from_string(self.template_stream)
return self._render_template(template)
def _render_file(self) -> str:
loader = jinja2.FileSystemLoader(self.template_searchpath)
env = self.get_set_env(loader)
template = env.get_template(self.template_file)
return self._render_template(template)
def _render_template(self, template: jinja2.Template) -> str:
"""
Description
-----------
Render a jinja2 template object
Parameters
----------
template: jinja2.Template
Returns
-------
rendered: str
"""
try:
rendered = template.render(**self.data)
except jinja2.UndefinedError as ee:
raise NameError(f"Undefined variable in Jinja2 template\n{ee}")
return rendered
[docs]
def save(self, output_file: str) -> None:
"""
Description
-----------
Render and save the output to a file
Parameters
----------
output_file: str
Path to the output file
Returns
-------
None
"""
with open(output_file, 'wb') as fh:
fh.write(self.render.encode("utf-8"))
@property
def dump(self) -> None:
"""
Description
-----------
Render and dump the output to stdout
Returns
-------
None
"""
sys.stdout.write(self.render)
def parse_j2tmpl(template_path_or_string: str, data: Dict, output_file=None):
"""
Description
-----------
Render a jinja2 template and optionally write the output to file
Parameters
-----------
template_path_or_string : str
Path to the template file or a templated string
data : dict
Data to be substituted into the template
output_file: str
Path to the output file
Returns
-------
None
"""
jobj = Jinja(template_path_or_string, data)
if output_file is None:
return jobj.render
else:
jobj.save(output_file)