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

Ported customize/filters/tests functionality from j2cli #46

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
172 changes: 172 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,18 @@ Options:
error will be raised).
* `--version`: prints the version of the tool and the Jinja2 package installed.

Customization Options:

These options were ported from the j2cli tool for backwards compatibility (See customization section below)

* `--filters PYTHON_FILE` - specify a python file containing additional j2 filters as simple functions. You can use this option more than once to include multiple files.
* NOTE: while this option's behavior matches j2cli documentation, but does not match j2cli implementation. If you are migrating from j2cli and use more than one file, you will need to adjust your cli args from `... --filters file1.py file2.py ...` to `... --filters file1.py --filters file2.py ...``.

* `--tests PYTHON_FILE [FILE2] ...` - specify a python file containing additional j2 tests as simple functions. You can use this option more than once to include multiple files.
* NOTE: while this option's behavior matches j2cli documentation, but does not match j2cli implementation. If you are migrating from j2cli and use more than one file, you will need to adjust your cli args from `... --tests file1.py file2.py ...` to `... --tests file1.py --tests file2.py ...``.

* `--customize PYTHON_FILE` - specify a customization python file. This file can modify context, add filters/tests or change J2 configuration. Unlike `--filters` or `--tests` - this option can only be used once per run.

There is some special behavior with environment variables:

* When `data` is not provided (data is `-`), `--format` defaults to
Expand Down Expand Up @@ -352,6 +364,166 @@ Notice that there must be quotes around the environment variable name
when it is a literal string.
<!-- fancy-readme end -->

## Customization

(this functionality was ported from j2cli)

Jinjanator now supports customizing your Jinja2 template processing via two methods - via simple files containing custom filters or tests, or via a more advanced "customize" file that allows you to do all of the above as well as modify core configuration of the Jinja2 engine

### Via filters/tests files

The simplest way to add additional filters or tests is via a "filters" or "tests" files. These files are simple python files with function. Each function becomes a filter or test. Examples:

`filters.py`

```python
# Simple filters file

def parentheses(message):
""" Put message in parenthesis """
return f"({message})"

```

`tests.py`

```python
# Example of simple tests file

def an_odd_number(number):
""" test if number is odd """
return True if (number % 2) else False
```

And a template that uses them:
```
{% for x in range(4) %}
{{x}} is: {% if x is an_odd_number %}
{{- "odd" | parentheses }}
{%- else %}
{{- "even" | parentheses }}
{%- endif %}
{%- endfor %}
```

The output is:

```
$ jinjanate --filter ./filters.py --test ./tests.py -- simple.j2

0 is: (even)
1 is: (odd)
2 is: (even)
3 is: (odd)

```

You can include multiple functions in each file and/or use multiple files as needed.

### Using via a Customize File

A more advanced way to customize your template processing is via a "customize" file.

Customize file allows you to:

* Pass additional keywords to Jinja2 environment
* Modify the context before it's used for rendering
* Register custom filters and tests

This is done through *hooks* that you implement in a customization file in Python language. Each hook is a plain functions at the module level with the exact name as shown below.

The following hooks are available:

* `j2_environment_params() -> dict`: returns a `dict` of additional parameters for
[Jinja2 Environment](http://jinja.pocoo.org/docs/2.10/api/#jinja2.Environment).
* `j2_environment(env: Environment) -> Environment`: lets you customize the `Environment` object.
* `alter_context(context: dict) -> dict`: lets you modify the context variables that are going to be
used for template rendering. You can do all sorts of pre-processing here.
* `extra_filters() -> dict`: returns a `dict` with extra filters for Jinja2
* `extra_tests() -> dict`: returns a `dict` with extra tests for Jinja2

All of them are optional.

The example customization.py file for your reference:

```python
#
# Example customization.py file for jinjanator
# Contains hooks that modify the way jinjanator is initialized and used


def j2_environment_params():
""" Extra parameters for the Jinja2 Environment """
# Jinja2 Environment configuration
# http://jinja.pocoo.org/docs/2.10/api/#jinja2.Environment
return dict(
# Just some examples

# Change block start/end strings
block_start_string='<%',
block_end_string='%>',
# Change variable strings
variable_start_string='<<',
variable_end_string='>>',
# Remove whitespace around blocks
trim_blocks=True,
lstrip_blocks=True,
# Enable line statements:
# http://jinja.pocoo.org/docs/2.10/templates/#line-statements
line_statement_prefix='#',
# Keep \n at the end of a file
keep_trailing_newline=True,
# Enable custom extensions
# http://jinja.pocoo.org/docs/2.10/extensions/#jinja-extensions
extensions=('jinja2.ext.i18n',),
)


def j2_environment(env):
""" Modify Jinja2 environment

:param env: jinja2.environment.Environment
:rtype: jinja2.environment.Environment
"""
env.globals.update(
my_function=lambda v: 'my function says "{}"'.format(v)
)
return env


def alter_context(context):
""" Modify the context and return it """
# An extra variable
context['ADD'] = '127'
return context


def extra_filters():
""" Declare some custom filters.

Returns: dict(name = function)
"""
return dict(
# Example: {{ var | parentheses }}
parentheses=lambda t: '(' + t + ')',
)


def extra_tests():
""" Declare some custom tests

Returns: dict(name = function)
"""
return dict(
# Example: {% if a|int is custom_odd %}odd{% endif %}
custom_odd=lambda n: True if (n % 2) else False
)

#

```
You can only have one customize file per run.

## Chat

If you'd like to chat with the jinjanator community, join us on
Expand Down
35 changes: 20 additions & 15 deletions src/jinjanator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
import jinjanator_plugins
import pluggy

from . import filters, formats, version
from . import customize, filters, formats, version
from .context import read_context_data
from .customize import CustomizationModule


class FilePathLoader(jinja2.BaseLoader):
Expand Down Expand Up @@ -71,23 +72,23 @@ def __init__(
j2_env_params.setdefault("extensions", self.ENABLED_EXTENSIONS)
j2_env_params.setdefault("loader", FilePathLoader(cwd))

self._env = jinja2.Environment(**j2_env_params, autoescape=False) # noqa: S701
self.env = jinja2.Environment(**j2_env_params, autoescape=False) # noqa: S701

for plugin_globals in plugin_hook_callers.plugin_globals():
self._env.globals |= plugin_globals
self.env.globals |= plugin_globals

for plugin_filters in plugin_hook_callers.plugin_filters():
self._env.filters |= plugin_filters
self.env.filters |= plugin_filters

for plugin_tests in plugin_hook_callers.plugin_tests():
self._env.tests |= plugin_tests
self.env.tests |= plugin_tests

for plugin_extensions in plugin_hook_callers.plugin_extensions():
for extension in plugin_extensions:
self._env.add_extension(extension)
self.env.add_extension(extension)

def render(self, template_name: str, context: Mapping[str, str]) -> str:
return self._env.get_template(template_name).render(context)
return self.env.get_template(template_name).render(context)


class UniqueStore(argparse.Action):
Expand Down Expand Up @@ -220,6 +221,9 @@ def parse_args(
help="Suppress informational messages",
)

# add args for customize support
customize.add_args(parser)

parser.add_argument(
"-o",
"--output-file",
Expand Down Expand Up @@ -304,7 +308,7 @@ def render_command(

# We always expect a file;
# unless the user wants 'env', and there's no input file provided.
if args.format == "env":
if args.format == "env" and args.data is None:
kpfleming marked this conversation as resolved.
Show resolved Hide resolved
"""
With the "env" format, if no dotenv filename is provided,
we have two options: 1. The user wants to use the current
Expand All @@ -321,12 +325,7 @@ def render_command(
And this is what we're going to do here as well. The script,
however, would give the user a hint that they should use '-'.
"""
if str(args.data) == "-":
input_data_f = stdin
elif args.data is None:
input_data_f = None
else:
input_data_f = args.data.open()
input_data_f = None
else:
input_data_f = stdin if args.data is None or str(args.data) == "-" else args.data.open()

Expand All @@ -342,13 +341,19 @@ def render_command(
args.import_env,
)

customizations = CustomizationModule.from_file(args.customize)

context = customizations.alter_context(context)

renderer = Jinja2TemplateRenderer(
cwd,
args.undefined,
j2_env_params={},
j2_env_params=customizations.j2_environment_params(),
plugin_hook_callers=plugin_hook_callers,
)

customize.apply(customizations, renderer.env, filters=args.filters, tests=args.tests)

try:
result = renderer.render(args.template, context)
except jinja2.exceptions.UndefinedError as e:
Expand Down
Loading
Loading