A Python template engine for the Liquid markup language.
Project description
A Python implementation of Liquid. A non evaling templating language suitable for end users.
Installing
Install and update using pip:
$ python -m pip install -U python-liquid
Quick Start
Please see Shopify’s documentation for template syntax and a reference of available tags and filters.
Render a template string by creating a Template and calling its render method.
from liquid import Template
template = Template("Hello, {{ you }}!")
print(template.render(you="World")) # "Hello, World!"
print(template.render(you="Liquid")) # "Hello, Liquid!"
Keyword arguments passed to render are available as variables for templates to use in Liquid expressions.
from liquid import Template
template = Template(
"{% for person in people %}"
"Hello, {{ person.name }}!\n"
"{% endfor %}"
)
context_data = {"people": [
{"name": "John"},
{"name": "Sally"},
]}
print(template.render(**context_data))
# "Hello, John!"
# "Hello, Sally!"
Loading Templates
If you want to use the built-in include or render tags, you’ll need to create an Environment, with a template Loader, then load and render templates from that environment.
This example assumes a folder called templates exists in the current working directory, and that the template file index.html exists within it.
from liquid import Environment
from liquid import FileSystemLoader
env = Environment(loader=FileSystemLoader("templates/"))
template = env.get_template("index.html")
print(template.render(some="variable", other="thing"))
You can create templates from strings using an Environment too. This is often more efficient than using Template directly.
from liquid import Environment
env = Environment()
template = env.from_string("""
<html>
{% for i in (1..3) %}
<p>hello {{ some }} {{ i }}</p>
{% endfor %}
</html>
""")
print(template.render(some="thing"))
Render Context
Each render context includes namespaces for global variables passed down from the Environment and local variables assigned with the built-in {% assign %} or {% capture %} tags.
The Environment constructor accepts globals, a dictionary of variables made available to all templates rendered from that environment.
from liquid import Environment
env = Environment(globals={"site_name": "Google"})
template = env.from_string("""
<html>
<h1>{{ site_name }}</h1>
{% for i in (1..3) %}
<p>hello {{ some }} {{ i }}</p>
{% endfor %}
</html>
""")
print(template.render(some="thing"))
As does Template, Environment.get_template and Environment.from_string, where the dictionary of variables is added to the resulting render context each time you call render.
from liquid import Environment
env = Environment()
template = env.get_template("index.html", globals={"page": "home"})
print(template.render(some="thing"))
Strictness
Templates are parsed and rendered in strict mode by default. Where syntax and render-time type errors raise an exception as soon as possible. You can change the error tolerance mode with the tolerance argument to the Environment or Template constructor.
Available modes are Mode.STRICT, Mode.WARN and Mode.LAX.
from liquid import Environment, FileSystemLoader, Mode
env = Environment(
loader=FileSystemLoader("templates/"),
tolerance=Mode.LAX,
)
By default, references to undefined variables are silently ignored. Pass StrictUndefined as the undefined argument to Template or Environment, and any operation on an undefined variable will raise an UndefinedError.
from liquid import Environment, StrictUndefined
env = Environment(
loader=FileSystemLoader("templates/"),
undefined=StrictUndefined,
)
HTML Auto Escape
As of version 0.7.4, Python Liquid offers HTML auto-escaping. Where context variables are automatically escaped on output. Install optional dependencies for auto-escaping using the autoescape extra.
$ python -m pip install -U python-liquid[autoescape]
Auto-escaping is disabled by default. Enable it by setting the Environment or Template autoescape argument to True.
>>> from liquid import Environment
>>> env = Environment(autoescape=True)
>>> template = env.from_string("<p>Hello, {{ you }}</p>")
>>> template.render(you='</p><script>alert("XSS!");</script>')
'<p>Hello, </p><script>alert("XSS!");</script></p>'
Mark a string as “safe” by making it Markup.
>>> from liquid import Environment, Markup
>>> env = Environment(autoescape=True)
>>> template = env.from_string("<p>Hello, {{ you }}</p>")
>>> template.render(you=Markup("<em>World!</em>"))
'<p>Hello, <em>World!</em></p>'
Alternatively use the non-standard safe filter.
>>> from liquid import Environment
>>> env = Environment(autoescape=True)
>>> template = env.from_string("<p>Hello, {{ you | safe }}</p>")
>>> template.render(you="<em>World!</em>")
'<p>Hello, <em>World!</em></p>'
Objects and Drops
Python Liquid uses __getitem__ internally for resolving attribute/property names and indexed array access. So, if your data (keyword arguments passed to Template.render()) is some combination of Dictionaries and Lists, for example, templates can reference objects as follows.
>>> from liquid import Template
>>> example_data = {
... "products": [
... {
... "title": "Some Shoes",
... "available": 5,
... "colors": [
... "blue",
... "red",
... ],
... },
... {
... "title": "A Hat",
... "available": 2,
... "colors": [
... "grey",
... "brown",
... ],
... },
... ]
... }
>>> Template("{{ products[0]title }}").render(**example_data)
'Some Shoes'
>>> Template("{{ products[-2]['title'] }}").render(**example_data)
'Some Shoes'
>>> Template("{{ products.last.title }}").render(**example_data)
'A Hat'
>>> Template("{{ products.last.foo }}").render(**example_data)
''
>>> Template("{{ products.last.foo }}", undefined=StrictUndefined).render(**example_data)
Traceback (most recent call last):
.
.
liquid.exceptions.UndefinedError: key error: 'foo', products[last][foo], on line 1
Attempting to access properties from a Python class or class instance will not work.
>>> from liquid import Template, StrictUndefined
>>>
>>> class Product:
... def __init__(self, title, colors):
... self.title = title
... self.colors = colors
>>>
>>> products = [
... Product(title="Some Shoes", colors=["blue", "red"]),
... Product(title="A Hat", colors=["grey", "brown"]),
... ]
>>>
>>> Template("{{ products.first.title }}").render(products=products)
''
>>> Template("{{ products.first.title }}", undefined=StrictUndefined).render(products=products)
Traceback (most recent call last):
.
.
UndefinedError: 'Product' object is not subscriptable: products[first][title], on line 1
This is by design, and is one of the reasons Liquid is considered “safe” and “suitable for end users”. To expose an object’s properties we can implement Python’s Mapping or Sequence interface. This is Python Liquid’s equivalent of a “drop”.
from collections import abc
from liquid import Template, StrictUndefined
class User(abc.Mapping):
def __init__(
self,
first_name,
last_name,
perms,
):
self.first_name = first_name
self.last_name = last_name
self.perms = perms or []
self._keys = [
"first_name",
"last_name",
"is_admin",
"name",
]
def __getitem__(self, k):
if k in self._keys:
return getattr(self, k)
raise KeyError(k)
def __iter__(self):
return iter(self._keys)
def __len__(self):
return len(self._keys)
def __str__(self):
return f"User(first_name='{self.first_name}', last_name='{self.last_name}')"
@property
def is_admin(self):
return "admin" in self.perms
@property
def name(self):
return f"{self.first_name} {self.last_name}"
user = User("John", "Smith", ["admin"])
print(Template("{{ user.first_name }}").render(user=user)) # John
print(Template("{{ user.name }}").render(user=user)) # John Smith
print(Template("{{ user.is_admin }}").render(user=user)) # true
print(Template("{{ user.perms[0] }}", undefined=StrictUndefined).render(user=user))
# UndefinedError: key error: 'perms', user[perms][0], on line 1
One could implement a simple “Drop” wrapper for data access objects like this, while still being explicit about which properties are exposed to templates.
class Drop(abc.Mapping):
def __init__(obj, keys):
self.obj = obj
self.keys = keys
def __getitem__(self, k):
# Delegate attribute access to self.obj only if `k` is in `self.keys`.
if k in self.keys:
return getattr(obj, k)
raise KeyError(k)
def __iter__(self):
return iter(self.keys)
def __len__(self):
return len(self.keys)
By implementing the __liquid__ method, Python class instances can behave like primitive Liquid data types. This is useful for situations where you need your Python object to act as an array index, or to be compared to a primitive data type, for example.
from liquid import Template
class IntDrop:
def __init__(self, val: int):
self.val = val
def __int__(self) -> int:
return self.val
def __str__(self) -> str:
return "one"
def __liquid__(self) -> int:
return self.val
template = Template(
"{% if my_drop < 10 %}"
"{{ my_drop }} "
"{% endif %}"
"{{ some_array[my_drop] }}"
)
context_data = {
"my_drop": IntDrop(1),
"some_array": ["a", "b", "c"],
}
print(template.render(**context_data)) # one b
Async Support
Python Liquid supports loading and rendering templates asynchronously. When Template.render_async is awaited, render and include tags will use Environment.get_template_async, which delegates to get_source_async of the configured template loader.
import asyncio
from liquid import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("templates/"))
async def coro():
template = await env.get_template_async("index.html")
return await template.render_async(you="World")
result = asyncio.run(coro())
Custom template loaders should implement get_source_async. For example, AsyncDatabaseLoader will load templates from a PostgreSQL database using asyncpg.
import datetime
import functools
import asyncpg
from liquid import Environment
from liquid.exceptions import TemplateNotFound
from liquid.loaders import BaseLoader
from liquid.loaders import TemplateSource
class AsyncDatabaseLoader(BaseLoader):
def __init__(self, pool: asyncpg.Pool) -> None:
self.pool = pool
def get_source(self, env: Environment, template_name: str) -> TemplateSource:
raise NotImplementedError("async only loader")
async def _is_up_to_date(self, name: str, updated: datetime.datetime) -> bool:
async with self.pool.acquire() as connection:
return updated == await connection.fetchval(
"SELECT updated FROM templates WHERE name = $1", name
)
async def get_source_async(
self, env: Environment, template_name: str
) -> TemplateSource:
async with self.pool.acquire() as connection:
source = await connection.fetchrow(
"SELECT source, updated FROM templates WHERE name = $1", template_name
)
if not source:
raise TemplateNotFound(template_name)
return TemplateSource(
source=source["source"],
filename=template_name,
uptodate=functools.partial(
self._is_up_to_date, name=template_name, updated=source["updated"]
),
)
Custom “drops” can implement __getitem_async__. If an instance of a drop that implements __getitem_async__ appears in a render_async context, __getitem_async__ will be awaited instead of calling __getitem__.
Most likely used for lazy loading objects from a database, an async drop would look something like this.
class SomeAsyncDrop(abc.Mapping):
def __init__(self, val):
self.key = "foo"
self.val = val
def __len__(self):
return 1
def __iter__(self):
return iter([self.key])
def __getitem__(self, k):
# Blocking IO here
time.sleep(0.5)
# ...
async def __getitem_async__(self, k):
# Do async IO here.
asyncio.sleep(0.5)
# ...
Compatibility
We strive to be 100% compatible with the reference implementation of Liquid, written in Ruby. That is, given an equivalent render context, a template rendered with Python Liquid should produce the same output as when rendered with Ruby Liquid.
Python Liquid faithfully reproduces the following tags.
assign
capture
case/when
comment
cycle
decrement
echo
for/break/continue
ifchanged
if/elsif/else
include
increment
liquid
raw
render
tablerow
unless
Given a liquid.Environment, you could print a list of registered filters, with their doc strings, like this.
from liquid import Environment
env = Environment()
for name, func in env.filters.items():
print(f"{name}: {func.__doc__}\n\n")
Known Issues
Please help by raising an issue if you notice an incompatibility.
Error handling. Python Liquid might not handle syntax or type errors in the same way as the reference implementation. We might fail earlier or later, and will almost certainly produce a different error message.
The built-in date filter uses dateutil for parsing strings to datetimes, and strftime for formatting. There are likely to be some inconsistencies between this and the reference implementation’s equivalent parsing and formatting of dates and times.
In Ruby Liquid, the built-in increment and decrement tags can, in some cases, mutate “global” context and keep named counters alive between renders. Although not difficult to implement, I can’t quite bring myself to do it.
If a range literal uses a float literal as its start or stop value, the float literal must have something after the decimal point. This is OK (1.0..3). This is not (1...3). Ruby Liquid will accept either, resulting in a sequence of [1,2,3].
Benchmark
You can run the benchmark using make benchmark (or python -O performance.py if you don’t have make) from the root of the source tree. On my ropey desktop computer with a Ryzen 5 1500X, we get the following results.
Best of 5 rounds with 100 iterations per round and 60 ops per iteration (6000 ops per round).
lex template (not expressions): 1.3s (4727.35 ops/s, 78.79 i/s)
lex and parse: 6.4s (942.15 ops/s, 15.70 i/s)
render: 1.7s (3443.62 ops/s, 57.39 i/s)
lex, parse and render: 8.2s (733.30 ops/s, 12.22 i/s)
And PyPy3.7 gives us a decent increase in performance.
Best of 5 rounds with 100 iterations per round and 60 ops per iteration (6000 ops per round).
lex template (not expressions): 0.58s (10421.14 ops/s, 173.69 i/s)
lex and parse: 2.9s (2036.33 ops/s, 33.94 i/s)
render: 1.1s (5644.80 ops/s, 94.08 i/s)
lex, parse and render: 4.2s (1439.43 ops/s, 23.99 i/s)
On the same machine, running rake benchmark:run from the root of the reference implementation source tree gives us these results.
/usr/bin/ruby ./performance/benchmark.rb lax
Running benchmark for 10 seconds (with 5 seconds warmup).
Warming up --------------------------------------
parse: 3.000 i/100ms
render: 8.000 i/100ms
parse & render: 2.000 i/100ms
Calculating -------------------------------------
parse: 39.072 (± 0.0%) i/s - 393.000 in 10.058789s
render: 86.995 (± 1.1%) i/s - 872.000 in 10.024951s
parse & render: 26.139 (± 0.0%) i/s - 262.000 in 10.023365s
I’ve tried to match the benchmark workload to that of the reference implementation, so that we might compare results directly. The workload is meant to be representative of Shopify’s use case, although I wouldn’t be surprised if their usage has changed subtly since the benchmark fixture was designed.
Custom Filters
Add a custom template filter to an Environment by calling its add_filter method. A filter can be any callable that accepts at least one argument (the result of the left hand side of a filtered expression), and returns a string or object with a __str__ method.
Here’s a simple example of adding str.endswith as a filter function.
from liquid import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("templates/"))
env.add_filter("endswith", str.endswith)
And use it like this.
{% assign foo = "foobar" | endswith: "bar" %}
{% if foo %}
<!-- do something -->
{% endif %}
Decorate filter functions with with_context or with_environment to have the active context or environment passed as a keyword arguments.
from liquid.filter import with_context
from liquid.filter import string_filter
@string_filter
@with_context
def link_to_tag(label, tag, *, context):
handle = context.resolve("handle", default="")
return (
f'<a title="Show tag {tag}" href="/collections/{handle}/{tag}">{label}</a>'
)
And register it wherever you create your environment.
from liquid import Environment, FileSystemLoader
from myfilters import link_to_tag
env = Environment(loader=FileSystemLoader("templates/"))
env.add_filter("link_to_tag", link_to_tag)
In a template, you could then use the link_to_tag filter like this.
{% if tags %}
<dl class="navbar">
<dt>Tags</dt>
{% for tag in collection.tags %}
<dd>{{ tag | link_to_tag: tag }}</dd>
{% endfor %}
</dl>
{% endif %}
All built-in filters are implemented in this way, so have a look in liquid/builtin/filters/ for many more examples.
Note that old style, class-based filters are depreciated and will be removed in Liquid 0.9. You can still implement custom filters as callable classes, but Liquid will not include any abstract base classes for filters or legacy filter “helpers”.
Custom Loaders
Write a custom loader class by inheriting from liquid.loaders.BaseLoader and implementing its get_source method. Here we implement DictLoader, a loader that uses a dictionary of strings instead of the file system for loading templates.
from liquid.loaders import BaseLoader
from liquid.loaders import TemplateSource
from liquid.exceptions import TemplateNotFound
class DictLoader(BaseLoader):
def __init__(self, templates: Mapping[str, str]):
self.templates = templates
def get_source(self, _: Env, template_name: str) -> TemplateSource:
try:
source = self.templates[template_name]
except KeyError as err:
raise TemplateNotFound(template_name) from err
return TemplateSource(source, template_name, None)
TemplateSource is a named tuple containing the template source as a string, its name and an optional uptodate callable. If uptodate is not None it should be a callable that returns False if the template needs to be loaded again, or True otherwise.
You could then use DictLoader like this.
from liquid import Environment
from liquid.loaders import DictLoader
snippets = {
"greeting": "Hello {{ user.name }}",
"row": """
<div class="row"'
<div class="col">
{{ row_content }}
</div>
</div>
""",
}
env = Environment(loader=DictLoader(snippets))
template = env.from_string("""
<html>
{% include 'greeting' %}
{% for i in (1..3) %}
{% include 'row' with i as row_content %}
{% endfor %}
</html>
""")
print(template.render(user={"name": "Brian"}))
Contributing
Install development dependencies with Pipenv
Python Liquid fully embraces type hints and static type checking. Run mypy or tox -e typing to check for issues.
Format code using black.
Write tests using unittest.TestCase.
Run tests with make test or python -m unittest or pytest.
Check test coverage with make coverage and open htmlcov/index.html in your browser.
Check your changes have not adversely affected performance with make benchmark.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
File details
Details for the file python-liquid-0.11.0.tar.gz
.
File metadata
- Download URL: python-liquid-0.11.0.tar.gz
- Upload date:
- Size: 183.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/3.4.2 importlib_metadata/4.5.0 pkginfo/1.7.1 requests/2.26.0 requests-toolbelt/0.9.1 tqdm/4.61.2 CPython/3.8.6
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | 9985ffb136c5bbcf2a55f6e322f1b3aa55be82951687d8b522542d55cf15e631 |
|
MD5 | 01598858116696cb85970522cbd8cf95 |
|
BLAKE2b-256 | 67b4f4942881e5a8c83c360f98441e133e0804fd05ad12840c6d8c2fa7e2f7a7 |
File details
Details for the file python_liquid-0.11.0-py3-none-any.whl
.
File metadata
- Download URL: python_liquid-0.11.0-py3-none-any.whl
- Upload date:
- Size: 127.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/3.4.2 importlib_metadata/4.5.0 pkginfo/1.7.1 requests/2.26.0 requests-toolbelt/0.9.1 tqdm/4.61.2 CPython/3.8.6
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | 5cfabe6a1403089d28dcd138a2e9bbe3bee4bd21fa8d0b79ba114b59a47b3c50 |
|
MD5 | e1241070dd159e9990db5a5d38632f7e |
|
BLAKE2b-256 | 9c855f959f8bcfe0f66adfbb5e02d9d3e5ef2ce61d04781f0c436715e49d028e |