Writing template filters¶
A filter is a Python class derived from TemplateFilter. Register the class
under a name in Template(..., filters=...), then use that name after | in
an expression. Filters are the extension point for operations that the
restricted template language cannot perform directly.
This guide builds a filter that turns labels into identifiers, then sends the compiled template and its filter to another Python interpreter. For the language and built-in filters, see Templating.
Define a filter in a Python file¶
Save the following class in template_filters.py. The complete example module
is in examples/tools/template_filters.py in the repository.
from __future__ import annotations
from collections.abc import Iterator
from rmote.templates import TemplateFilter
class SlugFilter(TemplateFilter):
"""Turn a label into an ASCII identifier separated by dashes or underscores."""
pattern = r"[^a-z0-9]+"
def __call__(self, value: str, separator: str = "-") -> str:
import re
if separator not in ("-", "_"):
raise ValueError("separator must be '-' or '_'")
return re.sub(self.pattern, separator, value.casefold()).strip(separator)
__call__ receives the piped value first. Any arguments written inside the
filter’s parentheses follow it. Here, separator has a default, so callers can
omit it, pass it positionally, or pass it by name. The return value becomes the
input of the next filter in the chain; when the final expression is inserted
into text, the engine converts it to a string.
The class keeps its regular-expression pattern on the class and imports re
inside the method. Those choices also make it portable: its implementation
does not rely on a module global being present on the receiving interpreter.
Register and call the filter¶
Save this client beside template_filters.py:
from template_filters import SlugFilter
from rmote.templates import TEMPLATE_FILTERS, Template
filters = {**TEMPLATE_FILTERS, "slug": SlugFilter}
template = Template("service={{ name | slug | upper }}", filters=filters)
assert template.render(name=" Front End 01 ") == "service=FRONT-END-01"
underscores = Template("{{ name | slug('_') }}", filters=filters)
assert underscores.render(name="Front End 01") == "front_end_01"
keyword = Template("{{ name | slug(separator=separator) }}", filters=filters)
assert keyword.render(name="Front End 01", separator="_") == "front_end_01"
The filter name must be a public identifier, such as slug or service_name;
names starting with _, Python keywords, and punctuation are rejected. The
mapping value is SlugFilter, not SlugFilter() or a function. The engine
constructs the instances itself.
For this class, name | slug('_') calls instance(name, '_'). Arguments are
ordinary template expressions and can refer to context variables. Use Python
defaults and keyword-only parameters in __call__ to define your filter’s API.
Filter calls are synchronous: implement a regular def __call__, and do any
asynchronous data collection in the tool before rendering.
Registration |
Available filters |
|---|---|
Omit |
All entries of |
|
Only |
|
Built-ins plus |
|
Built-ins with |
|
None |
An explicit mapping completely replaces the defaults. TEMPLATE_FILTERS is
read-only; create a new dictionary when extending it. A template snapshots the
mapping at construction, so changing your dictionary later does not change an
already compiled template. Create another Template to use a different mapping.
Unknown filter names fail at construction; missing or unexpected call arguments
fail during rendering when Python invokes __call__.
The render_template helper and RenderTemplate.render with a source string
use the built-ins. Register custom filters by constructing a Template and
passing that object to the remote tool.
Instance state and input data¶
The engine calls each registered class with no constructor arguments. It creates one instance per registered name per render, including names that are not used by that template. Keep constructors cheap. All calls to a filter under the same name share that instance during one render; the next render starts over.
State can be useful for numbering output. This local example makes that lifetime visible:
from rmote.templates import Template, TemplateFilter
class NumberedFilter(TemplateFilter):
def __init__(self) -> None:
self.count = 0
def __call__(self, value: str) -> str:
self.count += 1
return f"{self.count}:{value}"
template = Template(
"{% for name in names %}{{ name | numbered }};{% endfor %}",
filters={"numbered": NumberedFilter},
)
assert template.render(names=["web", "db"]) == "1:web;2:db;"
assert template.render(names=["cache"]) == "1:cache;"
Use call arguments or context variables for configuration that changes between hosts. Mutating a class attribute on the controller is not a portable way to configure a remote filter: transfer carries the class’s source definition.
Filters receive the original context values. Input dictionaries and lists are
validated without copying, so changing them inside a filter changes the caller’s
data too. Prefer returning a new value. Plain input data is intentionally limited;
convert dataclasses with asdict and generators with list before rendering.
Return collections and objects¶
A filter may return a string, number, list, dictionary, or a trusted Python
object. Return the value the next operation needs; there is no need to stringify
a list that the template will loop over. Public attributes of a returned object
are available through dot lookup, as with the built-in ipaddress filter.
Private attributes and method calls remain forbidden in template expressions.
Iterator results are consumed and materialized, including iterators nested in
returned containers. This lets the template reuse the result or ask for its
length. Return finite iterators; rendering consumes them before proceeding.
For example, add this class to template_filters.py to expose dictionary items:
class ItemsFilter(TemplateFilter):
"""Expose dictionary items for unpacking in template loops."""
def __call__(self, value: dict[str, object]) -> Iterator[tuple[str, object]]:
return iter(value.items())
The complete file imports Iterator from collections.abc for its return
annotation. The template below reuses the materialized pairs for a length and a
loop, without calling settings.items() inside the template:
from template_filters import ItemsFilter
from rmote.templates import TEMPLATE_FILTERS, Template
template = Template(
"{% set pairs = settings | items %}"
"count={{ pairs | length }};"
"{% for name, value in pairs %}{{ name }}={{ value }};{% endfor %}",
filters={**TEMPLATE_FILTERS, "items": ItemsFilter},
)
assert template.render(settings={"port": 8080, "workers": 4}) == "count=2;port=8080;workers=4;"
Registered filters and their results are trusted. Accessing a result’s property, consuming its iterator, or converting it to a string can execute its Python code. The restricted expression language is not a sandbox for an untrusted filter.
Validate values and handle missing input¶
Raise an ordinary exception when input is invalid. For example, SlugFilter
raises ValueError for an unsupported separator. The exception propagates to
the caller; default does not catch errors thrown by a preceding filter.
from template_filters import SlugFilter
from rmote.templates import TEMPLATE_FILTERS, Template
template = Template(
"{{ name | slug(separator='.') | default('fallback') }}",
filters={**TEMPLATE_FILTERS, "slug": SlugFilter},
)
try:
template.render(name="Front End")
except ValueError as error:
assert "separator" in str(error)
else:
raise AssertionError("default must not hide an invalid filter argument")
Normally, a missing input raises an error before your filter is called. Put
default first when you want to supply a fallback:
{{ name | default('Unnamed Service') | slug }}.
A filter that deliberately handles absent values can set
handles_undefined = True. It then receives a TemplateUndefined sentinel for
missing simple lookups. Do not test that sentinel with if value or convert it
to a string: both operations re-raise the original lookup error. Check its type.
Add this class to template_filters.py to give required settings a named error:
class RequiredFilter(TemplateFilter):
"""Raise a named validation error for missing values or None."""
handles_undefined = True
def __call__(self, value: object, label: str) -> object:
from rmote.templates.filters import TemplateUndefined
if isinstance(value, TemplateUndefined) or value is None:
raise ValueError(f"{label} is required")
return value
from template_filters import RequiredFilter
from rmote.templates import Template
template = Template(
"{{ settings.port | required('service port') }}",
filters={"required": RequiredFilter},
)
assert template.render(settings={"port": 8080}) == "8080"
assert template.render(settings={"port": 0}) == "0"
for settings in ({}, {"port": None}):
try:
template.render(settings=settings)
except ValueError as error:
assert str(error) == "service port is required"
else:
raise AssertionError("A missing or None port must fail validation")
This opt-in concerns lookup failures in the piped input. It does not catch
arithmetic errors, failures in filter arguments, or arbitrary exceptions from
other filters. TemplateUndefined.error holds the original lookup exception if
you want to re-raise it.
Make the class portable¶
Custom filter serialization carries the class source and its filter base classes. The remote interpreter reconstructs the class without importing the user module where it was originally defined. This differs from transferring a whole module as a Tool.
Keep these requirements in mind when writing that source:
Define the class in a readable
.pyfile. REPL, notebook, or dynamically executed definitions may work locally but have no source available for transfer.Put runtime imports inside the class or its methods, and constants on the class. Closure variables, module globals, and external decorators are not captured. Reference class attributes through
selfortype(self).Filter inheritance is supported; all direct bases must also be
TemplateFiltersubclasses. Arbitrary mixin classes are not transferred by this serializer.External dependencies used by the filter must be available on the target. The engine and the examples here require only the standard library.
Keep the code compatible with the target’s Python version. Source transfer does not convert Python syntax between versions.
Classes defined inside functions can be transferred when their source is available, but variables captured from the enclosing function are still not included. A top-level class in a small module is usually easier to maintain.
Pickle round-tripping is a useful first check. It exercises the class source and the stored template program without reparsing the template:
import pickle
from template_filters import SlugFilter
from rmote.templates import Template
compiled = Template("{{ name | slug }}", filters={"slug": SlugFilter})
restored = pickle.loads(pickle.dumps(compiled))
assert restored.render(name="Front End") == "front-end"
Also test a real remote call: an in-process pickle check alone does not prove that external dependencies are present on the target. Only unpickle trusted payloads; restoring a custom filter executes its class source.
Render remotely with your filters¶
The following example renders in a separate local Python process. Use
Connection.from_ssh("user@server") in the same place to render on an SSH host,
or select another of rmote’s Transports:
from template_filters import ItemsFilter, RequiredFilter, SlugFilter
from rmote.templates import TEMPLATE_FILTERS, Template
from rmote.sync import Connection
from rmote.tools.template import RenderTemplate
compiled = Template(
"service={{ name | required('service name') | slug | upper }};"
"{% for key, value in settings | items %}{{ key }}={{ value }};{% endfor %}",
filters={
**TEMPLATE_FILTERS,
"slug": SlugFilter,
"items": ItemsFilter,
"required": RequiredFilter,
},
)
with Connection.from_local() as remote:
result = remote(
RenderTemplate.render, compiled,
name="Front End", settings={"port": 8080},
)
assert result == "service=FRONT-END;port=8080;"
Pass the Template object itself. Its compiled program, registration mapping,
and custom filter definitions travel together. rmote transfers the engine
package lazily; the target does not need template_filters.py installed.
The string result comes back through the ordinary RPC response.
With the asynchronous client, await the render call:
import asyncio
from template_filters import SlugFilter
from rmote.protocol import Protocol
from rmote.templates import Template
from rmote.tools.template import RenderTemplate
async def main() -> str:
compiled = Template("{{ name | slug }}", filters={"slug": SlugFilter})
async with await Protocol.from_ssh("user@server") as remote:
return await remote(RenderTemplate.render, compiled, name="Front End")
assert asyncio.run(main()) == "front-end"
These calls receive context values from the controller. To use data that only
the target can read, pass the compiled Template to your own Tool method,
collect the values there, and call template.render(**values) inside that
method. The same filter and program transfer rules apply. See
Writing Tools for defining the method and Template filters for the
filter class reference.