├── tests ├── __init__.py ├── wild │ ├── __init__.py │ ├── subtpl.liq │ ├── test_include.py │ ├── test_filters.py │ └── test_wild_tags.py ├── standard │ ├── __init__.py │ ├── test_basics.py │ ├── test_filters.py │ └── test_tags.py ├── jekyll │ ├── templates │ │ ├── sub.tpl │ │ └── parent.tpl │ ├── test_jekyll_tags.py │ ├── test_jekyll_front_matter.py │ └── test_jekyll_filters.py ├── shopify │ └── test_shopify_filters.py ├── conftest.py └── test_liquid.py ├── liquid ├── exts │ ├── __init__.py │ ├── wild.py │ ├── jekyll.py │ ├── shopify.py │ ├── front_matter.py │ ├── filter_colon.py │ ├── ext.py │ └── standard.py ├── tags │ ├── __init__.py │ ├── shopify.py │ ├── jekyll.py │ ├── manager.py │ ├── wild.py │ └── standard.py ├── filters │ ├── __init__.py │ ├── shopify.py │ ├── manager.py │ ├── wild.py │ ├── jekyll.py │ └── standard.py ├── __init__.py ├── defaults.py ├── utils.py ├── patching.py └── liquid.py ├── docs ├── favicon.png ├── requirements.txt ├── playground │ ├── pyscript.toml │ ├── liquid.py │ └── index.html ├── shopify.md ├── jekyll.md ├── standard.md ├── style.css ├── basics.md ├── wild.md └── CHANGELOG.md ├── tox.ini ├── .coveragerc ├── setup.py ├── mkdocs.yml ├── .pre-commit-config.yaml ├── .github ├── actions │ └── liquidpy_test │ │ └── action.yml └── workflows │ ├── docs.yml │ └── build.yml ├── pyproject.toml ├── .gitignore ├── README.md └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /liquid/exts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /liquid/tags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/wild/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /liquid/filters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/standard/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/wild/subtpl.liq: -------------------------------------------------------------------------------- 1 | |{{ variable }}| -------------------------------------------------------------------------------- /tests/jekyll/templates/sub.tpl: -------------------------------------------------------------------------------- 1 | {{ "sub" }} -------------------------------------------------------------------------------- /tests/jekyll/templates/parent.tpl: -------------------------------------------------------------------------------- 1 | {% include_relative "sub.tpl" %} -------------------------------------------------------------------------------- /docs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwwang/liquidpy/HEAD/docs/favicon.png -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # use_directory_urls doesn't work for newer versions 2 | mkdocs 3 | mkdocs-material 4 | pymdown-extensions 5 | mkapi-fix 6 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, W503, E731 3 | per-file-ignores = 4 | # imported but unused 5 | __init__.py: F401 6 | max-line-length = 89 7 | -------------------------------------------------------------------------------- /docs/playground/pyscript.toml: -------------------------------------------------------------------------------- 1 | name = "liquidpy" 2 | description = "A simple example of a Python script that uses the Liquid template engine." 3 | packages = ["liquidpy"] 4 | -------------------------------------------------------------------------------- /tests/shopify/test_shopify_filters.py: -------------------------------------------------------------------------------- 1 | import pytest # noqa: F401 2 | from liquid import Liquid 3 | 4 | 5 | def test_filter(set_default_shopify): 6 | assert Liquid('').render() == '' 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | 2 | [run] 3 | omit = liquid/patching.py 4 | 5 | [report] 6 | exclude_lines = 7 | if TYPE_CHECKING: 8 | pragma: no cover 9 | pass 10 | except ImportError: 11 | -------------------------------------------------------------------------------- /liquid/__init__.py: -------------------------------------------------------------------------------- 1 | """A port of liquid template engine for python on the shoulders of jinja2""" 2 | from .liquid import Liquid 3 | from .patching import patch_jinja, unpatch_jinja 4 | 5 | patch_jinja() 6 | 7 | __version__ = "0.8.6" 8 | -------------------------------------------------------------------------------- /docs/shopify.md: -------------------------------------------------------------------------------- 1 | 2 | You may checkout the documentation for shopfiy liquid: 3 | 4 | - https://shopify.dev/api/liquid 5 | 6 | The compatibility issues list on: 7 | 8 | - https://pwwang.github.com/liquidpy/standard 9 | 10 | also applied in shopify mode. 11 | -------------------------------------------------------------------------------- /liquid/exts/wild.py: -------------------------------------------------------------------------------- 1 | """Provides extension for wild mode""" 2 | 3 | from ..tags.wild import wild_tags 4 | 5 | from .ext import LiquidExtension 6 | 7 | 8 | class LiquidWildExtension(LiquidExtension): 9 | """Extension for wild mode""" 10 | tag_manager = wild_tags 11 | -------------------------------------------------------------------------------- /liquid/exts/jekyll.py: -------------------------------------------------------------------------------- 1 | """Extension for jekyll mode""" 2 | from ..tags.jekyll import jekyll_tags 3 | from .standard import LiquidStandardExtension 4 | 5 | 6 | class LiquidJekyllExtension(LiquidStandardExtension): 7 | """Extension for jekyll mode""" 8 | tag_manager = jekyll_tags 9 | -------------------------------------------------------------------------------- /liquid/exts/shopify.py: -------------------------------------------------------------------------------- 1 | """Extension for shopify mode""" 2 | from ..tags.shopify import shopify_tags 3 | from .standard import LiquidStandardExtension 4 | 5 | 6 | class LiquidShopifyExtension(LiquidStandardExtension): 7 | """Extension for jekyll mode""" 8 | tag_manager = shopify_tags 9 | -------------------------------------------------------------------------------- /docs/jekyll.md: -------------------------------------------------------------------------------- 1 | 2 | You may checkout the documentation for jekyll liquid: 3 | 4 | - https://jekyllrb.com/docs/liquid/ 5 | 6 | The compatibility issues list on: 7 | 8 | - https://pwwang.github.com/liquidpy/standard 9 | 10 | also applied in jekyll mode. Besides, passing variables to a sub-template using `include` tag is not supported. Instead, please using jinja's `with` tag: 11 | 12 | - https://stackoverflow.com/a/9405157/5088165 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | This will not be included in the distribution. 4 | The distribution is managed by poetry 5 | This file is kept only for 6 | 1. Github to index the dependents 7 | 2. pip install -e . 8 | 9 | Do NOT use this to install this package, unless you handled the dependencies 10 | by your self: 11 | 12 | pip install git+https://... 13 | """ 14 | from setuptools import setup 15 | 16 | setup(name="liquidpy") 17 | -------------------------------------------------------------------------------- /tests/jekyll/test_jekyll_tags.py: -------------------------------------------------------------------------------- 1 | import pytest # noqa: F401 2 | from pathlib import Path 3 | from liquid import Liquid 4 | 5 | TPLDIR = Path(__file__).parent / "templates" 6 | 7 | 8 | def test_include_relative(set_default_jekyll): 9 | liq = Liquid(TPLDIR / "parent.tpl", from_file=True) 10 | assert liq.render().strip() == "sub" 11 | 12 | liq = Liquid( 13 | f'{{% include_relative "{TPLDIR}/parent.tpl" %}}', 14 | ) 15 | assert liq.render().strip() == "sub" 16 | -------------------------------------------------------------------------------- /tests/jekyll/test_jekyll_front_matter.py: -------------------------------------------------------------------------------- 1 | import pytest # noqa: F401 2 | from liquid import Liquid 3 | 4 | 5 | def test_front_matter_toml(set_default_jekyll): 6 | tpl = """+++ 7 | a = 1 8 | +++ 9 | 10 | {{page.a}} 11 | """ 12 | assert Liquid(tpl, front_matter_lang="toml").render().strip() == "1" 13 | 14 | 15 | def test_front_matter_json(set_default_jekyll): 16 | tpl = """{ 17 | "a": 1 18 | } 19 | 20 | {{page.a}} 21 | """ 22 | assert Liquid(tpl, front_matter_lang="json").render().strip() == "1" 23 | -------------------------------------------------------------------------------- /liquid/tags/shopify.py: -------------------------------------------------------------------------------- 1 | """Provide shopify tags 2 | see: https://shopify.dev/api/liquid/tags 3 | """ 4 | 5 | from .manager import TagManager 6 | from .standard import ( 7 | comment, 8 | capture, 9 | assign, 10 | unless, 11 | case, 12 | tablerow, 13 | increment, 14 | decrement, 15 | cycle, 16 | ) 17 | 18 | 19 | shopify_tags = TagManager() 20 | 21 | shopify_tags.register(comment, raw=True) 22 | shopify_tags.register(capture) 23 | shopify_tags.register(assign) 24 | shopify_tags.register(unless) 25 | shopify_tags.register(case) 26 | shopify_tags.register(tablerow) 27 | shopify_tags.register(increment) 28 | shopify_tags.register(decrement) 29 | shopify_tags.register(cycle) 30 | 31 | # https://shopify.dev/api/liquid/tags/theme-tags 32 | # TODO: echo, form, layout, liquid, paginate, render, section, style 33 | -------------------------------------------------------------------------------- /tests/wild/test_include.py: -------------------------------------------------------------------------------- 1 | from liquid import Liquid 2 | from pathlib import Path 3 | 4 | 5 | def test_include(set_default_wild): 6 | subtpl = Path(__file__).parent.joinpath("subtpl.liq") 7 | 8 | # default 9 | tpl = f""" 10 | {{% assign variable = 8525 %}} 11 | {{% include "{subtpl}" %}} 12 | """ 13 | out = Liquid(tpl).render().strip() 14 | assert out == "|8525|" 15 | 16 | # with context 17 | tpl = f""" 18 | {{% assign variable = 8525 %}} 19 | {{% include "{subtpl}" with context %}} 20 | """ 21 | out = Liquid(tpl).render().strip() 22 | assert out == "|8525|" 23 | 24 | # without context 25 | tpl = f""" 26 | {{% assign variable = 8525 %}} 27 | {{% include "{subtpl}" without context %}} 28 | """ 29 | out = Liquid(tpl).render().strip() 30 | assert out == "||" 31 | -------------------------------------------------------------------------------- /liquid/filters/shopify.py: -------------------------------------------------------------------------------- 1 | """Provides shopify filters""" 2 | 3 | from .manager import FilterManager 4 | 5 | 6 | shopify_filter_manager = FilterManager() 7 | 8 | # TODO: color filters 9 | # https://shopify.dev/api/liquid/filters/color-filters 10 | 11 | # TODO: font filters 12 | # https://shopify.dev/api/liquid/filters/font-filters 13 | 14 | # TODO: html filters 15 | # https://shopify.dev/api/liquid/filters/html-filters 16 | 17 | # TODO: media filters 18 | # https://shopify.dev/api/liquid/filters/media-filters 19 | 20 | # TODO: metafield filters 21 | # https://shopify.dev/api/liquid/filters/metafield-filters 22 | 23 | # TODO: money filters 24 | # https://shopify.dev/api/liquid/filters/money-filters 25 | 26 | # TODO: string filters 27 | # https://shopify.dev/api/liquid/filters/string-filters 28 | 29 | # TODO: url filters 30 | # https://shopify.dev/api/liquid/filters/url-filters 31 | 32 | # TODO: additional filters 33 | # https://shopify.dev/api/liquid/filters/additional-filters 34 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: liquidpy 2 | repo_url: https://github.com/pwwang/liquidpy 3 | repo_name: pwwang/liquidpy 4 | theme: 5 | favicon: favicon.png 6 | logo: favicon.png 7 | icon: 8 | repo: fontawesome/brands/github 9 | name: 'material' 10 | font: 11 | text: 'Ubuntu' 12 | code: 'Ubuntu Mono' 13 | features: 14 | - navigation.top 15 | markdown_extensions: 16 | - markdown.extensions.admonition 17 | - pymdownx.magiclink 18 | - pymdownx.superfences: 19 | preserve_tabs: true 20 | - toc: 21 | baselevel: 2 22 | plugins: 23 | - search # necessary for search to work 24 | - mkapi 25 | extra_css: 26 | - style.css 27 | nav: 28 | - 'Home': 'index.md' 29 | - 'Basics': 'basics.md' 30 | - 'Compatibility with standard liquid': 'standard.md' 31 | - 'Compatibility with jekyll liquid': 'jekyll.md' 32 | - 'Compatibility with shopify-extended liquid': 'shopify.md' 33 | - 'Wild mode': 'wild.md' 34 | - 'Change log': 'changelog.md' 35 | - 'API': 'mkapi/api/liquid' 36 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from liquid import defaults 3 | 4 | 5 | @pytest.fixture 6 | def set_default_standard(): 7 | orig_mode = defaults.MODE 8 | orig_from_file = defaults.FROM_FILE 9 | defaults.MODE = "standard" 10 | defaults.FROM_FILE = False 11 | yield 12 | defaults.MODE = orig_mode 13 | defaults.FROM_FILE = orig_from_file 14 | 15 | 16 | @pytest.fixture 17 | def set_default_wild(): 18 | orig_mode = defaults.MODE 19 | orig_from_file = defaults.FROM_FILE 20 | defaults.MODE = "wild" 21 | defaults.FROM_FILE = False 22 | yield 23 | defaults.MODE = orig_mode 24 | defaults.FROM_FILE = orig_from_file 25 | 26 | 27 | @pytest.fixture 28 | def set_default_jekyll(): 29 | orig_mode = defaults.MODE 30 | orig_from_file = defaults.FROM_FILE 31 | defaults.MODE = "jekyll" 32 | defaults.FROM_FILE = False 33 | yield 34 | defaults.MODE = orig_mode 35 | defaults.FROM_FILE = orig_from_file 36 | 37 | 38 | @pytest.fixture 39 | def set_default_shopify(): 40 | orig_mode = defaults.MODE 41 | orig_from_file = defaults.FROM_FILE 42 | defaults.MODE = "shopify" 43 | defaults.FROM_FILE = False 44 | yield 45 | defaults.MODE = orig_mode 46 | defaults.FROM_FILE = orig_from_file 47 | -------------------------------------------------------------------------------- /liquid/exts/front_matter.py: -------------------------------------------------------------------------------- 1 | """Provides an extension to allow front matter in the template""" 2 | from typing import TYPE_CHECKING 3 | from jinja2.ext import Extension 4 | 5 | from ..defaults import FRONT_MATTER_LANG 6 | 7 | if TYPE_CHECKING: 8 | from jinja2 import Environment 9 | 10 | 11 | class FrontMatterExtension(Extension): 12 | """This extension allows to have front matter""" 13 | 14 | def __init__(self, environment: "Environment") -> None: 15 | super().__init__(environment) 16 | environment.extend(front_matter_lang=FRONT_MATTER_LANG) 17 | 18 | def preprocess(self, source: str, name: str, filename: str = None) -> str: 19 | """Preprocess sourcee to extract front matter""" 20 | import frontmatter 21 | 22 | if self.environment.front_matter_lang.lower() == "toml": 23 | from frontmatter.default_handlers import TOMLHandler as handler 24 | elif self.environment.front_matter_lang.lower() == "json": 25 | from frontmatter.default_handlers import JSONHandler as handler 26 | else: 27 | from frontmatter.default_handlers import YAMLHandler as handler 28 | 29 | processed = frontmatter.loads(source, handler=handler()) 30 | self.environment.globals["page"] = processed 31 | return processed.content 32 | -------------------------------------------------------------------------------- /tests/wild/test_filters.py: -------------------------------------------------------------------------------- 1 | from liquid import Liquid 2 | import pytest # noqa 3 | 4 | 5 | def test_ifelse(set_default_wild): 6 | tpl = """{{ a | ifelse: isinstance, (int, ), 7 | "plus", (1, ), 8 | "append", (".html", ) }}""" 9 | 10 | out = Liquid(tpl).render(a=1) 11 | assert out == "2" 12 | 13 | out = Liquid(tpl).render(a="a") 14 | assert out == "a.html" 15 | 16 | 17 | def test_call(set_default_wild): 18 | tpl = """{{ int | call: "1" | plus: 1 }}""" 19 | 20 | out = Liquid(tpl).render() 21 | assert out == "2" 22 | 23 | 24 | def test_map(set_default_wild): 25 | tpl = """{{ floor | map: x | list }}""" 26 | 27 | out = Liquid(tpl).render(x=[1.1, 2.2, 3.3]) 28 | assert out == "[1, 2, 3]" 29 | out = Liquid(tpl).render(x=[]) 30 | assert out == "[]" 31 | 32 | # liquid_map 33 | tpl = """{{ x | liquid_map: 'y' }}""" 34 | 35 | out = Liquid(tpl).render(x=[{"y": 1}, {"y": 2}, {"y": 3}]) 36 | assert out == "[1, 2, 3]" 37 | out = Liquid(tpl).render(x=[]) 38 | assert out == "[]" 39 | 40 | 41 | def test_each(set_default_wild): 42 | tpl = """{{ x | each: plus, 1 }}""" 43 | 44 | out = Liquid(tpl).render(x=[1, 2, 3]) 45 | assert out == "[2, 3, 4]" 46 | out = Liquid(tpl).render(x=[]) 47 | assert out == "[]" 48 | -------------------------------------------------------------------------------- /tests/test_liquid.py: -------------------------------------------------------------------------------- 1 | from jinja2.environment import Environment 2 | from jinja2.loaders import FileSystemLoader 3 | from liquid.liquid import Liquid 4 | import pytest # noqa: F401 5 | 6 | 7 | def test_env_args(set_default_standard): 8 | loader = FileSystemLoader("~") 9 | tpl = Liquid( 10 | "{$ a $}", 11 | variable_start_string="{$", 12 | variable_end_string="$}", 13 | x=1, 14 | loader=loader, 15 | ) 16 | assert tpl.render(a=1) == "1" 17 | assert tpl.env.x == 1 18 | 19 | 20 | def test_from_env(set_default_standard): 21 | loader = FileSystemLoader("~") 22 | env = Environment( 23 | loader=loader, 24 | variable_start_string="{$", 25 | variable_end_string="$}", 26 | ) 27 | tpl = Liquid.from_env("{$ a $}", env) 28 | assert tpl.render(a=1) == "1" 29 | 30 | tpl = Liquid( 31 | "{@ a @}", 32 | env=env, 33 | # override the env's settings 34 | variable_start_string="{@", 35 | variable_end_string="@}", 36 | ) 37 | assert tpl.render(a=1) == "1" 38 | 39 | 40 | def test_async_render(set_default_standard): 41 | import asyncio 42 | tpl = Liquid('{{ a }}', enable_async=True) 43 | 44 | try: 45 | run = asyncio.run 46 | except AttributeError: 47 | loop = asyncio.get_event_loop() 48 | run = loop.run_until_complete 49 | 50 | assert run(tpl.render_async(a=1)) == "1" 51 | -------------------------------------------------------------------------------- /tests/wild/test_wild_tags.py: -------------------------------------------------------------------------------- 1 | 2 | import pytest 3 | 4 | from liquid import Liquid 5 | from jinja2.exceptions import TemplateSyntaxError 6 | 7 | 8 | def test_python_inline(set_default_wild): 9 | tpl = """ 10 | {% python a = 1 %}{{a}} 11 | """ 12 | assert Liquid(tpl).render().strip() == "1" 13 | 14 | 15 | def test_python_block(set_default_wild): 16 | tpl = """ 17 | {% python %} 18 | a = 1 19 | {% endpython %} 20 | {{a}} 21 | """ 22 | assert Liquid(tpl).render().strip() == "1" 23 | 24 | 25 | def test_import_block(set_default_wild): 26 | tpl = """ 27 | {% import_ os %} 28 | {{os.path.join("a", "b")}} 29 | """ 30 | assert Liquid(tpl).render().strip() == "a/b" 31 | 32 | 33 | def test_from_block(set_default_wild): 34 | tpl = """ 35 | {% from_ os import path %} 36 | {{path.join("a", "b")}} 37 | """ 38 | assert Liquid(tpl).render().strip() == "a/b" 39 | 40 | 41 | def test_addfilter(set_default_wild): 42 | tpl = """ 43 | {% addfilter path_join %} 44 | import os 45 | path_join = os.path.join 46 | {% endaddfilter %} 47 | {{"a" | path_join: "b"}} 48 | """ 49 | assert Liquid(tpl).render().strip() == "a/b" 50 | 51 | 52 | def test_addfilter_err(set_default_wild): 53 | tpl = """ 54 | {% addfilter path_join %} 55 | x = 1 56 | {% endaddfilter %} 57 | """ 58 | with pytest.raises(TemplateSyntaxError, match="No such filter defined"): 59 | Liquid(tpl) 60 | -------------------------------------------------------------------------------- /liquid/tags/jekyll.py: -------------------------------------------------------------------------------- 1 | """Provides jekyll tags""" 2 | import os 3 | 4 | from jinja2 import nodes 5 | from jinja2.lexer import Token 6 | from jinja2.parser import Parser 7 | 8 | from .manager import TagManager 9 | from .standard import ( 10 | assign, 11 | capture, 12 | case, 13 | comment, 14 | cycle, 15 | decrement, 16 | increment, 17 | tablerow, 18 | unless, 19 | ) 20 | 21 | 22 | jekyll_tags = TagManager() 23 | 24 | jekyll_tags.register(comment, raw=True) 25 | jekyll_tags.register(capture) 26 | jekyll_tags.register(assign) 27 | jekyll_tags.register(unless) 28 | jekyll_tags.register(case) 29 | jekyll_tags.register(tablerow) 30 | jekyll_tags.register(increment) 31 | jekyll_tags.register(decrement) 32 | jekyll_tags.register(cycle) 33 | 34 | 35 | # to specify certain named arguments 36 | # use jinja's with 37 | # https://stackoverflow.com/a/9405157/5088165 38 | @jekyll_tags.register 39 | def include_relative(token: Token, parser: Parser) -> nodes.Node: 40 | """The {% include_relative ... %} tag""" 41 | node = nodes.Include(lineno=token.lineno) 42 | path = parser.parse_expression() 43 | if parser.stream.filename: 44 | node.template = nodes.Add( 45 | nodes.Add( 46 | nodes.Const(os.path.dirname(parser.stream.filename)), 47 | nodes.Const(os.path.sep), 48 | ), 49 | path, 50 | ) 51 | else: 52 | node.template = path 53 | 54 | node.ignore_missing = False 55 | return parser.parse_import_context(node, True) 56 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | fail_fast: false 4 | exclude: '^README.rst$|^tests/|^setup.py$|^examples/|^docs/api\.md' 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: 5df1a4bf6f04a1ed3a643167b38d502575e29aef 8 | hooks: 9 | - id: trailing-whitespace 10 | - id: end-of-file-fixer 11 | - id: check-yaml 12 | - id: check-added-large-files 13 | - repo: local 14 | hooks: 15 | - id: versionchecker 16 | name: Check version agreement in pyproject and __version__ 17 | entry: bash -c 18 | language: system 19 | args: 20 | - get_ver() { echo $(egrep "^__version|^version" $1 | cut -d= -f2 | sed 's/\"\| //g'); }; 21 | v1=`get_ver pyproject.toml`; 22 | v2=`get_ver liquid/__init__.py`; 23 | if [[ $v1 == $v2 ]]; then exit 0; else exit 1; fi 24 | pass_filenames: false 25 | files: ^pyproject\.toml|liquid/__init__\.py$ 26 | - id: flake8 27 | name: Run flake8 28 | entry: flake8 29 | language: system 30 | args: [liquid] 31 | pass_filenames: false 32 | files: ^liquid/.+$ 33 | - id: pytest 34 | name: Run pytest 35 | entry: pytest 36 | language: system 37 | args: [tests/] 38 | pass_filenames: false 39 | files: ^tests/.+$|^liquid/.+$ 40 | - id: mypy 41 | name: Run mypy type check 42 | entry: mypy 43 | language: system 44 | args: ["-p", "liquid"] 45 | pass_filenames: false 46 | files: ^liquid/.+$ 47 | -------------------------------------------------------------------------------- /docs/standard.md: -------------------------------------------------------------------------------- 1 | 2 | You may checkout the documentation for standard liquid: 3 | - https://shopify.github.io/liquid/ 4 | 5 | `liquidpy` tries to maintain the maximum compatibility with `liquid`. But we do have some differences: 6 | 7 | ## Filter `round()` 8 | 9 | It always returns a `float` rather than an `integer` when `ndigits=0` 10 | 11 | ## Logical operators 12 | 13 | The logical operators `and`/`or` collapse from left to right (it's right to left in `liquid`) 14 | 15 | See: https://shopify.github.io/liquid/basics/operators/#order-of-operations 16 | 17 | 18 | ## Truthy and falsy 19 | 20 | Instead of always truthy for empty string, 0, empty array, they are falsy in `liquidpy` 21 | 22 | 23 | ## Iteration 24 | 25 | Literal ranges (`(1..5)`) are suported by `liquidpy`. However, the start and the stop must be integers or names, meaning this is not supported `(1..array.size)`. You can do this instead: 26 | 27 | ```liquid 28 | {% assign asize = array.size %} 29 | {% for i in (1..asize) %} 30 | ... 31 | {% endfor %} 32 | ``` 33 | 34 | ## Typecasting 35 | 36 | You are able to do the following in ruby liquid: 37 | ```liquid 38 | {{ "1" | plus: 1}} # 2 39 | ``` 40 | However, this is not valid in liquidpy. Because the template is eventually compiled into python code and the type handling is delegated to python, but "1" + 1 is not a valid python operation. 41 | 42 | So you have to do typecasting yourself: 43 | ```liquid 44 | {{ "1" | int | plus: 1 }} # 2 45 | ``` 46 | 47 | In order to make it work, extra filters `int`, `float`, `str` and `bool` are added as builtin filters. They are also added as globals in order to get this work: 48 | ```liquid 49 | {% capture lst_size %}4{% endcapture %} 50 | {{ 2 | at_most: int(lst_size) }} # 2 51 | ``` 52 | 53 | See also: https://github.com/pwwang/liquidpy/issues/40 54 | -------------------------------------------------------------------------------- /liquid/defaults.py: -------------------------------------------------------------------------------- 1 | """Provide default settings/values""" 2 | from typing import TYPE_CHECKING 3 | 4 | 5 | if TYPE_CHECKING: 6 | from .utils import PathTypeOrIter 7 | 8 | # The default mode to initialize a Liquid object 9 | # - standard: Compatible with standard liquid engine 10 | # - wild: liquid- and jinja-compatible engine 11 | # - jekyll: jekyll-compatible engine 12 | MODE: str = "standard" 13 | 14 | # Whether the template provided is a file path by default 15 | FROM_FILE: bool = True 16 | 17 | # Whether allow arguments of a filter to be separated 18 | # by colon (:) with the filter 19 | # e.g. {{ val | filter: arg1, arg2 }} 20 | # jinja only supports: 21 | # {{ val | filter(arg1, arg2)}} 22 | FILTER_WITH_COLON = True 23 | 24 | # The default search paths for templates 25 | # support absolute paths 26 | SEARCH_PATHS: "PathTypeOrIter" = ["/", "./"] 27 | 28 | # The default format/language for the front matter 29 | # Should be one of yaml, toml or json 30 | FRONT_MATTER_LANG = "yaml" 31 | 32 | # Available jinja Environment arguments 33 | ENV_ARGS = [ 34 | "block_start_string", 35 | "block_end_string", 36 | "variable_start_string", 37 | "variable_end_string", 38 | "comment_start_string", 39 | "comment_end_string", 40 | "line_statement_prefix", 41 | "line_comment_prefix", 42 | "trim_blocks", 43 | "lstrip_blocks", 44 | "newline_sequence", 45 | "keep_trailing_newline", 46 | "extensions", 47 | "optimized", 48 | "undefined", 49 | "finalize", 50 | "autoescape", 51 | "loader", 52 | "cache_size", 53 | "auto_reload", 54 | "bytecode_cache", 55 | "enable_async", 56 | ] 57 | 58 | # In case some one wants to use nil 59 | SHARED_GLOBALS = {"nil": None} 60 | 61 | # Whether treat filters as globals 62 | # Only works in wild mode 63 | FILTERS_AS_GLOBALS = True 64 | -------------------------------------------------------------------------------- /.github/actions/liquidpy_test/action.yml: -------------------------------------------------------------------------------- 1 | name: Liquidpy Test Run 2 | description: 'Run Liquidpy tests with specified Python and Jinja2 versions' 3 | 4 | inputs: 5 | python_version: 6 | description: 'Python version' 7 | jinja2_version: 8 | description: 'Jinja2 version' 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - name: Set up Python (${{ inputs.python_version }}) 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: ${{ inputs.python_version }} 17 | 18 | - name: Install dependencies 19 | shell: bash 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install --upgrade poetry 23 | poetry config virtualenvs.create false 24 | poetry install -v 25 | poetry install -E extra 26 | # reinstall pandas to specific version 27 | pip install ${{ inputs.jinja2_version }} 28 | 29 | - name: Replace jinja2 version asterisk 30 | uses: bluwy/substitute-string-action@v3 31 | id: replace_jinja2_version 32 | with: 33 | _input-text: ${{ inputs.jinja2_version }} 34 | "*": "x" 35 | 36 | - name: Test with pytest 37 | shell: bash 38 | run: pytest tests/ --junitxml=junit/test-results-${{ inputs.python_version }}-${{ steps.replace_jinja2_version.outputs.result }}.xml 39 | 40 | - name: Upload pytest test results 41 | id: upload_pytest_results 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: pytest-results-${{ inputs.python_version }}-${{ steps.replace_jinja2_version.outputs.result }} 45 | path: junit/test-results-${{ inputs.python_version }}-${{ steps.replace_jinja2_version.outputs.result }}.xml 46 | # Use always() to always run this step to publish test results when there are test failures 47 | if: ${{ always() }} 48 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ "poetry>=1.0.0",] 3 | build-backend = "poetry.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "liquidpy" 7 | version = "0.8.6" 8 | description = "A port of liquid template engine for python" 9 | authors = [ "pwwang ",] 10 | license = "MIT" 11 | readme = "README.md" 12 | homepage = "https://github.com/pwwang/liquidpy" 13 | repository = "https://github.com/pwwang/liquidpy" 14 | 15 | [tool.poetry.build] 16 | generate-setup-file = true 17 | 18 | [[tool.poetry.packages]] 19 | include = "liquid" 20 | 21 | [tool.poetry.dependencies] 22 | python = "^3.7" 23 | jinja2 = "^3" 24 | python-frontmatter = {version = "^1.0", optional = true} 25 | markdown = [ 26 | {version = "^3.4", optional = true, python = "<3.8"}, 27 | {version = "^3.5", optional = true, python = ">=3.8"}, 28 | ] 29 | regex = [ 30 | {version = "^2023.12", optional = true, python = "<3.8"}, 31 | {version = "^2024.11", optional = true, python = ">=3.8"}, 32 | ] 33 | python-slugify = {version = "^8", optional = true} 34 | python-dateutil = {version = "^2.8", optional = true} 35 | # needed by python-frontmatter 36 | toml = {version = "^0.10", optional = true} 37 | 38 | [tool.poetry.group.dev.dependencies] 39 | pytest = "^7" 40 | pytest-cov = "^4" 41 | six = "^1.17" 42 | 43 | [tool.poetry.extras] 44 | extra = ["python-frontmatter", "markdown", "regex", "python-slugify", "python-dateutil", "toml"] 45 | 46 | [tool.black] 47 | line-length = 88 48 | target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312', 'py313'] 49 | include = '\.pyi?$' 50 | 51 | [tool.pytest.ini_options] 52 | addopts = "-vv -p no:asyncio --cov-config=.coveragerc --cov=liquid --cov-report xml:cov.xml --cov-report term-missing" 53 | console_output_style = "progress" 54 | junit_family = "xunit1" 55 | 56 | [tool.mypy] 57 | ignore_missing_imports = true 58 | allow_redefinition = true 59 | disable_error_code = ["attr-defined", "no-redef", "union-attr"] 60 | show_error_codes = true 61 | strict_optional = false 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | cov.xml 47 | .coverage.xml 48 | *,cover 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Environments 88 | .env 89 | .venv 90 | env/ 91 | venv/ 92 | ENV/ 93 | env.bak/ 94 | venv.bak/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | /docs/index.md 106 | 107 | # mypy 108 | .mypy_cache/ 109 | 110 | # poetry.lock 111 | 112 | # tagit backup files 113 | *.bak 114 | 115 | .vscode 116 | docs/api/ 117 | docs/index.md 118 | 119 | .history/ 120 | _t.py 121 | -------------------------------------------------------------------------------- /docs/playground/liquid.py: -------------------------------------------------------------------------------- 1 | from pyweb import pydom 2 | from liquid import Liquid 3 | 4 | EXAMPLE_TEMPLATE = "{{ a | upper }}" 5 | EXAMPLE_VARIABLES = "a = 'hello world!'" 6 | EXAMPLE_FILTERS = """\ 7 | def upper(value): 8 | return value.upper() 9 | """ 10 | 11 | TEMPLATE_CONTAINER = pydom["#template"][0] 12 | VARIABLES_CONTAINER = pydom["#variables"][0] 13 | FILTERS_CONTAINER = pydom["#filters"][0] 14 | MODE_CONTAINER = pydom["#mode"][0] 15 | RENDERED_CONTAINER = pydom["#rendered"][0] 16 | 17 | 18 | def _remove_class(element, class_name): 19 | try: 20 | element.classes.remove(class_name) 21 | except ValueError: 22 | pass 23 | 24 | 25 | def _add_class(element, class_name): 26 | element.classes.append(class_name) 27 | 28 | 29 | def _error(message): 30 | """ 31 | Displays an error message. 32 | """ 33 | _add_class(RENDERED_CONTAINER, "bg-red-100") 34 | RENDERED_CONTAINER.value = message 35 | 36 | 37 | def load_example(*args, **kwargs): 38 | """ 39 | Loads the example template, variables and filters. 40 | """ 41 | TEMPLATE_CONTAINER.value = EXAMPLE_TEMPLATE 42 | VARIABLES_CONTAINER.value = EXAMPLE_VARIABLES 43 | FILTERS_CONTAINER.value = EXAMPLE_FILTERS 44 | 45 | 46 | def render(*args, **kwargs): 47 | """ 48 | Renders the template with the variables and filters. 49 | """ 50 | template = TEMPLATE_CONTAINER.value 51 | variables = {} 52 | try: 53 | exec(VARIABLES_CONTAINER.value, variables) 54 | except Exception as e: 55 | _error(f"Something wrong when evaluating variables: \n{e}") 56 | return 57 | 58 | filters = {} 59 | try: 60 | exec(FILTERS_CONTAINER.value, filters) 61 | except Exception as e: 62 | _error(f"Something wrong when evaluating filters: \n{e}") 63 | return 64 | 65 | mode = MODE_CONTAINER.value 66 | _remove_class(RENDERED_CONTAINER, "bg-red-100") 67 | try: 68 | liq = Liquid(template, from_file=False, mode=mode, filters=filters) 69 | RENDERED_CONTAINER.value = liq.render(**variables) 70 | except Exception as e: 71 | _error(f"Something wrong when rendering: \n{e}") 72 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: [push] 4 | 5 | jobs: 6 | docs: 7 | runs-on: ubuntu-latest 8 | # if: github.ref == 'refs/heads/master' 9 | strategy: 10 | matrix: 11 | python-version: [3.9] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Setup Python # Set Python version 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | python -m pip install poetry 22 | poetry config virtualenvs.create false 23 | poetry install -v 24 | - name: Build docs 25 | run: | 26 | python -m pip install -r docs/requirements.txt 27 | cd docs 28 | cp ../README.md index.md 29 | cd .. 30 | mkdocs build 31 | if : success() 32 | - name: Deploy docs 33 | run: | 34 | mkdocs gh-deploy --clean --force 35 | if: success() && (github.ref == 'refs/heads/master' || contains(github.event.head_commit.message, '[docs]')) 36 | 37 | fix-index: 38 | needs: docs 39 | runs-on: ubuntu-latest 40 | if: github.ref == 'refs/heads/master' 41 | strategy: 42 | matrix: 43 | python-version: [3.9] 44 | steps: 45 | - uses: actions/checkout@v4 46 | with: 47 | ref: gh-pages 48 | - name: Fix index.html 49 | run: | 50 | echo ':: head of index.html - before ::' 51 | head index.html 52 | sed -i '1,5{/^$/d}' index.html 53 | echo ':: head of index.html - after ::' 54 | head index.html 55 | if: success() 56 | - name: Commit changes 57 | run: | 58 | git config --local user.email "action@github.com" 59 | git config --local user.name "GitHub Action" 60 | git commit -m "Add changes" -a 61 | if: success() 62 | - name: Push changes 63 | uses: ad-m/github-push-action@master 64 | with: 65 | github_token: ${{ secrets.GITHUB_TOKEN }} 66 | branch: gh-pages 67 | if: success() 68 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: building 2 | 3 | on: 4 | push: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | 10 | Test_Python_37: 11 | runs-on: ubuntu-22.04 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: [3.7] 16 | jinja2: [ 17 | jinja2==3.0.*, 18 | jinja2 19 | ] 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Run Test 26 | uses: ./.github/actions/liquidpy_test 27 | with: 28 | python_version: ${{ matrix.python-version }} 29 | jinja2_version: ${{ matrix.jinja2 }} 30 | 31 | Test_Python_38_plus: 32 | runs-on: ubuntu-24.04 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] 37 | jinja2: [ 38 | jinja2==3.0.*, 39 | jinja2 40 | ] 41 | 42 | steps: 43 | - name: Checkout code 44 | uses: actions/checkout@v4 45 | 46 | - name: Run Test 47 | uses: ./.github/actions/liquidpy_test 48 | with: 49 | python_version: ${{ matrix.python-version }} 50 | jinja2_version: ${{ matrix.jinja2 }} 51 | 52 | - name: Run codacy-coverage-reporter 53 | uses: codacy/codacy-coverage-reporter-action@master 54 | if: matrix.python-version == '3.12' && matrix.jinja2 == 'jinja2' 55 | with: 56 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 57 | coverage-reports: cov.xml 58 | 59 | deploy: 60 | needs: Test_Python_38_plus 61 | runs-on: ubuntu-24.04 62 | if: github.event_name == 'release' 63 | strategy: 64 | matrix: 65 | python-version: [3.12] 66 | steps: 67 | - uses: actions/checkout@v4 68 | - name: Setup Python # Set Python version 69 | uses: actions/setup-python@v5 70 | - name: Install dependencies 71 | run: | 72 | python -m pip install --upgrade pip 73 | python -m pip install poetry 74 | - name: Publish to PyPI 75 | run: poetry publish --build -u ${{ secrets.PYPI_USER }} -p ${{ secrets.PYPI_PASSWORD }} 76 | if: success() 77 | -------------------------------------------------------------------------------- /liquid/exts/filter_colon.py: -------------------------------------------------------------------------------- 1 | """Provides an extension to use colon to separate filter and its arguments 2 | 3 | Jinja uses `{{a | filter(arg)}}`, but liquid uses `{{a | filter: arg}}` 4 | """ 5 | from typing import TYPE_CHECKING, Iterable 6 | from jinja2.ext import Extension 7 | from jinja2.lexer import ( 8 | TOKEN_ASSIGN, 9 | TOKEN_BLOCK_END, 10 | TOKEN_COLON, 11 | TOKEN_LPAREN, 12 | TOKEN_NAME, 13 | TOKEN_PIPE, 14 | TOKEN_RPAREN, 15 | TOKEN_VARIABLE_END, 16 | Token, 17 | ) 18 | 19 | if TYPE_CHECKING: 20 | from jinja2.lexer import TokenStream 21 | 22 | 23 | class FilterColonExtension(Extension): 24 | """This extension allows colon to be used to separate 25 | the filter and arguments, so that we can write django/liquid-style filters 26 | """ 27 | 28 | def filter_stream(self, stream: "TokenStream") -> Iterable[Token]: 29 | """Modify the colon to lparen and rparen tokens""" 30 | # expect a colon 31 | # 0: don't expect to change any {{a | filter: arg}} 32 | # to {{a | filter(arg)}} 33 | # 1: expect a filter 34 | # 2: expect the colon 35 | # 3: expect rparen 36 | flag = 0 37 | 38 | for token in stream: 39 | # print(token.value, token.type) 40 | if flag == 0 and token.type is TOKEN_PIPE: 41 | flag = 1 42 | elif token.type is TOKEN_NAME and flag == 1: 43 | flag = 2 44 | elif token.type is TOKEN_COLON and flag == 2: 45 | flag = 3 46 | token = Token(token.lineno, TOKEN_LPAREN, None) 47 | elif token.type is TOKEN_COLON and flag == 3: 48 | # {{ a | filter: 1, x: 2}} => {{ a | filter: 1, x=2}} 49 | token = Token(token.lineno, TOKEN_ASSIGN, None) 50 | elif ( 51 | token.type in (TOKEN_VARIABLE_END, TOKEN_BLOCK_END, TOKEN_PIPE) 52 | and flag == 3 53 | ): 54 | flag = 1 if token.type is TOKEN_PIPE else 0 55 | yield Token(token.lineno, TOKEN_RPAREN, None) 56 | elif token.type in (TOKEN_VARIABLE_END, TOKEN_BLOCK_END): 57 | flag = 0 58 | 59 | yield token 60 | -------------------------------------------------------------------------------- /liquid/utils.py: -------------------------------------------------------------------------------- 1 | """Some utils""" 2 | from os import PathLike 3 | from typing import TYPE_CHECKING, Iterable, List, Union 4 | from jinja2 import nodes 5 | from jinja2.lexer import TOKEN_INTEGER, TOKEN_NAME 6 | from jinja2.exceptions import TemplateSyntaxError 7 | 8 | if TYPE_CHECKING: 9 | from jinja2.lexer import TokenStream, Token 10 | 11 | PathType = Union[PathLike, str] 12 | PathTypeOrIter = Union[PathType, Iterable[PathType]] 13 | 14 | 15 | def peek_tokens(stream: "TokenStream", n: int = 1) -> List["Token"]: 16 | """Peek ahead 'n' tokens in the token stream, but don't move the cursor 17 | 18 | Args: 19 | stream: The token stream 20 | n: n tokens to look at 21 | 22 | Returns: 23 | List of n tokens ahead. 24 | """ 25 | out = [] 26 | pushes = [] 27 | for _ in range(n): 28 | out.append(next(stream)) 29 | pushes.append(stream.current) 30 | 31 | for token in pushes: 32 | stream.push(token) 33 | stream.current = out[0] 34 | return out 35 | 36 | 37 | def parse_tag_args( 38 | stream: "TokenStream", name: str, lineno: int 39 | ) -> nodes.Node: 40 | """Parse arguments for a tag. 41 | 42 | Only integer and name are allowed as values 43 | 44 | Examples: 45 | >>> "{{tablerow product in products cols:2}}" 46 | >>> parse_tag_args(stream, "cols", lineno) 47 | >>> # returns nodes.Const(2) 48 | 49 | Args: 50 | stream: The token stream 51 | name: The name of the argument 52 | lineno: The lineno 53 | 54 | Returns: 55 | None if the argument is not pressent otherwise a Const or Name node 56 | """ 57 | # use Parser.parse_primary? 58 | arg = stream.skip_if(f"name:{name}") 59 | if not arg: 60 | return None 61 | 62 | stream.expect("colon") 63 | # tokens_ahead = peek_tokens(stream) 64 | if not stream.current.test_any(TOKEN_INTEGER, TOKEN_NAME): 65 | raise TemplateSyntaxError( 66 | f"Expected an integer or a variable as argument for '{name}'.", 67 | lineno, 68 | ) 69 | 70 | arg = next(stream) 71 | if arg.type is TOKEN_INTEGER: 72 | return nodes.Const(arg.value) 73 | return nodes.Name(arg.value, "load") 74 | -------------------------------------------------------------------------------- /liquid/filters/manager.py: -------------------------------------------------------------------------------- 1 | """Provides filter manager""" 2 | from typing import TYPE_CHECKING, Callable, Dict, Sequence, Union 3 | 4 | if TYPE_CHECKING: 5 | from jinja2 import Environment 6 | 7 | 8 | class FilterManager: 9 | """A manager for filters 10 | 11 | Attributes: 12 | filters: a mapping of filter names to filters 13 | """ 14 | 15 | __slots__ = ("filters",) 16 | 17 | def __init__(self) -> None: 18 | """Constructor""" 19 | self.filters: Dict[str, Callable] = {} 20 | 21 | def register( 22 | self, name_or_filter: Union[str, Sequence[str], Callable] = None 23 | ) -> Callable: 24 | """Register a filter 25 | 26 | This can be used as a decorator 27 | 28 | Examples: 29 | >>> @filter_manager.register 30 | >>> def add(a, b): 31 | >>> return a+b 32 | >>> # register it with an alias: 33 | >>> @filter_manager.register('addfunc') 34 | >>> def add(a, b): 35 | >>> return a+b 36 | 37 | Args: 38 | name_or_filter: The filter to register 39 | if name is given, will be treated as alias 40 | 41 | Returns: 42 | The registered function or the decorator 43 | """ 44 | 45 | def decorator(filterfunc: Callable) -> Callable: 46 | name = filterfunc.__name__ 47 | name = [name] # type: ignore 48 | 49 | if name_or_filter and name_or_filter is not filterfunc: 50 | names = name_or_filter 51 | if isinstance(names, str): 52 | names = ( 53 | nam.strip() for nam in names.split(",") 54 | ) # type: ignore 55 | name = names # type: ignore 56 | for nam in name: 57 | self.filters[nam] = filterfunc 58 | 59 | return filterfunc 60 | 61 | if callable(name_or_filter): 62 | return decorator(name_or_filter) 63 | 64 | return decorator 65 | 66 | def update_to_env( 67 | self, env: "Environment", overwrite: bool = True 68 | ) -> None: 69 | """Update the filters to environment 70 | 71 | Args: 72 | env: The environment to update these filters to 73 | overwrite: Whether overwrite existing filters in the env? 74 | """ 75 | if overwrite: 76 | env.filters.update(self.filters) 77 | 78 | filters = self.filters.copy() 79 | filters.update(env.filters) 80 | env.filters = filters 81 | -------------------------------------------------------------------------------- /docs/playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Liquidpy playground 12 | 13 | 14 |
15 |

Liquidpy playground

16 |

17 | This is a playground for Liquidpy, powered by pyscript. 18 |

19 |

20 | 21 | import liquid 22 | from pyscript import display 23 | display(f"Liquidpy version: {liquid.__version__}") 24 | 25 |

26 |

 

27 |
28 |
29 |

Template

30 | 31 |
32 |
33 |

Variables

34 | 35 |
36 |
37 |

Filters

38 | 39 |
40 |
41 | 47 | 48 | 49 | 50 |
51 |
52 |

Rendered:

53 | 54 |
55 |
56 | 57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | .md-main { 2 | margin: 0 5em; 3 | } 4 | 5 | .md-typeset .admonition, .md-typeset details { 6 | font-size: .7rem !important; 7 | } 8 | 9 | .md-typeset table:not([class]) td { 10 | padding: .55em 1.25em !important; 11 | } 12 | 13 | .md-typeset table:not([class]) th { 14 | padding: .75em 1.25em !important; 15 | } 16 | 17 | .md-grid { 18 | max-width: none; 19 | } 20 | 21 | .mkapi-docstring{ 22 | line-height: 1; 23 | } 24 | .mkapi-node { 25 | background-color: #f0f6fa; 26 | border-top: 3px solid #559bc9; 27 | } 28 | .mkapi-node .mkapi-object-container { 29 | background-color: #b4d4e9; 30 | padding: .12em .4em; 31 | } 32 | .mkapi-node .mkapi-object-container .mkapi-object.code { 33 | background: none; 34 | border: none; 35 | } 36 | .mkapi-node .mkapi-object-container .mkapi-object.code * { 37 | font-size: .65rem !important; 38 | } 39 | .mkapi-node pre { 40 | line-height: 1.5; 41 | } 42 | .md-typeset pre>code { 43 | overflow: visible; 44 | line-height: 1.2; 45 | } 46 | .mkapi-docstring .md-typeset pre>code { 47 | font-size: 0.1rem !important; 48 | } 49 | .mkapi-section-name.bases { 50 | margin-top: .2em; 51 | } 52 | .mkapi-section-body.bases { 53 | padding-bottom: .7em; 54 | line-height: 1.3; 55 | } 56 | .mkapi-section.bases { 57 | margin-bottom: .8em; 58 | } 59 | .mkapi-node * { 60 | font-size: .7rem; 61 | } 62 | .mkapi-node a.mkapi-src-link { 63 | word-break: keep-all; 64 | } 65 | .mkapi-docstring { 66 | padding: .4em .15em !important; 67 | } 68 | .mkapi-section-name-body { 69 | font-size: .72rem !important; 70 | } 71 | .mkapi-node ul.mkapi-items li { 72 | line-height: 1.4 !important; 73 | } 74 | .mkapi-node ul.mkapi-items li * { 75 | font-size: .65rem !important; 76 | } 77 | .mkapi-node code.mkapi-object-signature { 78 | padding-right: 2px; 79 | } 80 | .mkapi-node .mkapi-code * { 81 | font-size: .65rem; 82 | } 83 | .mkapi-node a.mkapi-docs-link { 84 | font-size: .6rem; 85 | } 86 | .mkapi-node h1.mkapi-object.mkapi-object-code { 87 | margin: .2em .3em; 88 | } 89 | .mkapi-node h1.mkapi-object.mkapi-object-code .mkapi-object-kind.mkapi-object-kind-code { 90 | font-style: normal; 91 | margin-right: 16px; 92 | } 93 | .mkapi-node .mkapi-item-name { 94 | font-size: .7rem !important; 95 | color: #555; 96 | padding-right: 4px; 97 | } 98 | .md-typeset { 99 | font-size: .75rem !important; 100 | line-height: 1.5 !important; 101 | } 102 | .mkapi-object-kind.package.top { 103 | font-size: .8rem !important; 104 | color: #111; 105 | 106 | } 107 | .mkapi-object.package.top > h2 { 108 | font-size: .8rem !important; 109 | } 110 | 111 | .mkapi-object-body.package.top * { 112 | font-size: .75rem !important; 113 | } 114 | .mkapi-object-kind.module.top { 115 | font-size: .75rem !important; 116 | color: #222; 117 | } 118 | 119 | .mkapi-object-body.module.top * { 120 | font-size: .75rem !important; 121 | } 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # liquidpy 2 | A port of [liquid][19] template engine for python, on the shoulders of [jinja2][17] 3 | 4 | [![Pypi][2]][9] [![Github][3]][10] [![PythonVers][4]][9] [![Docs building][13]][11] [![Travis building][5]][11] [![Codacy][6]][12] [![Codacy coverage][7]][12] 5 | 6 | ## Install 7 | ```shell 8 | pip install -U liquidpy 9 | ``` 10 | 11 | ## Playground 12 | 13 | Powered by [pyscript][21]: 14 | 15 | [https://pwwang.github.io/liquidpy/playground][22] 16 | 17 | ## Baisic usage 18 | 19 | ### Loading a template 20 | ```python 21 | from liquid import Liquid 22 | liq = Liquid('{{a}}', from_file=False) 23 | ret = liq.render(a = 1) 24 | # ret == '1' 25 | 26 | # load template from a file 27 | liq = Liquid('/path/to/template.html') 28 | ``` 29 | 30 | Using jinja's environment 31 | ```python 32 | from jinja2 import Environment, FileSystemLoader 33 | env = Environment(loader=FileSystemLoader('./'), ...) 34 | 35 | liq = Liquid.from_env("/path/to/template.html", env) 36 | ``` 37 | 38 | ### Switching to a different mode 39 | 40 | ```python 41 | liq = Liquid( 42 | """ 43 | {% python %} 44 | from os import path 45 | filename = path.join("a", "b") 46 | {% endpython %} 47 | {{filename}} 48 | """, 49 | mode="wild" # supported: standard(default), jekyll, shopify, wild 50 | ) 51 | liq.render() 52 | # 'a/b' 53 | ``` 54 | 55 | ### Changing default options 56 | 57 | ```python 58 | from liquid import defaults, Liquid 59 | defaults.FROM_FILE = False 60 | defaults.MODE = 'wild' 61 | 62 | # no need to pass from_file and mode anymore 63 | liq = Liquid('{% from_ os import path %}{{path.basename("a/b.txt")}}') 64 | liq.render() 65 | # 'b.txt' 66 | ``` 67 | 68 | 69 | ## Documentation 70 | 71 | - [Liquidpy's documentation][8] 72 | - [Liquid documentation (standard)][19] 73 | - [Liquid documentation (jekyll)][18] 74 | - [Liquid documentation (shopify-extended)][1] 75 | - [Jinja2's documentation][20] 76 | 77 | 78 | [1]: https://shopify.dev/api/liquid 79 | [2]: https://img.shields.io/pypi/v/liquidpy.svg?style=flat-square 80 | [3]: https://img.shields.io/github/tag/pwwang/liquidpy.svg?style=flat-square 81 | [4]: https://img.shields.io/pypi/pyversions/liquidpy.svg?style=flat-square 82 | [5]: https://img.shields.io/github/actions/workflow/status/pwwang/liquidpy/build.yml?style=flat-square 83 | [6]: https://img.shields.io/codacy/grade/aed04c099cbe42dabda2b42bae557fa4?style=flat-square 84 | [7]: https://img.shields.io/codacy/coverage/aed04c099cbe42dabda2b42bae557fa4?style=flat-square 85 | [8]: https://pwwang.github.io/liquidpy 86 | [9]: https://pypi.org/project/liquidpy/ 87 | [10]: https://github.com/pwwang/liquidpy 88 | [11]: https://github.com/pwwang/liquidpy/actions 89 | [12]: https://app.codacy.com/gh/pwwang/liquidpy/dashboard 90 | [13]: https://img.shields.io/github/actions/workflow/status/pwwang/liquidpy/docs.yml?style=flat-square 91 | [14]: https://github.com/pwwang/liquidpy/tree/lark 92 | [15]: https://github.com/pwwang/liquidpy/tree/larkone 93 | [16]: https://github.com/pwwang/liquidpy/issues/22 94 | [17]: https://jinja.palletsprojects.com/ 95 | [18]: https://jekyllrb.com/docs/liquid/ 96 | [19]: https://shopify.github.io/liquid/ 97 | [20]: https://jinja.palletsprojects.com/ 98 | [21]: https://pyscript.net/ 99 | [22]: https://pwwang.github.io/liquidpy/playground 100 | -------------------------------------------------------------------------------- /liquid/exts/ext.py: -------------------------------------------------------------------------------- 1 | """Provides a base extension class""" 2 | import re 3 | from base64 import b64encode 4 | from typing import TYPE_CHECKING 5 | 6 | from jinja2 import nodes 7 | from jinja2.ext import Extension 8 | 9 | if TYPE_CHECKING: 10 | from jinja2.parser import Parser 11 | 12 | 13 | re_e = re.escape 14 | re_c = lambda rex: re.compile(rex, re.DOTALL | re.MULTILINE) 15 | 16 | # A unique id to encode the start strings 17 | ENCODING_ID = id(Extension) 18 | 19 | 20 | class LiquidExtension(Extension): 21 | """A base extension class for extensions in this package to extend""" 22 | 23 | def __init_subclass__(cls) -> None: 24 | """Initalize the tags and raw_tags using tag manager""" 25 | cls.tags = cls.tag_manager.names 26 | cls.raw_tags = cls.tag_manager.names_raw 27 | 28 | def preprocess( # type: ignore 29 | self, 30 | source: str, 31 | name: str, 32 | filename: str, 33 | ) -> str: 34 | """Try to keep the tag body raw by encode the variable/comment/block 35 | start strings ('{{', '{#', '{%') so that the body won't be tokenized 36 | by jinjia. 37 | """ 38 | # Turn 39 | # " {{* ... }}" to 40 | # " {{* ... | indent(2) }}" 41 | # to keep the indent for multiline variables 42 | variable_start_re = re_e(self.environment.variable_start_string) 43 | variable_end_re = re_e(self.environment.variable_end_string) 44 | indent_re = re_c( 45 | fr"^([ \t]*){variable_start_re}\*" 46 | "(.*?)" 47 | fr"(\-{variable_end_re}|\+{variable_end_re}|{variable_end_re})" 48 | ) 49 | source = indent_re.sub( 50 | lambda m: ( 51 | f"{m.group(1)}{self.environment.variable_start_string}" 52 | f"{m.group(2)} | indent({m.group(1)!r}){m.group(3)}" 53 | ), 54 | source, 55 | ) 56 | 57 | if not self.__class__.raw_tags: # pragma: no cover 58 | return super().preprocess(source, name, filename=filename) 59 | 60 | block_start_re = re_e(self.environment.block_start_string) 61 | block_end_re = re_e(self.environment.block_end_string) 62 | comment_start_re = re_e(self.environment.comment_start_string) 63 | to_encode = re_c( 64 | f"({block_start_re}|{variable_start_re}|{comment_start_re})" 65 | ) 66 | 67 | def encode_raw(matched): 68 | content = to_encode.sub( 69 | lambda m: ( 70 | f"$${ENCODING_ID}$" 71 | f"{b64encode(m.group(1).encode()).decode()}$$" 72 | ), 73 | matched.group(2), 74 | ) 75 | return f"{matched.group(1)}{content}{matched.group(3)}" 76 | 77 | for raw_tag in self.__class__.raw_tags: 78 | tag_re = re_c( 79 | # {% comment "//" 80 | fr"({block_start_re}(?:\-|\+|)\s*{raw_tag}\s*.*?" 81 | # %} 82 | fr"(?:\-{block_end_re}|\+{block_end_re}|{block_end_re}))" 83 | # ... 84 | fr"(.*?)" 85 | # {% endcomment 86 | fr"({block_start_re}(?:\-|\+|)\s*end{raw_tag}\s*" 87 | fr"(?:\-{block_end_re}|\+{block_end_re}|{block_end_re}))" 88 | ) 89 | source = tag_re.sub(encode_raw, source) 90 | return source 91 | 92 | def parse(self, parser: "Parser") -> nodes.Node: 93 | """Let tag manager to parse the tags that are being listened to""" 94 | token = next(parser.stream) 95 | return self.__class__.tag_manager.parse( 96 | self.environment, token, parser 97 | ) 98 | -------------------------------------------------------------------------------- /liquid/exts/standard.py: -------------------------------------------------------------------------------- 1 | """Provides an extension to implment features for standard liquid""" 2 | 3 | from typing import TYPE_CHECKING, Generator 4 | from jinja2.lexer import ( 5 | TOKEN_ADD, 6 | TOKEN_COMMA, 7 | TOKEN_INTEGER, 8 | TOKEN_LPAREN, 9 | TOKEN_NAME, 10 | TOKEN_RPAREN, 11 | TOKEN_DOT, 12 | Token, 13 | ) 14 | 15 | from ..utils import peek_tokens 16 | from ..tags.standard import standard_tags 17 | 18 | from .ext import LiquidExtension 19 | 20 | if TYPE_CHECKING: 21 | from jinja2.lexer import TokenStream 22 | 23 | 24 | class LiquidStandardExtension(LiquidExtension): 25 | """This extension implement features for standard liqiud 26 | 27 | These features (that jinja does support) including 28 | 1. Allow '.size' to get length of an array (by replacing it 29 | with '.__len__()') 30 | 2. Allow 'contains' to work as an operator by turning it into a test 31 | 3. Turn 'forloop' to 'loop' 32 | 4. Allow `(1..5)`, which will be turned to `range(1, 6)` 33 | """ 34 | 35 | tag_manager = standard_tags 36 | 37 | def __init__(self, environment): 38 | super().__init__(environment) 39 | environment.tests["contains"] = lambda cont, elm: cont.__contains__( 40 | elm 41 | ) 42 | 43 | def filter_stream(self, stream: "TokenStream") -> Generator: 44 | """Supports for liquid features""" 45 | for token in stream: 46 | # .size => .__len__() 47 | if token.type is TOKEN_DOT: 48 | 49 | if stream.current.test("name:size"): 50 | stream.skip() # skip 'size' 51 | yield token 52 | yield Token(token.lineno, "name", "__len__") 53 | yield Token(token.lineno, "lparen", None) 54 | yield Token(token.lineno, "rparen", None) 55 | else: 56 | yield token 57 | 58 | # turn "contains" to "is contains" to use "contains" as a test 59 | elif token.test("name:contains"): 60 | yield Token(token.lineno, "name", "is") 61 | yield token 62 | 63 | # turn forloop to loop 64 | elif token.test("name:forloop"): 65 | # only when we do forloop.xxx 66 | if stream.current.type is TOKEN_DOT: 67 | yield Token(token.lineno, "name", "loop") 68 | else: 69 | yield token 70 | 71 | # (a..b) => range(a, b + 1) 72 | elif token.type is TOKEN_LPAREN and stream.current.type in ( 73 | TOKEN_NAME, 74 | TOKEN_INTEGER, 75 | ): 76 | tokens_ahead = peek_tokens(stream, 5) 77 | # print(tokens_ahead) 78 | if ( 79 | len(tokens_ahead) < 5 80 | or tokens_ahead[0].type not in (TOKEN_INTEGER, TOKEN_NAME) 81 | or tokens_ahead[1].type is not TOKEN_DOT 82 | or tokens_ahead[2].type is not TOKEN_DOT 83 | or tokens_ahead[3].type not in (TOKEN_INTEGER, TOKEN_NAME) 84 | or tokens_ahead[4].type is not TOKEN_RPAREN 85 | ): 86 | yield token 87 | else: 88 | stream.skip(5) 89 | yield Token(token.lineno, TOKEN_NAME, "range") 90 | yield Token(token.lineno, TOKEN_LPAREN, None) 91 | yield tokens_ahead[0] 92 | yield Token(token.lineno, TOKEN_COMMA, None) 93 | yield tokens_ahead[3] 94 | yield Token(token.lineno, TOKEN_ADD, None) 95 | yield Token(token.lineno, TOKEN_INTEGER, 1) # type: ignore 96 | yield Token(token.lineno, TOKEN_RPAREN, None) 97 | 98 | else: 99 | yield token 100 | -------------------------------------------------------------------------------- /liquid/filters/wild.py: -------------------------------------------------------------------------------- 1 | """Provides some wild filters""" 2 | 3 | try: 4 | from jinja2 import pass_environment 5 | except ImportError: 6 | from jinja2 import environmentfilter as pass_environment 7 | 8 | from typing import TYPE_CHECKING, Any, Callable 9 | from .manager import FilterManager 10 | 11 | if TYPE_CHECKING: 12 | from jinja2.environment import Environment 13 | 14 | wild_filter_manager = FilterManager() 15 | 16 | 17 | @wild_filter_manager.register("ifelse, if_else") 18 | @pass_environment 19 | def ifelse( 20 | env: "Environment", 21 | value: Any, 22 | test: Any, 23 | test_args: Any = (), 24 | true: Any = None, 25 | true_args: Any = (), 26 | false: Any = None, 27 | false_args: Any = (), 28 | ) -> Any: 29 | """An if-else filter, implementing a tenary-like filter. 30 | 31 | Use `ifelse` or `if_else`. 32 | 33 | Examples: 34 | >>> {{ a | ifelse: isinstance, (int, ), 35 | >>> "plus", (1, ), 36 | >>> "append", (".html", ) }} 37 | >>> # 2 when a = 1 38 | >>> # "a.html" when a = "a" 39 | 40 | Args: 41 | value: The base value 42 | test: The test callable or filter name 43 | test_args: Other args (value as the first arg) for the test 44 | true: The callable or filter name when test is True 45 | true_args: Other args (value as the first arg) for the true 46 | When this is None, return the true callable itself or the name 47 | of the filter it self 48 | false: The callable or filter name when test is False 49 | false_args: Other args (value as the first arg) for the false 50 | When this is None, return the false callable itself or the name 51 | of the filter it self 52 | Returns: 53 | The result of true of test result is True otherwise result of false. 54 | """ 55 | 56 | def compile_out(func: Any, args: Any) -> Any: 57 | if args is None: 58 | return func 59 | if not isinstance(args, tuple): 60 | args = (args,) 61 | if callable(func): 62 | return func(value, *args) 63 | expr = env.compile_expression(f"value | {func}(*args)") 64 | return expr(value=value, args=args) 65 | 66 | test_out = compile_out(test, test_args) 67 | if test_out: 68 | return compile_out(true, true_args) 69 | return compile_out(false, false_args) 70 | 71 | 72 | @wild_filter_manager.register 73 | def call(fn: Callable, *args, **kwargs) -> Any: 74 | """Call a function with passed arguments 75 | 76 | Examples: 77 | >>> {{ int | call: "1" | plus: 1 }} 78 | >>> # 2 79 | 80 | Args: 81 | fn: The callable 82 | *args: and 83 | **kwargs: The arguments for the callable 84 | 85 | Returns: 86 | The result of calling the function 87 | """ 88 | return fn(*args, **kwargs) 89 | 90 | 91 | @wild_filter_manager.register 92 | def each(array: Any, fn: Callable, *args: Any, **kwargs: Any) -> Any: 93 | """Call a function for each item in an array. 94 | 95 | With wild mode, you can use the 'map' filter to apply a function to each 96 | item in an array. However, this filter is different from the 'map' filter 97 | in that it takes the array as the first argument and additional arguments 98 | passed to the function are allowed. 99 | 100 | Examples: 101 | >>> {{ floor | map: [1.1, 2.1, 3.1] | list }} 102 | >>> # [1, 2, 3] 103 | 104 | >>> {{ [1.1, 2.1, 3.1] | each: floor }} 105 | >>> # [1, 2, 3] 106 | >>> {{ [1.1, 2.1, 3.1] | each: plus, 1 }} 107 | >>> # [2.2, 3.2, 4.2] 108 | 109 | Args: 110 | array: The array 111 | fn: The callable 112 | 113 | Returns: 114 | The result of calling the function for each item in the array 115 | """ 116 | return [fn(item, *args, **kwargs) for item in array] 117 | -------------------------------------------------------------------------------- /docs/basics.md: -------------------------------------------------------------------------------- 1 | ## Mode of a template 2 | 3 | `liquidpy` supports 4 modes: 4 | 5 | - standard 6 | - try to be compatible with standard liquid template engine 7 | - See: https://shopify.github.io/liquid/ 8 | - jekyll 9 | - try to be compatible with jekyll liquid template engine 10 | - See: https://jekyllrb.com/docs/liquid/ 11 | - shopify 12 | - try to be compatible with shopify-extended liquid template engine 13 | - See: https://shopify.dev/api/liquid 14 | - wild 15 | - With some wild features supported (i.e. executing python code inside the template) 16 | - See: https://pwwang.github.io/liquidpy/wild 17 | 18 | See also an introduction about liquid template engine variants: 19 | 20 | - https://shopify.github.io/liquid/basics/variations/ 21 | 22 | By default, `liquidpy` uses the `standard` mode. But you can specify a mode using the `mode` argument of `Liquid` constructor or `Liquid.from_env()` method. 23 | 24 | You can changed the default by: 25 | ```python 26 | from liquid import defaults 27 | defaults.MODE = 'wild' 28 | ``` 29 | before you initialize a `Liquid` object. 30 | 31 | ## Preset globals and filters 32 | 33 | If you want to send a set of global variables and filters to the templates: 34 | 35 | ```python 36 | from liquid import Liquid, defaults 37 | defaults.FROM_FILE = False 38 | 39 | a = 1 40 | b = 2 41 | 42 | Liquid("{{a | plus: b}}", globals=globals()).render() 43 | # '3' 44 | ``` 45 | 46 | Specify predefined filters: 47 | 48 | ```python 49 | import os 50 | from liquid import Liquid, defaults 51 | defaults.FROM_FILE = False 52 | 53 | Liquid("{{'/a' | path_join: 'b'}}", filters={'path_join': os.path.join}).render() 54 | # '/a/b' 55 | ``` 56 | 57 | ## Relationship with Jinja2/3 58 | 59 | Most features here are implemented by jinja extensions. Some of them, however, are impossible to implement via extensions. So we monkey-patched jinja to be better compatible with liquid syntax. 60 | 61 | !!! Note 62 | 63 | If you want jinja to work as its original way, remember to unpatch it before you parse and render your template: 64 | 65 | ```python 66 | from jinja2 import Template 67 | from liquid import Liquid, patch_jinja, unpatch_jinja 68 | 69 | liq_tpl = Liquid(...) 70 | liq_tpl.render(...) # works 71 | 72 | jinja_tpl = Template(...) # error may happen 73 | jinja_tpl.render(...) # error may happen 74 | 75 | unpatch_jinja() # restore jinja 76 | jinja_tpl = Template(...) # works 77 | jinja_tpl.render(...) # works 78 | 79 | liq_tpl.render(...) # error may happen 80 | 81 | patch_jinja() # patch jinja again 82 | liq_tpl.render(...) # works 83 | ``` 84 | 85 | Most jinja features are supported unless the filters/tags are overriden. For example, the `round()` filter acts differently then the one in `jinja`. 86 | 87 | We could say that the implementations of `liquid` and its variants are super sets of them themselves, with some slight compatibility issues (See `Compatilities` below.) 88 | 89 | ## Whitespace control 90 | 91 | The whitespace control behaves the same as it describes here: 92 | 93 | - https://shopify.github.io/liquid/basics/whitespace/ 94 | 95 | ## Compatibilies 96 | 97 | See the compatiblity issues for truthy/falsy, tags, and other aspects on pages: 98 | 99 | - Standard: https://pwwang.github.com/liquidpy/standard 100 | - Jekyll: https://pwwang.github.com/liquidpy/jekyll 101 | - Shopify: https://pwwang.github.com/liquidpy/shopify 102 | 103 | ## Wild mode 104 | 105 | You can do arbitrary things with the wild mode, like executing python code and adding custom filters inside the template. 106 | 107 | See details on: 108 | 109 | - https://pwwang.github.com/liquidpy/wild 110 | 111 | ## `*` modifier for `{{` to keep initial indention along multiple lines 112 | 113 | ```python 114 | tpl = """\ 115 | if True: 116 | {{* body }} 117 | """ 118 | body = """\ 119 | print('hello') 120 | print('world') 121 | """ 122 | print(Liquid(tpl, from_file=False).render(body=body)) 123 | ``` 124 | 125 | ``` 126 | if True: 127 | print('hello') 128 | print('world') 129 | ``` 130 | -------------------------------------------------------------------------------- /liquid/patching.py: -------------------------------------------------------------------------------- 1 | """Patch a couple of jinja functions to implement some features 2 | that are impossible or too complex to be implemented by extensions 3 | 4 | Including 5 | 1. Patching Parser.parse to allow 'elsif' in addition to 'elif' 6 | 2. Patching LoopContext to allow rindex and rindex0 7 | 3. Adding liquid_cycle method to LoopContext to allow cycle to have a name 8 | 4. Patching Parser.parse_for to allow arguments for tag 'for' 9 | """ 10 | from typing import Any 11 | from jinja2 import nodes 12 | from jinja2.parser import Parser 13 | from jinja2.runtime import LoopContext 14 | 15 | from .utils import parse_tag_args 16 | 17 | 18 | # patching Parser.parse_if to allow elsif in addition to elif 19 | # ----------------------------------------------------------- 20 | def parse_if(self) -> nodes.Node: 21 | node = result = nodes.If(lineno=self.stream.expect("name:if").lineno) 22 | while True: 23 | node.test = self.parse_tuple(with_condexpr=False) 24 | node.body = self.parse_statements( 25 | ("name:elif", "name:elsif", "name:else", "name:endif") 26 | ) 27 | node.elif_ = [] 28 | node.else_ = [] 29 | token = next(self.stream) 30 | if token.test_any("name:elif", "name:elsif"): 31 | node = nodes.If(lineno=self.stream.current.lineno) 32 | result.elif_.append(node) 33 | continue 34 | elif token.test("name:else"): 35 | result.else_ = self.parse_statements( 36 | ("name:endif",), drop_needle=True 37 | ) 38 | break 39 | return result 40 | 41 | 42 | jinja_nodes_if_fields = nodes.If.fields 43 | jinja_parse_if = Parser.parse_if 44 | 45 | 46 | # patching LoopContext to allow rindex and rindex0 47 | # Also add liquid_cycle method to allow cycle to have a name 48 | # ----------------------------------------------------------- 49 | def cycle(self, *args: Any, name: Any = None) -> Any: 50 | if not hasattr(self, "_liquid_cyclers"): 51 | setattr(self, "_liquid_cyclers", {}) 52 | cyclers = self._liquid_cyclers 53 | if name not in cyclers: 54 | cyclers[name] = [args, -1] 55 | cycler = cyclers[name] 56 | cycler[1] += 1 57 | return cycler[0][cycler[1] % len(cycler[0])] 58 | 59 | 60 | # patching Parser.parse_for to allow arguments 61 | # ----------------------------------------------------------- 62 | def parse_for(self) -> nodes.Node: 63 | lineno = self.stream.expect("name:for").lineno 64 | target = self.parse_assign_target(extra_end_rules=("name:in",)) 65 | self.stream.expect("name:in") 66 | iter = self.parse_tuple( 67 | with_condexpr=False, 68 | extra_end_rules=( 69 | "name:recursive", 70 | "name:reversed", 71 | "name:limit", 72 | "name:offset", 73 | ), 74 | ) 75 | reverse = self.stream.skip_if("name:reversed") 76 | limit = parse_tag_args(self.stream, "limit", lineno) 77 | offset = parse_tag_args(self.stream, "offset", lineno) 78 | if limit and offset: 79 | limit = nodes.Add(offset, limit) 80 | if limit or offset: 81 | iter = nodes.Getitem(iter, nodes.Slice(offset, limit, None), "load") 82 | if reverse: 83 | iter = nodes.Filter(iter, "reverse", [], [], None, None) 84 | 85 | test = None 86 | if self.stream.skip_if("name:if"): 87 | test = self.parse_expression() 88 | recursive = self.stream.skip_if("name:recursive") 89 | body = self.parse_statements(("name:endfor", "name:else")) 90 | if next(self.stream).value == "endfor": 91 | else_ = [] 92 | else: 93 | else_ = self.parse_statements(("name:endfor",), drop_needle=True) 94 | return nodes.For(target, iter, body, else_, test, recursive, lineno=lineno) 95 | 96 | 97 | jinja_parse_for = Parser.parse_for 98 | 99 | 100 | def patch_jinja(): 101 | """Monkey-patch jinja""" 102 | nodes.If.fields = jinja_nodes_if_fields + ("elsif",) 103 | nodes.If.elsif = None 104 | Parser.parse_if = parse_if 105 | 106 | LoopContext.rindex = LoopContext.revindex 107 | LoopContext.rindex0 = LoopContext.revindex0 108 | LoopContext.liquid_cycle = cycle 109 | 110 | Parser.parse_for = parse_for 111 | 112 | 113 | def unpatch_jinja(): 114 | """Restore the patches to jinja""" 115 | nodes.If.fields = jinja_nodes_if_fields 116 | del nodes.If.elsif 117 | 118 | Parser.parse_if = jinja_parse_if 119 | del LoopContext.rindex 120 | del LoopContext.rindex0 121 | del LoopContext.liquid_cycle 122 | 123 | Parser.parse_for = jinja_parse_for 124 | -------------------------------------------------------------------------------- /liquid/tags/manager.py: -------------------------------------------------------------------------------- 1 | """Provide tag manager""" 2 | import re 3 | from base64 import b64decode 4 | from typing import TYPE_CHECKING, Callable, Dict, Set, Union 5 | 6 | from jinja2 import nodes 7 | from jinja2.exceptions import TemplateSyntaxError 8 | 9 | if TYPE_CHECKING: 10 | from jinja2.lexer import Token 11 | from jinja2.parser import Parser 12 | from jinja2.environment import Environment 13 | 14 | 15 | from ..exts.ext import ENCODING_ID 16 | 17 | ENCODED_PATTERN = re.compile(fr"\$\${ENCODING_ID}\$([\w=+/]+)\$\$") 18 | 19 | 20 | def decode_raw(body: str) -> str: 21 | """Decode the encoded string in body 22 | 23 | The start string in body is encoded so that they won't be recognized 24 | as variable/comment/block by jinja. This way, we can protect the body 25 | from being tokenized. 26 | 27 | Args: 28 | body: The body 29 | 30 | Returns: 31 | The decoded string. 32 | """ 33 | return ENCODED_PATTERN.sub( 34 | lambda m: b64decode(m.group(1)).decode(), 35 | body, 36 | ) 37 | 38 | 39 | class TagManager: 40 | """A manager for tags 41 | 42 | Attributes: 43 | tags: a mapping of tag names and parser functions 44 | envs: a mapping of tag names and whether environment should be passed 45 | to the parser functions 46 | raws: a mapping of tag names and whether the tag body should be 47 | kept raw. 48 | """ 49 | 50 | __slots__ = ("tags", "envs", "raws") 51 | 52 | def __init__(self) -> None: 53 | """Constructor""" 54 | self.tags: Dict[str, Callable] = {} 55 | self.envs: Dict[str, bool] = {} 56 | self.raws: Dict[str, bool] = {} 57 | 58 | def register( 59 | self, 60 | name_or_tagparser: Union[str, Callable] = None, 61 | env: bool = False, 62 | raw: bool = False, 63 | ) -> Callable: 64 | """Register a filter 65 | 66 | This can be used as a decorator 67 | 68 | Examples: 69 | >>> @tag_manager.register 70 | >>> def comment(token, parser): 71 | >>> from jinja2 import nodes 72 | >>> return nodes.Const("") 73 | 74 | Args: 75 | name_or_tagparser: The tag parser to register 76 | if name is given, will be treated as alias 77 | env: Whether we should pass environment to the parser 78 | raw: Whether we should keep the body of the tag raw 79 | 80 | Returns: 81 | The registered parser for the tag or a decorator 82 | """ 83 | 84 | def decorator(tagparser: Callable) -> Callable: 85 | name = tagparser.__name__ 86 | name = [name] # type: ignore 87 | 88 | if ( 89 | name_or_tagparser and name_or_tagparser is not tagparser 90 | ): # pragma: no cover 91 | names = name_or_tagparser 92 | if isinstance(names, str): 93 | names = ( 94 | nam.strip() for nam in names.split(",") 95 | ) # type: ignore 96 | name = names # type: ignore 97 | 98 | for nam in name: 99 | self.tags[nam] = tagparser 100 | self.envs[nam] = env 101 | self.raws[nam] = raw 102 | 103 | return tagparser 104 | 105 | if callable(name_or_tagparser): 106 | return decorator(name_or_tagparser) 107 | 108 | return decorator 109 | 110 | @property 111 | def names(self) -> Set[str]: 112 | """Get a set of the tag names""" 113 | return set(self.tags) 114 | 115 | @property 116 | def names_raw(self) -> Set[str]: 117 | """Get a set of names of tags whose body will be kept raw""" 118 | return set(raw for raw in self.raws if self.raws[raw]) 119 | 120 | def parse( 121 | self, env: "Environment", token: "Token", parser: "Parser" 122 | ) -> nodes.Node: 123 | """Calling the parser functions to parse the tags 124 | 125 | Args: 126 | env: The environment 127 | token: The token matches the tag name 128 | parser: The parser 129 | 130 | Returns: 131 | The parsed node 132 | """ 133 | tagname = token.value 134 | if tagname not in self.tags: # pragma: no cover 135 | raise TemplateSyntaxError( 136 | f"Encountered unknown tag '{tagname}'.", 137 | token.lineno, 138 | ) 139 | 140 | if self.envs.get(tagname, False): 141 | return self.tags[tagname](env, token, parser) 142 | return self.tags[tagname](token, parser) 143 | -------------------------------------------------------------------------------- /docs/wild.md: -------------------------------------------------------------------------------- 1 | Wild mode tries to introduce more flexiblities for the template. It's very arbitrary for one to do things inside the template. So security is not it's first priority. 2 | 3 | !!! Warning 4 | 5 | Do not trust any templates in wild mode with `liquidpy` 6 | 7 | Below are some features it supports. 8 | 9 | ## Globals 10 | 11 | - By default, wild mode loads all `__builtins__` as global variables, except those whose names start with `_`. 12 | - `nil` is also loaded and intepreted as `None`. 13 | - Other globals if not overridden by the above: 14 | - See: https://jinja.palletsprojects.com/en/3.0.x/templates/?highlight=builtin%20filters#list-of-global-functions 15 | 16 | ## Filters 17 | 18 | - All builtin functions are loaded as filters, except those whose names starts with `_` and not in: `"copyright", "credits", "input", "help", "globals", "license", "locals", "memoryview", "object", "property", "staticmethod", "super"`. 19 | - Filters from standard mode are loaded 20 | - Builtin jinja filters are enabled if not overridden by the above filters 21 | - See: https://jinja.palletsprojects.com/en/3.0.x/templates/?highlight=builtin%20filters#builtin-filters 22 | - `ifelse`: 23 | - See: https://pwwang.github.io/liquidpy/api/liquid.filters.wild/ 24 | - `map()`: 25 | - It is overridden by python's `builtins.map()`. To use the one from `liquid`, try `liquid_map()` 26 | - `each()`: 27 | - Call a function for each item in an array. With wild mode, you can use the 'map' filter to apply a function to each item in an array. However, this filter is different from the 'map' filter in that it takes the array as the first argument and additional arguments passed to the function are allowed. 28 | 29 | ## Tests 30 | 31 | All jinja tests are supported 32 | 33 | See: https://jinja.palletsprojects.com/en/3.0.x/templates/#builtin-tests 34 | 35 | ## Tags 36 | 37 | `liquidpy` wild mode supports a set of tags that we can do arbitrary things. 38 | 39 | ### `python` tag 40 | 41 | The `python` tag allows you to execute arbitrary code inside a template. It supports single line mode and block mode. 42 | 43 | If you just want execute a single line of python code: 44 | 45 | ```liquid 46 | {% python a = 1 %} 47 | ``` 48 | 49 | Or if you want to execute a chunk of code: 50 | ```liquid 51 | {% python %} 52 | def func(x) 53 | ... 54 | b = func(a) 55 | {% endpython %} 56 | ``` 57 | 58 | !!! Note 59 | 60 | The `python` tag can only interact with the global variables. The variables in the context (`Context.vars`) cannot be referred and will not be affected. 61 | 62 | In the above examples, the first will write variable `a` the `environment.globals` or overwrite it. 63 | The second will use variable `a` in `environment.globals` and then write `b` to it. 64 | 65 | !!! Tip 66 | 67 | Any variables declared at top level of the code gets stored in the `environment.globals`. If you don't want some to be stored, you should delete them using `del` 68 | 69 | !!! Tip 70 | 71 | The code will be dedentated using `textwrap.dedent` and then send to `exec`. So: 72 | ```liquid 73 | {% python %}[space][space]a 74 | [space][space]b 75 | {% endpython %} 76 | ``` 77 | works as expected. But you can also write it like this: 78 | ```liquid 79 | {% python %} 80 | [space][space]a 81 | [space][space]b 82 | {% endpython %} 83 | ``` 84 | The first non-spaced line will be ignored. 85 | 86 | !!! Tip 87 | 88 | You can also print stuff inside the code, which will be parsed as literals. 89 | 90 | ### `import_` and `from_` tags 91 | 92 | The `import_` and `from_` tags help users to import python modules into the `environment.globals`. 93 | It works the same as python's `import` and `from ... import ...` 94 | 95 | !!! Note 96 | 97 | The `import` and `from` from jinja are kept and work as they are in jinja. 98 | 99 | ### `addfilter` tag 100 | 101 | This allows one to add a filter using python code. For example: 102 | 103 | ```liquid 104 | {% addfilter trunc %} 105 | def trunc(string, n): 106 | return string[:n] 107 | {% endaddfilter %} 108 | {{ a | trunc: 3 }} 109 | ``` 110 | When render with `a="abcde"`, it gives: `'abc'` 111 | 112 | Like the `python` tag, you can only use the variables in `environment.globals` inside the code. 113 | But unlike the `python` tag, anything you print inside the code will be ignored. 114 | 115 | You can also define a filter with the environment: 116 | 117 | ```liquid 118 | {% addfilter render pass_env %} 119 | def render(env, expr, **kwargs): 120 | compiled = env.compile_expression(expr) 121 | return compiled(**kwargs) 122 | {% endaddfilter %} 123 | {{ "item | plus(1)" | render: a }} 124 | ``` 125 | 126 | When render with `a=1`, it gives `2`. 127 | 128 | !!! Note 129 | The expresison passed to `env.compile_expression()` has to use the jinja-supported syntax (i.e. using colon to separate filter and its arguments is not supported). 130 | 131 | This is useful when you want to render an template expression insdie the template. 132 | 133 | ## Extensions 134 | 135 | - `jinja2.ext.debug` is enabled 136 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 0.8.6 4 | 5 | - fix: fix for "{{*" when there is no leading white spaces 6 | 7 | ## 0.8.5 8 | 9 | - fix: update playground to use pyscript 2024.1.1 10 | - ci: update action description for clarity 11 | - ci: add Python 3.13 to test matrix 12 | - chore: update pyproject.toml for dev dependencies and Python version support 13 | - test: remove unused import 14 | 15 | ## 0.8.4 16 | 17 | - feat: add 'each' filter in wild mode 18 | 19 | ## 0.8.3 (housekeeping) 20 | 21 | - dep: bump up dependencies 22 | - style: fix code styles in tests 23 | - ci: update actions 24 | - chore(deps): bump jinja2 to 3.1.5 25 | - ci: separate tests for python3.7 26 | 27 | ## 0.8.2 28 | 29 | - deps: bump jinja2 to 3.1.3 30 | 31 | ## 0.8.1 32 | 33 | - 📝 Fix badges in README.md 34 | - 👷 Use latest actions in CI 35 | - 🏗️ Enable automatic setup file creation with Poetry 36 | - ⬆️ Upgrade dependencies to the latest 37 | - ✨ Add `*` modifier for variable block to keep intial indention for multiline strings for all modes 38 | - 📝 Update doc for "indent modifier" 39 | 40 | ## 0.8.0 41 | 42 | - ⬆️ Upgrade deps including markdown, regex, and python-slugify 43 | - 📌 Drop support for python3.6 44 | - 🐛 Fix passing `env` to `Liquid()` not working 45 | 46 | ## 0.7.6 47 | 48 | - 🐛 Fix JSON/list parsing in certain cases (#50) 49 | 50 | ## 0.7.5 51 | 52 | - ✨ Implement a playground powered by pyscript 53 | - ✨ Add filter `call` for `wild` mode 54 | 55 | ## 0.7.4 56 | 57 | - ✅ Add tests regarding #47 58 | - 📌 Upgrade and pin dependencies 59 | 60 | ## 0.7.3 61 | 62 | - 🩹 Make `default` filter work with `None` 63 | - 🩹 Make `attr` filter work with dicts 64 | - 🩹 Use filter `liquid_map`, in wild mode, instead of `map`, which is overridden by python's builtin `map` 65 | 66 | ## 0.7.2 67 | 68 | - 🐛 Fix `date` filter issues (#38, #40) 69 | - ✨ Add `markdownify` for jekyll (#36, #37) 70 | - ✨ Add `number_of_words` for jekyll 71 | - ✨ Add jekyll filter `sort` 72 | - ✨ Add jekyll filter `slugify` 73 | - ✨ Add jekyll filter `array_to_sentence_string` 74 | - ✨ Add jekyll filter `jsonify` 75 | - ✨ Add jekyll filters `xml_escape`, `cgi_escape` and `uri_escape` 76 | - ✨ Add `int`, `float`, `str` and `bool` as both filters and globals for all modes (#40) 77 | 78 | ## 0.7.1 79 | 80 | - ✨ Add `regex_replace` filter 81 | - ✨ Allow absolute path and pathlib.Path passed as template files 82 | - ✨ Allow `+/-` to work with date filter (#38) 83 | - ✨ Add `filters_as_globals` for wild mode (defaults to `True`) 84 | 85 | ## 0.7.0 86 | 87 | - Reimplement using jinja2 88 | 89 | ## 0.6.4 90 | 91 | Last release of 0.6, for compatibilities. 92 | 93 | - Add regex_replace filter (#33) 94 | 95 | ## 0.6.3 96 | 97 | - Allow tag for to have output(test | filter) in python mode. 98 | - Fix stacks not print in some cases. 99 | - Avoid closing stream after parsing 100 | - Add better error message for attribute error while rendering 101 | - Print 'KeyError' for render error if it is a KeyError. 102 | 103 | ## 0.6.2 104 | 105 | - Update dependency versions 106 | 107 | ## 0.6.1 108 | 109 | - Fix use of LiquidPython 110 | - Add getitem and render filter for python mode 111 | - Fix EmptyDrop for variable segment in python mode 112 | - Fix re-rendering error for extends tag (#29) 113 | 114 | ## 0.6.0 115 | 116 | - Remodel the package to use a lexer to scan the nodes first and then lark-parse to parse the tag. 117 | 118 | ## 0.5.0 119 | 120 | - Extract major model of node to allow `register_node` (#18) 121 | - Introduce `config` node and deprecate `mode` 122 | - Allow specification of directories to scan for `include` and `extends` (#19) 123 | - Add loglevel `detail` to enable verbosity between `info` and `debug` 124 | - Allow passing variables to included templates (#8) 125 | - Disallow variables in parent templates to be modified in included templates 126 | - Require backtick ``( ` )`` for liquidpy expression to be used in statement nodes 127 | - Add API documentations 128 | 129 | ## 0.4.0 130 | 131 | - Implement issue #13: Adding ternary end modifier (`$`) 132 | - Expand list/dict context in debug information 133 | 134 | ## 0.3.0 135 | 136 | - Force explict modifiers (=/!) for True/False action in ternary filters 137 | - Add combined ternary filters 138 | - Add shortcut `?` for `?bool` 139 | - Use the maximum lineno on traceback instead of the last one. 140 | 141 | ## 0.2.3 142 | 143 | - Fix parsing errors when unicode in a template loaded from text #10 (thanks to vermeeca) 144 | 145 | ## 0.2.2 146 | 147 | - Show shortened context in debug information 148 | - Fix #9: stream cursor shifted when unicode in the template. 149 | 150 | ## 0.2.1 151 | 152 | - Fix #7: forloop problem with nesting for statements 153 | - Fix other bugs 154 | 155 | ## 0.2.0 156 | 157 | - Add inclusion and inheritance support 158 | - Add `cycle` for `for` loop 159 | 160 | ## 0.1.0 161 | 162 | - Rewrite whole engine using a stream parser 163 | - Support multi-line for statements, expressions and tag comments (#1) 164 | - Support wrapper (instead of a single prefix) for statement comments 165 | - Add `from` and `import` shortcuts to import python modules 166 | - Support expressions in `if/unless/while` statements 167 | - Support `liquid` `forloop` object for `for` statement (#2) 168 | - Improve debug information 169 | - Add arguemtn position specification for filters 170 | - Add tenary filters 171 | - Remove `&` modifiers 172 | 173 | ## 0.0.7 174 | 175 | - Allow `{% mode %}` block to be anywhere in the source code 176 | - Full the coverage 177 | - Change support only for python3.5+ 178 | 179 | ## 0.0.6 180 | 181 | - Add modifiers `&` and `*` to allow chaining and expanding arguments 182 | -------------------------------------------------------------------------------- /liquid/tags/wild.py: -------------------------------------------------------------------------------- 1 | """Provides tags for wild mode""" 2 | import textwrap 3 | from contextlib import redirect_stdout 4 | from io import StringIO 5 | from typing import TYPE_CHECKING, List, Union 6 | 7 | from jinja2 import nodes 8 | from jinja2.exceptions import TemplateSyntaxError 9 | from jinja2.lexer import TOKEN_BLOCK_END 10 | 11 | try: 12 | from jinja2 import pass_environment 13 | except ImportError: 14 | from jinja2 import environmentfilter as pass_environment 15 | 16 | from .manager import TagManager, decode_raw 17 | from .standard import assign, capture, case, comment, cycle 18 | 19 | if TYPE_CHECKING: 20 | from jinja2.lexer import Token 21 | from jinja2.parser import Parser 22 | from jinja2.environment import Environment 23 | 24 | 25 | wild_tags = TagManager() 26 | 27 | wild_tags.register(comment, raw=True) 28 | wild_tags.register(case) 29 | wild_tags.register(capture) 30 | wild_tags.register(assign) 31 | wild_tags.register(cycle) 32 | 33 | 34 | @wild_tags.register(raw=True, env=True) 35 | def python(env: "Environment", token: "Token", parser: "Parser") -> nodes.Node: 36 | """The python tag 37 | 38 | {% python %} ... {% endpython %} or 39 | {% python ... %} 40 | 41 | The globals from the enviornment will be used to evaluate the code 42 | It also affect the globals from the environment 43 | 44 | Args: 45 | env: The environment 46 | token: The token matches the tag name 47 | parser: The parser 48 | 49 | Returns: 50 | The parsed node 51 | """ 52 | if parser.stream.current.type is TOKEN_BLOCK_END: 53 | # expect {% endpython %} 54 | body = parser.parse_statements(("name:endpython",), drop_needle=True) 55 | body = decode_raw(body[0].nodes[0].data) 56 | body_parts = body.split("\n", 1) 57 | if not body_parts[0]: 58 | body = "" if len(body_parts) < 2 else body_parts[1] 59 | body = textwrap.dedent(body) 60 | else: 61 | pieces: List[str] = [] 62 | pieces_append = pieces.append 63 | while True: 64 | token = next(parser.stream) 65 | pieces_append(str(token.value)) 66 | if parser.stream.current.type is TOKEN_BLOCK_END: 67 | break 68 | 69 | body = " ".join(pieces) 70 | 71 | code = compile(body, "", mode="exec") 72 | out = StringIO() 73 | with redirect_stdout(out): 74 | exec(code, env.globals) 75 | return nodes.Output([nodes.Const(out.getvalue())], lineno=token.lineno) 76 | 77 | 78 | @wild_tags.register(env=True) 79 | def import_( 80 | env: "Environment", token: "Token", parser: "Parser" 81 | ) -> nodes.Node: 82 | """The import_ tag {% import_ ... %} 83 | 84 | Name it 'import_' so the 'import' tag from jinja can still work 85 | 86 | Args: 87 | env: The environment 88 | token: The token matches tag name 89 | parser: The parser 90 | 91 | Returns: 92 | The parsed node 93 | """ 94 | pieces = ["import"] 95 | pieces_append = pieces.append 96 | while True: 97 | token = next(parser.stream) 98 | pieces_append(str(token.value)) 99 | if parser.stream.current.type is TOKEN_BLOCK_END: 100 | break 101 | body = " ".join(pieces) 102 | code = compile(body, "", mode="exec") 103 | exec(code, env.globals) 104 | return nodes.Output([], lineno=token.lineno) 105 | 106 | 107 | @wild_tags.register(env=True) 108 | def from_(env: "Environment", token: "Token", parser: "Parser") -> nodes.Node: 109 | """The from_ tag {% from_ ... %} 110 | 111 | Name it 'from_' so the 'from_' tag from jinja can still work 112 | 113 | Args: 114 | env: The environment 115 | token: The token matches tag name 116 | parser: The parser 117 | 118 | Returns: 119 | The parsed node 120 | """ 121 | pieces = ["from"] 122 | pieces_append = pieces.append 123 | while True: 124 | token = next(parser.stream) 125 | pieces_append(str(token.value)) 126 | if parser.stream.current.type is TOKEN_BLOCK_END: 127 | break 128 | body = " ".join(pieces) 129 | code = compile(body, "", mode="exec") 130 | exec(code, env.globals) 131 | return nodes.Output([], lineno=token.lineno) 132 | 133 | 134 | @wild_tags.register(env=True, raw=True) 135 | def addfilter( 136 | env: "Environment", token: "Token", parser: "Parser" 137 | ) -> nodes.Node: 138 | """The addfilter tag {% addfilter name ... %} ... {% endaddfilter %} 139 | 140 | This allows one to use the python code inside the body to add a filter or 141 | replace an existing filter 142 | 143 | Args: 144 | env: The environment 145 | token: The token matches tag name 146 | parser: The parser 147 | 148 | Returns: 149 | The parsed node 150 | """ 151 | token = parser.stream.expect("name") 152 | filtername = token.value 153 | 154 | pass_env: Union[bool, Token] 155 | if parser.stream.current.type is TOKEN_BLOCK_END: 156 | # no pass_environment 157 | pass_env = False 158 | else: 159 | pass_env = parser.stream.expect("name:pass_env") 160 | 161 | body = parser.parse_statements(("name:endaddfilter",), drop_needle=True) 162 | body = decode_raw(body[0].nodes[0].data) 163 | body_parts = body.split("\n", 1) 164 | if not body_parts[0]: 165 | body = "" if len(body_parts) < 2 else body_parts[1] 166 | body = textwrap.dedent(body) 167 | 168 | globs = env.globals.copy() 169 | code = compile(body, "", mode="exec") 170 | exec(code, globs) 171 | try: 172 | filterfunc = globs[filtername] 173 | except KeyError: 174 | raise TemplateSyntaxError( 175 | f"No such filter defined in 'addfilter': {filtername}", 176 | token.lineno, 177 | ) from None 178 | 179 | if pass_env: 180 | filterfunc = pass_environment(filterfunc) # type: ignore 181 | env.filters[filtername] = filterfunc 182 | 183 | return nodes.Output([], lineno=token.lineno) 184 | -------------------------------------------------------------------------------- /tests/standard/test_basics.py: -------------------------------------------------------------------------------- 1 | """Tests grabbed from: 2 | https://shopify.github.io/liquid/basics 3 | """ 4 | import pytest # noqa: F401 5 | 6 | from liquid import Liquid, unpatch_jinja # noqa: F401 7 | 8 | 9 | def test_introduction(set_default_standard): 10 | assert ( 11 | Liquid("{{page.title}}").render(page={"title": "Introduction"}) 12 | == "Introduction" 13 | ) 14 | 15 | 16 | def test_tags(set_default_standard): 17 | assert ( 18 | Liquid( 19 | """ 20 | {%- if user -%} 21 | Hello {{ user.name }}! 22 | {%- endif -%} 23 | """ 24 | ).render(user={"name": "Adam"}) 25 | == "Hello Adam!" 26 | ) 27 | 28 | 29 | def test_filters(set_default_standard): 30 | assert Liquid('{{ "/my/fancy/url" | append: ".html" }}').render() == ( 31 | "/my/fancy/url.html" 32 | ) 33 | 34 | assert Liquid( 35 | '{{ "adam!" | capitalize | prepend: "Hello " }}' 36 | ).render() == ("Hello Adam!") 37 | # test keyword argument 38 | assert Liquid( 39 | '{{ "hello adam!" | replace_first: "adam", new:"Adam" | remove: "hello "}}' 40 | ).render() == ("Adam!") 41 | 42 | 43 | def test_operators(set_default_standard): 44 | tpl = """ 45 | {%- if product.title == "Awesome Shoes" -%} 46 | These shoes are awesome! 47 | {%- endif -%} 48 | """ 49 | assert Liquid(tpl).render(product={"title": "Awesome Shoes"}) == ( 50 | "These shoes are awesome!" 51 | ) 52 | 53 | tpl = """ 54 | {%- if product.type == "Shirt" or product.type == "Shoes" -%} 55 | This is a shirt or a pair of shoes. 56 | {%- endif -%} 57 | """ 58 | assert Liquid(tpl).render(product={"type": "Shirt"}) == ( 59 | "This is a shirt or a pair of shoes." 60 | ) 61 | 62 | tpl = """ 63 | {%- if product.title contains "Pack" -%} 64 | This product's title contains the word Pack. 65 | {%- endif -%} 66 | """ 67 | assert Liquid(tpl).render(product={"title": "Show Pack"}) == ( 68 | "This product's title contains the word Pack." 69 | ) 70 | 71 | tpl = """ 72 | {%- if product.tags contains "Hello" -%} 73 | This product has been tagged with "Hello". 74 | {%- endif -%} 75 | """ 76 | assert Liquid(tpl).render(product={"tags": "Hello Pack"}) == ( 77 | 'This product has been tagged with "Hello".' 78 | ) 79 | 80 | 81 | def test_or_and(set_default_standard): 82 | tpl = """ 83 | {%- if true or false and false -%} 84 | This evaluates to true, since the `and` condition is checked first. 85 | {%- endif -%} 86 | """ 87 | assert Liquid(tpl).render() == ( 88 | "This evaluates to true, since the `and` condition is checked first." 89 | ) 90 | 91 | 92 | # This is a different behavior than in standard liquid 93 | def test_multiple_and_or(set_default_standard): 94 | # tpl = """ 95 | # {%- if true and false and false or true %} 96 | # This evaluates to false, since the tags are checked like this: 97 | 98 | # true and (false and (false or true)) 99 | # true and (false and true) 100 | # true and false 101 | # false 102 | # {% endif -%} 103 | # """ 104 | # assert Liquid(tpl).render() == "" 105 | 106 | tpl = """ 107 | {%- if true and false and true or false %} 108 | This evaluates to false, since the tags are checked like this: 109 | 110 | (true and false) and true or false 111 | (false and true) or false 112 | (false or false) 113 | false 114 | {% endif -%} 115 | """ 116 | assert Liquid(tpl).render() == "" 117 | 118 | 119 | def test_truthy_falsy(set_default_standard): 120 | tpl = """ 121 | {% assign tobi = "Tobi" %} 122 | 123 | {% if tobi %} 124 | This condition will always be true. 125 | {% endif %} 126 | """ 127 | assert Liquid(tpl).render().strip() == "This condition will always be true." 128 | 129 | tpl = """ 130 | {% if settings.fp_heading %} 131 |

{{ settings.fp_heading }}

132 | {% endif %} 133 | """ 134 | assert ( 135 | # Liquid(tpl).render(settings={"fp_heading": ""}).strip() == "

" 136 | # "" tests false in python, instead of true in liquid 137 | Liquid(tpl).render(settings={"fp_heading": " "}).strip() == "

" 138 | ) 139 | 140 | 141 | def test_types(set_default_standard): 142 | tpl = "The current user is {{ user.name }}" 143 | assert Liquid(tpl).render(user={}) == "The current user is " 144 | 145 | tpl = """ 146 | {%- for user in site.users %} {{ user }} 147 | {%- endfor -%} 148 | """ 149 | assert ( 150 | Liquid(tpl).render(site={"users": ["Tobi", "Laura", "Tetsuro", "Adam"]}) 151 | == " Tobi Laura Tetsuro Adam" 152 | ) 153 | 154 | tpl = "{{ site.users[0] }} {{ site.users[1] }} {{ site.users[3] }}" 155 | assert ( 156 | Liquid(tpl).render(site={"users": ["Tobi", "Laura", "Tetsuro", "Adam"]}) 157 | == "Tobi Laura Adam" 158 | ) 159 | 160 | 161 | def test_wscontrol(set_default_standard): 162 | tpl = """ 163 | {% assign my_variable = "tomato" %} 164 | {{ my_variable }}""" 165 | 166 | assert Liquid(tpl).render() == "\n\ntomato" 167 | tpl = """ 168 | {%- assign my_variable = "tomato" -%} 169 | {{ my_variable }}""" 170 | 171 | assert Liquid(tpl).render() == "tomato" 172 | 173 | tpl = """{% assign username = "John G. Chalmers-Smith" %} 174 | {% if username and username.size > 10 %} 175 | Wow, {{ username }}, you have a long name! 176 | {% else %} 177 | Hello there! 178 | {% endif %}""" 179 | assert ( 180 | Liquid(tpl).render() 181 | == "\n\n Wow, John G. Chalmers-Smith, you have a long name!\n" 182 | ) 183 | 184 | tpl = """{%- assign username = "John G. Chalmers-Smith" -%} 185 | {%- if username and username.size > 10 -%} 186 | Wow, {{ username }}, you have a long name! 187 | {%- else -%} 188 | Hello there! 189 | {%- endif -%}""" 190 | assert ( 191 | Liquid(tpl).render() 192 | == "Wow, John G. Chalmers-Smith, you have a long name!" 193 | ) 194 | 195 | 196 | def test_indention_keeping(set_default_standard): 197 | tpl = """ 198 | 1 199 | {{* var }} 200 | 2 201 | """ 202 | out = Liquid(tpl).render({"var": "a\n b"}) 203 | assert out == """ 204 | 1 205 | a 206 | b 207 | 2 208 | """ 209 | 210 | 211 | def test_indention_keeping_without_leading_space(set_default_standard): 212 | tpl = """{{* var }}""" 213 | out = Liquid(tpl).render({"var": "a\n b"}) 214 | assert out == """a\n b""" 215 | -------------------------------------------------------------------------------- /tests/jekyll/test_jekyll_filters.py: -------------------------------------------------------------------------------- 1 | import pytest # noqa: F401 2 | from liquid import Liquid 3 | 4 | 5 | def test_relative_url(set_default_jekyll): 6 | with pytest.raises(ValueError, match="Global variables"): 7 | Liquid('{{"b/c" | relative_url}}').render() 8 | 9 | out = Liquid( 10 | '{{"b/c" | relative_url}}', globals={"site": {"baseurl": "/a"}} 11 | ).render() 12 | assert out == "/a/b/c" 13 | 14 | 15 | def test_find(set_default_jekyll): 16 | liq = Liquid('{{ obj | find: "a", 1 }}') 17 | out = liq.render( 18 | obj=[ 19 | {"a": 1, "b": 2}, 20 | {"a": 2, "b": 4}, 21 | ] 22 | ) 23 | assert out == "{'a': 1, 'b': 2}" 24 | 25 | out = liq.render(obj=[]) 26 | assert out == "None" 27 | 28 | out = liq.render(obj=[{}]) 29 | assert out == "None" 30 | 31 | 32 | def test_normalize_whitespace(set_default_jekyll): 33 | assert Liquid('{{"a b" | normalize_whitespace}}').render() == "a b" 34 | 35 | 36 | def test_sample(set_default_jekyll): 37 | assert Liquid("{{arr | sample | first}}").render(arr=[1, 2, 3], n=1) in [ 38 | "1", 39 | "2", 40 | "3", 41 | ] 42 | 43 | 44 | def test_markdownify(set_default_jekyll): 45 | assert Liquid("{{ '# a' | markdownify }}").render() == "

a

" 46 | 47 | 48 | @pytest.mark.parametrize( 49 | "sen,mode,out", 50 | [ 51 | ("Hello world!", None, 2), 52 | ("hello world and taoky strong!", "cjk", 5), 53 | ("hello world and taoky strong!", "auto", 5), 54 | ("こんにちは、世界!안녕하세요 세상!", "cjk", 17), 55 | ("こんにちは、世界!안녕하세요 세상!", "auto", 17), 56 | ("你好hello世界world", None, 1), 57 | ("你好hello世界world", "cjk", 6), 58 | ("你好hello世界world", "auto", 6), 59 | ], 60 | ) 61 | def test_number_of_words(sen, mode, out, set_default_jekyll): 62 | if mode is None: 63 | tpl = f"{{{{ {sen!r} | number_of_words }}}}" 64 | else: 65 | tpl = f"{{{{ {sen!r} | number_of_words: {mode!r} }}}}" 66 | 67 | assert Liquid(tpl).render() == str(out) 68 | 69 | 70 | def test_sort_error(set_default_jekyll): 71 | tpl = Liquid("{{ x | sort }}") 72 | with pytest.raises(ValueError): 73 | tpl.render(x=None) 74 | 75 | tpl = Liquid("{{ x | sort: p, n }}") 76 | with pytest.raises(ValueError): 77 | tpl.render(x=[], p=None, n=None) 78 | 79 | 80 | @pytest.mark.parametrize( 81 | "array, prop, none_pos, out", 82 | [ 83 | ([10, 2], None, "first", [10, 2]), 84 | ([None, 10, 2], None, "first", [None, 10, 2]), 85 | ([None, 10, 2], None, "last", [10, 2, None]), 86 | (["FOO", "Foo", "foo"], None, "first", ["foo", "Foo", "FOO"]), 87 | # acts differently then ruby 88 | # (["_foo", "foo", "foo_"], None, "first", ["foo_", "_foo", "foo"]), 89 | (["_foo", "foo", "foo_"], None, "first", ["foo_", "foo", "_foo"]), 90 | # (["ВУЗ", "Вуз", "вуз"], None, "first", ["Вуз", "вуз", "ВУЗ"]), 91 | (["ВУЗ", "Вуз", "вуз"], None, "first", ["вуз", "Вуз", "ВУЗ"]), 92 | (["_вуз", "вуз", "вуз_"], None, "first", ["вуз_", "вуз", "_вуз"]), 93 | (["אלף", "בית"], None, "first", ["בית", "אלף"]), 94 | ( 95 | [{"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}], 96 | "a", 97 | "first", 98 | [{"a": 4}, {"a": 3}, {"a": 2}, {"a": 1}], 99 | ), 100 | ( 101 | [{"a": ".5"}, {"a": "0.65"}, {"a": "10"}], 102 | "a", 103 | "first", 104 | [{"a": "10"}, {"a": "0.65"}, {"a": ".5"}], 105 | ), 106 | ( 107 | [{"a": ".5"}, {"a": "0.6"}, {"a": "twelve"}], 108 | "a", 109 | "first", 110 | [{"a": "twelve"}, {"a": "0.6"}, {"a": ".5"}], 111 | ), 112 | ( 113 | [{"a": "1"}, {"a": "1abc"}, {"a": "20"}], 114 | "a", 115 | "first", 116 | [{"a": "20"}, {"a": "1abc"}, {"a": "1"}], 117 | ), 118 | ( 119 | [{"a": 2}, {"b": 1}, {"a": 1}], 120 | "a", 121 | "first", 122 | [{"b": 1}, {"a": 2}, {"a": 1}], 123 | ), 124 | ( 125 | [{"a": 2}, {"b": 1}, {"a": 1}], 126 | "a", 127 | "last", 128 | [{"a": 2}, {"a": 1}, {"b": 1}], 129 | ), 130 | ( 131 | [{"a": {"b": 1}}, {"a": {"b": 2}}, {"a": {"b": 3}}], 132 | "a.b", 133 | "first", 134 | [{"a": {"b": 3}}, {"a": {"b": 2}}, {"a": {"b": 1}}], 135 | ), 136 | ], 137 | ) 138 | def test_sort(array, prop, none_pos, out, set_default_jekyll): 139 | assert Liquid("{{x | sort: p, n}}").render( 140 | x=array, p=prop, n=none_pos 141 | ) == str(out) 142 | 143 | 144 | @pytest.mark.parametrize( 145 | "tpl, out", 146 | [ 147 | ("{{ None | slugify }}", None), 148 | ('{{ " Q*bert says @!#?@!" | slugify }}', "q-bert-says"), 149 | ( 150 | '{{ " Q*bert says @!#?@!" | slugify: "pretty" }}', 151 | "q-bert-says-@!-@!", 152 | ), 153 | ('{{ "The _config.yml file" | slugify }}', "the-config-yml-file"), 154 | ( 155 | '{{ "The _config.yml file" | slugify: "default" }}', 156 | "the-config-yml-file", 157 | ), 158 | ( 159 | '{{ "The _config.yml file" | slugify: "pretty" }}', 160 | "the-_config.yml-file", 161 | ), 162 | ( 163 | '{{ "The _config.yml file" | slugify: "raw" }}', 164 | "the-_config.yml-file", 165 | ), 166 | ( 167 | '{{ "The _cönfig.yml file" | slugify: "ascii" }}', 168 | "the-config-yml-file", 169 | ), 170 | ( 171 | '{{ "The cönfig.yml file" | slugify: "latin" }}', 172 | "the-config-yml-file", 173 | ), 174 | ], 175 | ) 176 | def test_slugify(tpl, out, set_default_jekyll): 177 | assert Liquid(tpl).render() == str(out) 178 | 179 | 180 | @pytest.mark.parametrize( 181 | "array, connector, out", 182 | [ 183 | ([], "and", ""), 184 | ([1], "and", "1"), 185 | (["chunky"], "and", "chunky"), 186 | ([1, 2], "and", "1 and 2"), 187 | ([1, 2], "or", "1 or 2"), 188 | (["chunky", "bacon"], "and", "chunky and bacon"), 189 | ([1, 2, 3, 4], "and", "1, 2, 3, and 4"), 190 | ([1, 2, 3, 4], "or", "1, 2, 3, or 4"), 191 | ( 192 | ["chunky", "bacon", "bits", "pieces"], 193 | "and", 194 | "chunky, bacon, bits, and pieces", 195 | ), 196 | ], 197 | ) 198 | def test_array_to_sentence_string(array, connector, out, set_default_jekyll): 199 | assert ( 200 | Liquid("{{ x | array_to_sentence_string: c }}").render( 201 | x=array, 202 | c=connector, 203 | ) 204 | == out 205 | ) 206 | 207 | 208 | @pytest.mark.parametrize( 209 | "obj, out", 210 | [ 211 | ({"age": 18}, '{"age": 18}'), 212 | ([1, 2], "[1, 2]"), 213 | ( 214 | [{"name": "Jack"}, {"name": "Smith"}], 215 | '[{"name": "Jack"}, {"name": "Smith"}]', 216 | ), 217 | ], 218 | ) 219 | def test_jsonify(obj, out, set_default_jekyll): 220 | assert Liquid("{{ x | jsonify }}").render(x=obj) == out 221 | 222 | 223 | def test_xml_escape(set_default_jekyll): 224 | assert Liquid("{{ x | xml_escape }}").render(x=None) == "" 225 | assert Liquid("{{ x | xml_escape }}").render(x="AT&T") == "AT&T" 226 | assert ( 227 | Liquid("{{ x | xml_escape }}").render( 228 | x="command <filename>" 229 | ) 230 | == "<code>command &lt;filename&gt;</code>" 231 | ) 232 | 233 | 234 | def test_cgi_escape(set_default_jekyll): 235 | assert Liquid("{{ x | cgi_escape }}").render(x="my things") == "my+things" 236 | assert Liquid("{{ x | cgi_escape }}").render(x="hey!") == "hey%21" 237 | assert ( 238 | Liquid("{{ x | cgi_escape }}").render(x="foo, bar; baz?") 239 | == "foo%2C+bar%3B+baz%3F" 240 | ) 241 | 242 | 243 | def test_uri_escape(set_default_jekyll): 244 | assert ( 245 | Liquid("{{ x | uri_escape }}").render(x="my things") == "my%20things" 246 | ) 247 | assert ( 248 | Liquid("{{ x | uri_escape }}").render(x="foo!*'();:@&=+$,/?#[]bar") 249 | == "foo!*'();:@&=+$,/?#[]bar" 250 | ) 251 | assert ( 252 | Liquid("{{ x | uri_escape }}").render(x="foo bar!*'();:@&=+$,/?#[]baz") 253 | == "foo%20bar!*'();:@&=+$,/?#[]baz" 254 | ) 255 | assert ( 256 | Liquid("{{ x | uri_escape }}").render(x="http://foo.com/?q=foo, \\bar?") 257 | == "http://foo.com/?q=foo,%20%5Cbar?" 258 | ) 259 | -------------------------------------------------------------------------------- /liquid/liquid.py: -------------------------------------------------------------------------------- 1 | """Provides Liquid class""" 2 | import builtins 3 | from typing import Any, Callable, Mapping 4 | from jinja2 import ( 5 | Environment, 6 | ChoiceLoader, 7 | FileSystemLoader, 8 | ) 9 | 10 | from .filters.standard import standard_filter_manager 11 | from .utils import PathType, PathTypeOrIter 12 | 13 | 14 | class Liquid: 15 | """The entrance for the package 16 | 17 | Examples: 18 | >>> Liquid('{{a}}', from_file=False) 19 | >>> Liquid('template.html') 20 | 21 | Args: 22 | template: The template string or path of the template file 23 | env: The jinja environment 24 | from_file: Whether `template` is a file path. If True, a 25 | `FileSystemLoader` will be used in the `env`. 26 | mode: The mode of the engine. 27 | - standard: Most compatibility with the standard liquid engine 28 | - jekyll: The jekyll-compatible mode 29 | - shopify: The shopify-compatible mode 30 | - wild: The liquid- and jinja-compatible mode 31 | filter_with_colon: Whether enable to use colon to separate filter and 32 | its arguments (i.e. `{{a | filter: arg}}`). If False, will 33 | fallback to use parentheses (`{{a | filter(arg)}}`) 34 | search_paths: The search paths for the template files. 35 | This only supports specification of paths. If you need so specify 36 | `encoding` and/or `followlinks`, you should use jinja's 37 | `FileSystemLoader` 38 | globals: Additional global values to be used to render the template 39 | filters: Additional filters be to used to render the template 40 | filters_as_globals: Whether also use filters as globals 41 | Only works in wild mode 42 | **kwargs: Other arguments for an jinja Environment construction and 43 | configurations for extensions 44 | """ 45 | 46 | __slots__ = ("env", "template") 47 | 48 | def __init__( 49 | self, 50 | template: PathType, 51 | from_file: bool = None, 52 | mode: str = None, 53 | env: Environment = None, 54 | filter_with_colon: bool = None, 55 | search_paths: PathTypeOrIter = None, 56 | globals: Mapping[str, Any] = None, 57 | filters: Mapping[str, Callable] = None, 58 | filters_as_globals: bool = None, 59 | **kwargs: Any, 60 | ) -> None: 61 | """Constructor""" 62 | # default values 63 | # fetch at runtime, so that they can be configured at importing 64 | from .defaults import ( 65 | FROM_FILE, 66 | MODE, 67 | FILTER_WITH_COLON, 68 | SEARCH_PATHS, 69 | ENV_ARGS, 70 | SHARED_GLOBALS, 71 | FILTERS_AS_GLOBALS, 72 | ) 73 | 74 | if from_file is None: 75 | from_file = FROM_FILE 76 | if mode is None: 77 | mode = MODE 78 | if filter_with_colon is None: 79 | filter_with_colon = FILTER_WITH_COLON 80 | if search_paths is None: 81 | search_paths = SEARCH_PATHS 82 | if filters_as_globals is None: 83 | filters_as_globals = FILTERS_AS_GLOBALS 84 | 85 | # split kwargs into arguments for Environment constructor and 86 | # configurations for extensions 87 | env_args = {} 88 | ext_conf = {} 89 | for key, val in kwargs.items(): 90 | if key in ENV_ARGS: 91 | env_args[key] = val 92 | else: 93 | ext_conf[key] = val 94 | 95 | loader = env_args.pop("loader", None) 96 | fsloader = FileSystemLoader(search_paths) # type: ignore 97 | if loader: 98 | loader = ChoiceLoader([loader, fsloader]) 99 | else: 100 | loader = fsloader 101 | 102 | self.env = Environment(**env_args, loader=loader) 103 | if env is not None: 104 | self.env = env.overlay(**env_args, loader=loader) 105 | 106 | self.env.extend(**ext_conf) 107 | self.env.globals.update(SHARED_GLOBALS) 108 | 109 | standard_filter_manager.update_to_env(self.env) 110 | self.env.add_extension("jinja2.ext.loopcontrols") 111 | if filter_with_colon: 112 | from .exts.filter_colon import FilterColonExtension 113 | 114 | self.env.add_extension(FilterColonExtension) 115 | 116 | if mode == "wild": 117 | from .exts.wild import LiquidWildExtension 118 | from .filters.wild import wild_filter_manager 119 | 120 | self.env.add_extension("jinja2.ext.debug") 121 | self.env.add_extension(LiquidWildExtension) 122 | 123 | bfilters = { 124 | key: getattr(builtins, key) 125 | for key in dir(builtins) 126 | if not key.startswith("_") 127 | and callable(getattr(builtins, key)) 128 | and key 129 | not in ( 130 | "copyright", 131 | "credits", 132 | "input", 133 | "help", 134 | "globals", 135 | "license", 136 | "locals", 137 | "memoryview", 138 | "object", 139 | "property", 140 | "staticmethod", 141 | "super", 142 | ) 143 | and not any(key_c.isupper() for key_c in key) 144 | } 145 | self.env.filters.update(bfilters) 146 | wild_filter_manager.update_to_env(self.env) 147 | self.env.globals.update( 148 | { 149 | key: val 150 | for key, val in __builtins__.items() 151 | if not key.startswith("_") 152 | } 153 | ) 154 | if filters_as_globals: 155 | self.env.globals.update(standard_filter_manager.filters) 156 | self.env.globals.update(wild_filter_manager.filters) 157 | 158 | elif mode == "jekyll": 159 | from .exts.front_matter import FrontMatterExtension 160 | from .exts.jekyll import LiquidJekyllExtension 161 | from .filters.jekyll import jekyll_filter_manager 162 | 163 | jekyll_filter_manager.update_to_env(self.env) 164 | self.env.add_extension(FrontMatterExtension) 165 | self.env.add_extension(LiquidJekyllExtension) 166 | 167 | elif mode == "shopify": 168 | from .exts.shopify import LiquidShopifyExtension 169 | from .filters.shopify import shopify_filter_manager 170 | 171 | shopify_filter_manager.update_to_env(self.env) 172 | self.env.add_extension(LiquidShopifyExtension) 173 | 174 | else: # standard 175 | from .exts.standard import LiquidStandardExtension 176 | 177 | self.env.add_extension(LiquidStandardExtension) 178 | 179 | if filters: 180 | self.env.filters.update(filters) 181 | 182 | builtin_globals = { 183 | "int": int, 184 | "float": float, 185 | "str": str, 186 | "bool": bool 187 | } 188 | if globals: 189 | builtin_globals.update(globals) 190 | self.env.globals.update(builtin_globals) 191 | 192 | if from_file: 193 | # in case template is a PathLike 194 | self.template = self.env.get_template(str(template)) 195 | else: 196 | self.template = self.env.from_string(str(template)) 197 | 198 | def render(self, *args, **kwargs) -> Any: 199 | """Render the template. 200 | 201 | You can either pass the values using `tpl.render(a=1)` or 202 | `tpl.render({'a': 1})` 203 | """ 204 | return self.template.render(*args, **kwargs) 205 | 206 | async def render_async(self, *args, **kwargs) -> Any: 207 | """Asynchronously render the template""" 208 | return await self.template.render_async(*args, **kwargs) 209 | 210 | @classmethod 211 | def from_env( 212 | cls, 213 | template: PathType, 214 | env: Environment, 215 | from_file: bool = None, 216 | filter_with_colon: bool = None, 217 | filters_as_globals: bool = None, 218 | mode: str = None, 219 | ) -> "Liquid": 220 | """Initiate a template from a jinja environment 221 | 222 | You should not specify any liquid-related extensions here. They will 223 | be added automatically. 224 | 225 | No search path is allow to be passed here. Instead, use jinja2's 226 | loaders or use the constructor to initialize a template. 227 | 228 | @Args: 229 | template: The template string or path of the template file 230 | env: The jinja environment 231 | from_file: Whether `template` is a file path. If True, a 232 | `FileSystemLoader` will be used in the `env`. 233 | filter_with_colon: Whether enable to use colon to separate filter 234 | and its arguments (i.e. `{{a | filter: arg}}`). If False, will 235 | fallback to use parentheses (`{{a | filter(arg)}}`) 236 | filters_as_globals: Whether also use filters as globals 237 | Only works in wild mode 238 | mode: The mode of the engine. 239 | - standard: Most compatibility with the standard liquid engine 240 | - wild: The liquid- and jinja-compatible mode 241 | - jekyll: The jekyll-compatible mode 242 | 243 | @Returns: 244 | A `Liquid` object 245 | """ 246 | return cls( 247 | template, 248 | env=env, 249 | from_file=from_file, 250 | filter_with_colon=filter_with_colon, 251 | filters_as_globals=filters_as_globals, 252 | mode=mode, 253 | ) 254 | -------------------------------------------------------------------------------- /liquid/filters/jekyll.py: -------------------------------------------------------------------------------- 1 | """Provides jekyll filters 2 | See: https://jekyllrb.com/docs/liquid/filters/ 3 | """ 4 | import datetime 5 | import os 6 | import random 7 | import re 8 | import urllib.parse 9 | from typing import TYPE_CHECKING, Any, Sequence 10 | 11 | if TYPE_CHECKING: 12 | from jinja2.environment import Environment 13 | 14 | 15 | # environmentfilter deprecated 16 | try: 17 | from jinja2 import pass_environment 18 | except ImportError: 19 | from jinja2 import environmentfilter as pass_environment 20 | 21 | from jinja2.filters import FILTERS 22 | 23 | from .manager import FilterManager 24 | 25 | jekyll_filter_manager = FilterManager() 26 | 27 | 28 | def _getattr(obj: Any, attr: str) -> Any: 29 | """Get attribute of an object, if fails, try get item""" 30 | try: 31 | return getattr(obj, attr) 32 | except AttributeError: 33 | return obj[attr] 34 | 35 | 36 | def _getattr_multi(obj: Any, attr: str) -> Any: 37 | """Get attribute of an object at multiple levels 38 | 39 | For example: x.a.b = 1, _getattr_multi(x, "a.b") == 1 40 | """ 41 | attrs = attr.split(".") 42 | for att in attrs: 43 | try: 44 | obj = _getattr(obj, att) 45 | except (TypeError, KeyError): 46 | obj = None 47 | return obj 48 | 49 | 50 | def _get_global_var(env: "Environment", name: str, attr: str = None) -> Any: 51 | if name not in env.globals: 52 | raise ValueError(f"Global variables has not been set: {name}") 53 | 54 | out = env.globals[name] 55 | if attr is None: # pragma: no cover 56 | return out 57 | 58 | return _getattr(out, attr) 59 | 60 | 61 | jekyll_filter_manager.register("group_by")(FILTERS["groupby"]) 62 | jekyll_filter_manager.register("to_integer")(FILTERS["int"]) 63 | jekyll_filter_manager.register("inspect")(repr) 64 | 65 | 66 | @jekyll_filter_manager.register 67 | @pass_environment 68 | def relative_url(env, value): 69 | """Get relative url based on site.baseurl""" 70 | baseurl = _get_global_var(env, "site", "baseurl") 71 | parts = urllib.parse.urlparse(baseurl) 72 | return os.path.join(parts.path, value) 73 | 74 | 75 | @jekyll_filter_manager.register 76 | @pass_environment 77 | def absolute_url(env, value): 78 | """Get absolute url based on site.baseurl""" 79 | baseurl = _get_global_var(env, "site", "baseurl") 80 | return urllib.parse.urljoin(baseurl, value) 81 | 82 | 83 | @jekyll_filter_manager.register 84 | @pass_environment 85 | def date_to_xmlschema(env, value: datetime.datetime): 86 | """Convert date to xml schema format""" 87 | return value.isoformat() 88 | 89 | 90 | # TODO: other date filters 91 | 92 | 93 | @jekyll_filter_manager.register 94 | @pass_environment 95 | def where_exp(env, value, item, expr): 96 | """Where using expression""" 97 | compiled = env.compile_expression(expr) 98 | return [itm for itm in value if compiled(**{item: itm})] 99 | 100 | 101 | @jekyll_filter_manager.register 102 | def find(value, attr, query): 103 | """Find elements from array using attribute value""" 104 | for item in value: 105 | try: 106 | if _getattr(item, attr) == query: 107 | return item 108 | except (KeyError, AttributeError): 109 | continue 110 | return None 111 | 112 | 113 | @jekyll_filter_manager.register 114 | @pass_environment 115 | def find_exp(env, value, item, expr): 116 | """Find elements using expression""" 117 | compiled = env.compile_expression(expr) 118 | for itm in value: 119 | try: 120 | test = compiled(**{item: itm}) 121 | except AttributeError: 122 | continue 123 | if test: 124 | return itm 125 | return None 126 | 127 | 128 | @jekyll_filter_manager.register 129 | @pass_environment 130 | def group_by_expr(env, value, item, expr): 131 | """Group by data using expression""" 132 | compiled = env.compile_expression(expr) 133 | out = {} 134 | for itm in value: 135 | name = compiled(**{item: itm}) 136 | out.setdefault(name, []).append(itm) 137 | return [{name: name, items: items} for name, items in out.items()] 138 | 139 | 140 | @jekyll_filter_manager.register 141 | def xml_escape(input: str) -> str: 142 | """Convert an object into its String representation 143 | 144 | Args: 145 | input: The object to be converted 146 | 147 | Returns: 148 | The converted string 149 | """ 150 | if input is None: 151 | return "" 152 | 153 | from xml.sax.saxutils import escape 154 | return escape(input) 155 | 156 | 157 | @jekyll_filter_manager.register 158 | def cgi_escape(input: str) -> str: 159 | """CGI escape a string for use in a URL. Replaces any special characters 160 | with appropriate %XX replacements. 161 | 162 | Args: 163 | input: The string to escape 164 | 165 | Returns: 166 | The escaped string 167 | """ 168 | return urllib.parse.quote_plus(input) 169 | 170 | 171 | @jekyll_filter_manager.register 172 | def uri_escape(input: str) -> str: 173 | """URI escape a string. 174 | 175 | Args: 176 | input: The string to escape 177 | 178 | Returns: 179 | The escaped string 180 | """ 181 | return urllib.parse.quote(input, safe="!*'();:@&=+$,/?#[]") 182 | 183 | 184 | # TODO: smartify, sassify, scssify 185 | 186 | 187 | @jekyll_filter_manager.register 188 | def jsonify(input: Any) -> str: 189 | """Convert the input into json string 190 | 191 | Args: 192 | input: The Array or Hash to be converted 193 | 194 | Returns: 195 | The converted json string 196 | """ 197 | import json 198 | return json.dumps(input) 199 | 200 | 201 | @jekyll_filter_manager.register 202 | def array_to_sentence_string( 203 | array: Sequence[str], 204 | connector: str = "and", 205 | ) -> str: 206 | """Join an array of things into a string by separating with commas and the 207 | word "and" for the last one. 208 | 209 | Args: 210 | array: The Array of Strings to join. 211 | connector: Word used to connect the last 2 items in the array 212 | 213 | Returns: 214 | The formatted string. 215 | """ 216 | if len(array) == 0: 217 | return "" 218 | 219 | array = [str(elm) for elm in array] 220 | if len(array) == 1: 221 | return array[0] 222 | 223 | if len(array) == 2: 224 | return f"{array[0]} {connector} {array[1]}" 225 | 226 | return ", ".join(array[:-1]) + f", {connector} {array[-1]}" 227 | 228 | 229 | @jekyll_filter_manager.register("slugify") 230 | def jekyll_slugify(input: str, mode: str = "default") -> str: 231 | """Slugify a string 232 | 233 | Note that non-ascii characters are always translated to ascii ones. 234 | 235 | Args: 236 | input: The input string 237 | mode: How string is slugified 238 | 239 | Returns: 240 | The slugified string 241 | """ 242 | if input is None or mode == "none": 243 | return input 244 | 245 | from slugify import slugify # type: ignore 246 | 247 | if mode == "pretty": 248 | return slugify(input, regex_pattern=r"[^_.~!$&'()+,;=@\w]+") 249 | if mode == "raw": 250 | return slugify(input, regex_pattern=r"\s+") 251 | 252 | return slugify(input) 253 | 254 | 255 | @jekyll_filter_manager.register 256 | def number_of_words(input: str, mode: str = None) -> int: 257 | """Count the number of words in the input string. 258 | 259 | Args: 260 | input: The String on which to operate. 261 | mode: Passing 'cjk' as the argument will count every CJK character 262 | detected as one word irrespective of being separated by whitespace. 263 | Passing 'auto' (auto-detect) works similar to 'cjk' 264 | 265 | Returns: 266 | The word count. 267 | """ 268 | import regex 269 | 270 | cjk_charset = r"\p{Han}\p{Katakana}\p{Hiragana}\p{Hangul}" 271 | cjk_regex = fr"[{cjk_charset}]" 272 | word_regex = fr"[^{cjk_charset}\s]+" 273 | if mode == "cjk": 274 | return len(regex.findall(cjk_regex, input)) + len( 275 | regex.findall(word_regex, input) 276 | ) 277 | if mode == "auto": 278 | cjk_count = len(regex.findall(cjk_regex, input)) 279 | return ( 280 | len(input.split()) 281 | if cjk_count == 0 282 | else cjk_count + len(regex.findall(word_regex, input)) 283 | ) 284 | return len(input.split()) 285 | 286 | 287 | @jekyll_filter_manager.register 288 | def markdownify(value): 289 | """Markdownify a string""" 290 | from markdown import markdown # type: ignore 291 | 292 | return markdown(value) 293 | 294 | 295 | @jekyll_filter_manager.register 296 | def normalize_whitespace(value): 297 | """Replace multiple spaces into one""" 298 | return re.sub(r"\s+", " ", value) 299 | 300 | 301 | @jekyll_filter_manager.register("sort") 302 | def jekyll_sort( 303 | array: Sequence, 304 | prop: str = None, 305 | none_pos: str = "first", 306 | ) -> Sequence: 307 | """Sort an array in a reverse way by default. 308 | 309 | Note that the order might be different than it with ruby. For example, 310 | in python `"1abc" > "1"`, but it's not the case in jekyll. Also, it's 311 | always in reverse order for property values. 312 | 313 | Args: 314 | array: The array 315 | prop: property name 316 | none_pos: None order (first or last). 317 | 318 | Returns: 319 | The sorted array 320 | """ 321 | if array is None: 322 | raise ValueError("Cannot sort None object.") 323 | 324 | if none_pos not in ("first", "last"): 325 | raise ValueError( 326 | f"{none_pos!r} is not a valid none_pos order. " 327 | "It must be 'first' or 'last'." 328 | ) 329 | 330 | if prop is None: 331 | non_none_arr = [elm for elm in array if elm is not None] 332 | n_none = len(array) - len(non_none_arr) 333 | sorted_arr = list(sorted(non_none_arr, reverse=True)) 334 | 335 | if none_pos == "first": 336 | return [None] * n_none + sorted_arr 337 | 338 | return sorted_arr + [None] * n_none 339 | 340 | non_none_arr = [ 341 | elm for elm in array if _getattr_multi(elm, prop) is not None 342 | ] 343 | none_arr = [elm for elm in array if _getattr_multi(elm, prop) is None] 344 | sorted_arr = list( 345 | sorted( 346 | non_none_arr, 347 | key=lambda elm: _getattr_multi(elm, prop), 348 | reverse=True, 349 | ) 350 | ) 351 | 352 | if none_pos == "first": 353 | return none_arr + sorted_arr 354 | 355 | return sorted_arr + none_arr 356 | 357 | 358 | @jekyll_filter_manager.register 359 | def sample(value, n: int = 1): 360 | """Sample elements from array""" 361 | return random.sample(value, k=n) 362 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /liquid/filters/standard.py: -------------------------------------------------------------------------------- 1 | """Provides standard liquid filters""" 2 | import re 3 | import math 4 | import html 5 | from datetime import datetime 6 | 7 | from jinja2.filters import FILTERS 8 | 9 | from .manager import FilterManager 10 | 11 | 12 | standard_filter_manager = FilterManager() 13 | 14 | 15 | class DateTime: 16 | """Date time allows plus/minus operation""" 17 | def __init__(self, dt: datetime, fmt: str) -> None: 18 | self.dt = dt 19 | self.fmt = fmt 20 | 21 | def __str__(self) -> str: 22 | """How it is rendered""" 23 | return self.dt.strftime(self.fmt) 24 | 25 | def __add__(self, other: int) -> int: 26 | return int(str(self)) + other 27 | 28 | def __sub__(self, other: int) -> int: 29 | return int(str(self)) - other 30 | 31 | def __mul__(self, other: int) -> int: 32 | return int(str(self)) * other 33 | 34 | def __floordiv__(self, other: int) -> float: 35 | return float(str(self)) // other 36 | 37 | def __mod__(self, other: int) -> int: 38 | return int(str(self)) % other 39 | 40 | def __pow__(self, other: int) -> int: # pragma: no cover 41 | return int(str(self)) ** other 42 | 43 | def __truediv__(self, other: int) -> float: # pragma: no cover 44 | return float(str(self)) / other 45 | 46 | def __radd__(self, other: int) -> int: # pragma: no cover 47 | return other + int(str(self)) 48 | 49 | def __rsub__(self, other: int) -> int: # pragma: no cover 50 | return other - int(str(self)) 51 | 52 | def __rmul__(self, other: int) -> int: # pragma: no cover 53 | return other * int(str(self)) 54 | 55 | def __rmod__(self, other: int) -> int: # pragma: no cover 56 | return other % int(str(self)) 57 | 58 | def __rpow__(self, other: int) -> int: # pragma: no cover 59 | return other ** int(str(self)) 60 | 61 | def __rtruediv__(self, other: int) -> float: # pragma: no cover 62 | return other / float(str(self)) 63 | 64 | def __rfloordiv__(self, other: int) -> float: # pragma: no cover 65 | return other // float(str(self)) 66 | 67 | 68 | class EmptyDrop: 69 | """The EmptyDrop class borrowed from liquid""" 70 | 71 | # Use jinja's Undefined instead? 72 | 73 | def __init__(self): 74 | setattr(self, "empty?", True) 75 | 76 | def __str__(self): 77 | return "" 78 | 79 | def __eq__(self, other): 80 | return not bool(other) 81 | 82 | def __ne__(self, other): 83 | return not self.__eq__(other) 84 | 85 | def __bool__(self): 86 | return False 87 | 88 | 89 | def _get_prop(obj, prop, _raise=False): 90 | """Get the property of the object, allow via getitem""" 91 | try: 92 | return obj[prop] 93 | except (TypeError, KeyError): 94 | try: 95 | return getattr(obj, prop) 96 | except AttributeError: 97 | if _raise: # pragma: no cover 98 | raise 99 | return None 100 | 101 | 102 | # Jinja comes with thses filters 103 | # standard_filter_manager.register(str.capitalize) 104 | # standard_filter_manager.register(abs) 105 | # standard_filter_manager.register(round) 106 | standard_filter_manager.register("concat")(list.__add__) 107 | standard_filter_manager.register("at_least")(max) 108 | standard_filter_manager.register("at_most")(min) 109 | standard_filter_manager.register("downcase")(str.lower) 110 | standard_filter_manager.register("upcase")(str.upper) 111 | standard_filter_manager.register(html.escape) 112 | standard_filter_manager.register(str.lstrip) 113 | standard_filter_manager.register(str.rstrip) 114 | standard_filter_manager.register(str.strip) 115 | standard_filter_manager.register(str.replace) 116 | standard_filter_manager.register("size")(len) 117 | standard_filter_manager.register(int) 118 | standard_filter_manager.register(float) 119 | standard_filter_manager.register(str) 120 | standard_filter_manager.register(bool) 121 | 122 | 123 | @standard_filter_manager.register 124 | def split(base, sep): 125 | """Split a string into a list 126 | If the sep is empty, return the list of characters 127 | """ 128 | if not sep: 129 | return list(base) 130 | return base.split(sep) 131 | 132 | 133 | @standard_filter_manager.register 134 | def append(base, suffix): 135 | """Append a suffix to a string""" 136 | return f"{base}{suffix}" 137 | 138 | 139 | @standard_filter_manager.register 140 | def prepend(base, prefix): 141 | """Prepend a prefix to a string""" 142 | return f"{prefix}{base}" 143 | 144 | 145 | @standard_filter_manager.register 146 | def times(base, sep): 147 | """Implementation of *""" 148 | return base * sep 149 | 150 | 151 | @standard_filter_manager.register 152 | def minus(base, sep): 153 | """Implementation of -""" 154 | return base - sep 155 | 156 | 157 | @standard_filter_manager.register 158 | def plus(base, sep): 159 | """Implementation of +""" 160 | return base + sep 161 | 162 | 163 | @standard_filter_manager.register 164 | def modulo(base, sep): 165 | """Implementation of %""" 166 | return base % sep 167 | 168 | 169 | @standard_filter_manager.register 170 | def ceil(base): 171 | """Get the ceil of a number""" 172 | return math.ceil(float(base)) 173 | 174 | 175 | @standard_filter_manager.register 176 | def floor(base): 177 | """Get the floor of a number""" 178 | return math.floor(float(base)) 179 | 180 | 181 | @standard_filter_manager.register("date") 182 | def liquid_date(base, fmt): 183 | """Format a date/datetime""" 184 | 185 | if base == "now": 186 | dtime = datetime.now() 187 | elif base == "today": 188 | dtime = datetime.today() 189 | elif isinstance(base, (int, float)): 190 | dtime = datetime.fromtimestamp(base) 191 | else: 192 | from dateutil import parser # type: ignore 193 | dtime = parser.parse(base) 194 | 195 | return DateTime(dtime, fmt) 196 | 197 | 198 | @standard_filter_manager.register 199 | def default(base, deft, allow_false=False): 200 | """Return the deft value if base is not set. 201 | Otherwise, return base""" 202 | if allow_false and base is False: 203 | return False 204 | if base is None: 205 | return deft 206 | return FILTERS["default"](base, deft, isinstance(base, str)) 207 | 208 | 209 | @standard_filter_manager.register 210 | def divided_by(base, dvdby): 211 | """Implementation of / or //""" 212 | if isinstance(dvdby, int): 213 | return base // dvdby 214 | return base / dvdby 215 | 216 | 217 | @standard_filter_manager.register 218 | def escape_once(base): 219 | """Escapse html characters only once of the string""" 220 | return html.escape(html.unescape(base)) 221 | 222 | 223 | @standard_filter_manager.register 224 | def newline_to_br(base): 225 | """Replace newline with `
`""" 226 | return base.replace("\n", "
") 227 | 228 | 229 | @standard_filter_manager.register 230 | def remove(base, string): 231 | """Remove a substring from a string""" 232 | return base.replace(string, "") 233 | 234 | 235 | @standard_filter_manager.register 236 | def remove_first(base, string): 237 | """Remove the first substring from a string""" 238 | return base.replace(string, "", 1) 239 | 240 | 241 | @standard_filter_manager.register 242 | def replace_first(base, old, new): 243 | """Replace the first substring with new string""" 244 | return base.replace(old, new, 1) 245 | 246 | 247 | # @standard_filter_manager.register 248 | # def reverse(base): 249 | # """Get the reversed list""" 250 | # if not base: 251 | # return EmptyDrop() 252 | # return list(reversed(base)) 253 | 254 | 255 | @standard_filter_manager.register 256 | def sort(base): 257 | """Get the sorted list""" 258 | if not base: 259 | return EmptyDrop() 260 | return list(sorted(base)) 261 | 262 | 263 | @standard_filter_manager.register 264 | def sort_natural(base): 265 | """Get the sorted list in a case-insensitive manner""" 266 | if not base: 267 | return EmptyDrop() 268 | return list(sorted(base, key=str.casefold)) 269 | 270 | 271 | @standard_filter_manager.register("slice") 272 | def liquid_slice(base, start, length=1): 273 | """Slice a list""" 274 | if not base: 275 | return EmptyDrop() 276 | if start < 0: 277 | start = len(base) + start 278 | end = None if length is None else start + length 279 | return base[start:end] 280 | 281 | 282 | @standard_filter_manager.register 283 | def strip_html(base): 284 | """Strip html tags from a string""" 285 | # use html parser? 286 | return re.sub(r"<[^>]+>", "", base) 287 | 288 | 289 | @standard_filter_manager.register 290 | def strip_newlines(base): 291 | """Strip newlines from a string""" 292 | return base.replace("\n", "") 293 | 294 | 295 | @standard_filter_manager.register 296 | def truncate(base, length, ellipsis="..."): 297 | """Truncate a string""" 298 | lenbase = len(base) 299 | if length >= lenbase: 300 | return base 301 | 302 | return base[: length - len(ellipsis)] + ellipsis 303 | 304 | 305 | @standard_filter_manager.register 306 | def truncatewords(base, length, ellipsis="..."): 307 | """Truncate a string by words""" 308 | # do we need to preserve the whitespaces? 309 | baselist = base.split() 310 | lenbase = len(baselist) 311 | if length >= lenbase: 312 | return base 313 | 314 | # instead of collapsing them into just a single space? 315 | return " ".join(baselist[:length]) + ellipsis 316 | 317 | 318 | @standard_filter_manager.register 319 | def uniq(base): 320 | """Get the unique elements from a list""" 321 | if not base: 322 | return EmptyDrop() 323 | ret = [] 324 | for bas in base: 325 | if bas not in ret: 326 | ret.append(bas) 327 | return ret 328 | 329 | 330 | @standard_filter_manager.register 331 | def url_decode(base): 332 | """Url-decode a string""" 333 | try: 334 | from urllib import unquote 335 | except ImportError: 336 | from urllib.parse import unquote 337 | return unquote(base) 338 | 339 | 340 | @standard_filter_manager.register 341 | def url_encode(base): 342 | """Url-encode a string""" 343 | try: 344 | from urllib import urlencode 345 | except ImportError: 346 | from urllib.parse import urlencode 347 | return urlencode({"": base})[1:] 348 | 349 | 350 | @standard_filter_manager.register 351 | def where(base, prop, value): 352 | """Query a list of objects with a given property value""" 353 | ret = [bas for bas in base if _get_prop(bas, prop) == value] 354 | return ret or EmptyDrop() 355 | 356 | 357 | @standard_filter_manager.register(["liquid_map", "map"]) 358 | def liquid_map(base, prop): 359 | """Map a property to a list of objects""" 360 | return [_get_prop(bas, prop) for bas in base] 361 | 362 | 363 | @standard_filter_manager.register 364 | def attr(base, prop): 365 | """Similar as `__getattr__()` but also works like `__getitem__()""" 366 | return _get_prop(base, prop) 367 | 368 | 369 | # @standard_filter_manager.register 370 | # def join(base, sep): 371 | # """Join a list by the sep""" 372 | # if isinstance(base, EmptyDrop): 373 | # return '' 374 | # return sep.join(base) 375 | 376 | # @standard_filter_manager.register 377 | # def first(base): 378 | # """Get the first element of the list""" 379 | # if not base: 380 | # return EmptyDrop() 381 | # return base[0] 382 | 383 | # @standard_filter_manager.register 384 | # def last(base): 385 | # """Get the last element of the list""" 386 | # if not base: 387 | # return EmptyDrop() 388 | # return base[-1] 389 | 390 | 391 | @standard_filter_manager.register 392 | def compact(base): 393 | """Remove empties from a list""" 394 | ret = [bas for bas in base if bas] 395 | return ret or EmptyDrop() 396 | 397 | 398 | @standard_filter_manager.register 399 | def regex_replace( 400 | base: str, 401 | regex: str, 402 | replace: str = "", 403 | case_sensitive: bool = False, 404 | count: int = 0, 405 | ) -> str: 406 | """Replace matching regex pattern""" 407 | if not isinstance(base, str): 408 | # Raise an error instead? 409 | return base 410 | 411 | args = { 412 | "pattern": regex, # re.escape 413 | "repl": replace, 414 | "string": base, 415 | "count": count, 416 | } 417 | if not case_sensitive: 418 | args["flags"] = re.IGNORECASE 419 | 420 | return re.sub(**args) # type: ignore 421 | -------------------------------------------------------------------------------- /liquid/tags/standard.py: -------------------------------------------------------------------------------- 1 | """Provides standard liquid tags""" 2 | from typing import TYPE_CHECKING, List, Union 3 | from jinja2 import nodes 4 | from jinja2.exceptions import TemplateSyntaxError 5 | from jinja2.lexer import TOKEN_BLOCK_END, TOKEN_COLON, TOKEN_STRING 6 | 7 | from ..utils import peek_tokens, parse_tag_args 8 | from .manager import TagManager, decode_raw 9 | 10 | if TYPE_CHECKING: 11 | from jinja2.parser import Parser 12 | from jinja2.lexer import Token 13 | 14 | 15 | standard_tags = TagManager() 16 | 17 | 18 | @standard_tags.register(raw=True) 19 | def comment(token: "Token", parser: "Parser") -> nodes.Node: 20 | """The comment tag {% comment %} ... {% endcomment %} 21 | 22 | This tag accepts an argument, which is the prefix to be used for each line 23 | in the body. 24 | If no prefix provided, the entire body will be ignored (works as the one 25 | from liquid) 26 | 27 | Args: 28 | token: The token matches tag name 29 | parser: The parser 30 | 31 | Returns: 32 | The parsed node 33 | """ 34 | if parser.stream.current.type is TOKEN_BLOCK_END: 35 | # no args provided, ignore whatever 36 | parser.parse_statements(("name:endcomment", ), drop_needle=True) 37 | return nodes.Output([], lineno=token.lineno) 38 | 39 | args = parser.parse_expression() 40 | body = parser.parse_statements(("name:endcomment", ), drop_needle=True) 41 | body = decode_raw(body[0].nodes[0].data) 42 | body_parts = body.split("\n", 1) 43 | if not body_parts[0]: 44 | body = "" if len(body_parts) < 2 else body_parts[1] 45 | 46 | out = [nodes.Const(f"{args.value} {line}\n") for line in body.splitlines()] 47 | return nodes.Output(out, lineno=token.lineno) 48 | 49 | 50 | @standard_tags.register 51 | def capture(token: "Token", parser: "Parser") -> nodes.Node: 52 | """The capture tag {% capture var %}...{% endcapture %} 53 | 54 | Args: 55 | token: The token matches tag name 56 | parser: The parser 57 | 58 | Returns: 59 | The parsed node 60 | """ 61 | target = parser.parse_assign_target(with_namespace=True) 62 | filter_node = parser.parse_filter(None) 63 | body = parser.parse_statements(("name:endcapture",), drop_needle=True) 64 | return nodes.AssignBlock(target, filter_node, body, lineno=token.lineno) 65 | 66 | 67 | @standard_tags.register 68 | def assign(token: "Token", parser: "Parser") -> nodes.Node: 69 | """The assign tag {% assign x = ... %} 70 | 71 | Args: 72 | token: The token matches tag name 73 | parser: The parser 74 | 75 | Returns: 76 | The parsed node 77 | """ 78 | target = parser.parse_assign_target(with_namespace=True) 79 | parser.stream.expect("assign") 80 | expr = parser.parse_tuple() 81 | return nodes.Assign(target, expr, lineno=token.lineno) 82 | 83 | 84 | @standard_tags.register 85 | def unless(token: "Token", parser: "Parser") -> nodes.Node: 86 | """The unless tag {% unless ... %} ... {% endunless %} 87 | 88 | Args: 89 | token: The token matches tag name 90 | parser: The parser 91 | 92 | Returns: 93 | The parsed node 94 | """ 95 | node = result = nodes.If(lineno=token.lineno) 96 | while True: 97 | node.test = nodes.Not( 98 | parser.parse_tuple(with_condexpr=False), 99 | lineno=token.lineno, 100 | ) 101 | node.body = parser.parse_statements( 102 | ("name:elif", "name:elsif", "name:else", "name:endunless") 103 | ) 104 | node.elif_ = [] 105 | node.else_ = [] 106 | token = next(parser.stream) 107 | if token.test_any("name:elif", "name:elsif"): 108 | node = nodes.If(lineno=parser.stream.current.lineno) 109 | result.elif_.append(node) 110 | continue 111 | if token.test("name:else"): 112 | result.else_ = parser.parse_statements( 113 | ("name:endunless",), drop_needle=True 114 | ) 115 | break 116 | return result 117 | 118 | 119 | @standard_tags.register 120 | def case(token: "Token", parser: "Parser") -> nodes.Node: 121 | """The case-when tag {% case x %}{% when y %} ... {% endcase %} 122 | 123 | Args: 124 | token: The token matches tag name 125 | parser: The parser 126 | 127 | Returns: 128 | The parsed node 129 | """ 130 | lhs = parser.parse_tuple(with_condexpr=False) 131 | # %} 132 | if not parser.stream.skip_if("block_end"): 133 | raise TemplateSyntaxError( # pragma: no cover 134 | "Expected 'end of statement block'", 135 | token.lineno, 136 | ) 137 | token = next(parser.stream) 138 | if token.type == "data": 139 | if token.value.strip(): 140 | raise TemplateSyntaxError( 141 | "Expected nothing or whitespaces between case and when, " 142 | f"but got {token}", 143 | token.lineno, 144 | ) 145 | token = next(parser.stream) 146 | 147 | if token.type != "block_begin": 148 | raise TemplateSyntaxError( 149 | "Expected 'begin of statement block', " f"but got {token}", 150 | token.lineno, 151 | ) 152 | 153 | token = parser.stream.expect("name:when") 154 | node = result = nodes.If(lineno=token.lineno) 155 | while True: 156 | node.test = nodes.Compare( 157 | lhs, 158 | [ 159 | nodes.Operand( 160 | "eq", 161 | parser.parse_tuple(with_condexpr=False), 162 | ) 163 | ], 164 | lineno=token.lineno, 165 | ) 166 | node.body = parser.parse_statements( 167 | ("name:when", "name:else", "name:endcase") 168 | ) 169 | node.elif_ = [] 170 | node.else_ = [] 171 | token = next(parser.stream) 172 | if token.test("name:when"): 173 | node = nodes.If(lineno=parser.stream.current.lineno) 174 | result.elif_.append(node) 175 | continue 176 | if token.test("name:else"): 177 | result.else_ = parser.parse_statements( 178 | ("name:endcase",), drop_needle=True 179 | ) 180 | break 181 | return result 182 | 183 | 184 | @standard_tags.register 185 | def tablerow( 186 | token: "Token", parser: "Parser" 187 | ) -> Union[nodes.Node, List[nodes.Node]]: 188 | """The tablerow tag {% tablerow ... %} ... {% endtablerow %} 189 | 190 | Args: 191 | token: The token matches tag name 192 | parser: The parser 193 | 194 | Returns: 195 | The parsed node 196 | """ 197 | target = parser.parse_assign_target(extra_end_rules=("name:in", )) 198 | parser.stream.expect("name:in") 199 | iter_ = parser.parse_tuple( 200 | with_condexpr=False, 201 | extra_end_rules=("name:cols", "name:limit", "name:offset"), 202 | ) 203 | 204 | cols = parse_tag_args(parser.stream, "cols", token.lineno) 205 | limit = parse_tag_args(parser.stream, "limit", token.lineno) 206 | offset = parse_tag_args(parser.stream, "offset", token.lineno) 207 | 208 | if limit and offset: 209 | limit = nodes.Add(offset, limit) 210 | if limit or offset: 211 | iter_ = nodes.Getitem(iter_, nodes.Slice(offset, limit, None), "load") 212 | 213 | if cols: 214 | slice_start = nodes.Mul(nodes.Name("_tablerow_i", "load"), cols) 215 | inner_iter = nodes.Getitem( 216 | iter_, 217 | nodes.Slice( 218 | slice_start, 219 | nodes.Add(slice_start, cols), 220 | None, 221 | ), 222 | "load", 223 | ) 224 | else: 225 | inner_iter: nodes.Getitem = iter_ 226 | 227 | inner_body = [ 228 | nodes.Output( 229 | [ 230 | nodes.Const(''), 233 | ] 234 | ), 235 | *parser.parse_statements(("name:endtablerow",), drop_needle=True), 236 | nodes.Output([nodes.Const("")]), 237 | ] 238 | tr_begin = nodes.Output( 239 | [ 240 | nodes.Const(''), 247 | ] 248 | ) 249 | tr_end = nodes.Output([nodes.Const("")]) 250 | inner_loop = nodes.For( 251 | target, inner_iter, inner_body, [], None, False, lineno=token.lineno 252 | ) 253 | if not cols: 254 | return [tr_begin, inner_loop, tr_end] 255 | 256 | # (iter_ | length) / cols 257 | iter_length = nodes.Div( 258 | nodes.Filter(iter_, "length", [], [], None, None), 259 | cols, 260 | ) # float 261 | # int(iter_length) 262 | iter_length_int = nodes.Filter(iter_length, "int", [], [], None, None) 263 | 264 | # implement ceil, as jinja's ceil is implemented as round(..., "ceil") 265 | # while liquid has a ceil filter 266 | # iter_length_int if iter_length == iter_length_int 267 | # else iter_length_int + 1 268 | iter_length = nodes.CondExpr( 269 | nodes.Compare(iter_length, [nodes.Operand("eq", iter_length_int)]), 270 | iter_length_int, 271 | nodes.Add(iter_length_int, nodes.Const(1)), 272 | ) 273 | 274 | return nodes.For( 275 | nodes.Name("_tablerow_i", "store"), 276 | nodes.Call(nodes.Name("range", "load"), [iter_length], [], None, None), 277 | [tr_begin, inner_loop, tr_end], 278 | [], 279 | None, 280 | False, 281 | lineno=token.lineno, 282 | ) 283 | 284 | 285 | @standard_tags.register 286 | def increment(token: "Token", parser: "Parser") -> List[nodes.Node]: 287 | """The increment tag {% increment x %} 288 | 289 | Args: 290 | token: The token matches tag name 291 | parser: The parser 292 | 293 | Returns: 294 | The parsed node 295 | """ 296 | variable = parser.stream.expect("name") 297 | varname = f"_liquid_xcrement_{variable.value}" 298 | varnode = nodes.Name(varname, "load") 299 | 300 | return [ 301 | nodes.Assign( 302 | nodes.Name(varname, "store"), 303 | nodes.CondExpr( 304 | nodes.Test(varnode, "defined", [], [], None, None), 305 | nodes.Add(varnode, nodes.Const(1)), 306 | nodes.Const(0), 307 | ), 308 | lineno=token.lineno, 309 | ), 310 | nodes.Output([varnode], lineno=token.lineno), 311 | ] 312 | 313 | 314 | @standard_tags.register 315 | def decrement(token: "Token", parser: "Parser") -> List[nodes.Node]: 316 | """The decrement tag {% decrement x %} 317 | 318 | Args: 319 | token: The token matches tag name 320 | parser: The parser 321 | 322 | Returns: 323 | The parsed node 324 | """ 325 | variable = parser.stream.expect("name") 326 | varname = f"_liquid_xcrement_{variable.value}" 327 | varnode = nodes.Name(varname, "load") 328 | 329 | return [ 330 | nodes.Assign( 331 | nodes.Name(varname, "store"), 332 | nodes.CondExpr( 333 | nodes.Test(varnode, "defined", [], [], None, None), 334 | nodes.Sub(varnode, nodes.Const(1)), 335 | nodes.Const(-1), 336 | ), 337 | lineno=token.lineno, 338 | ), 339 | nodes.Output([varnode], lineno=token.lineno), 340 | ] 341 | 342 | 343 | @standard_tags.register 344 | def cycle(token: "Token", parser: "Parser") -> nodes.Node: 345 | """The cycle tag {% cycle ... %} 346 | 347 | With name: {% cycle "name": "one", "two", "three" %} 348 | Without: {% cycle "one", "two", "three" %} 349 | 350 | Turn these to 351 | {{ loop.liquid_cycle("one", "two", "three", name=...) }} 352 | 353 | Args: 354 | token: The token matches tag name 355 | parser: The parser 356 | 357 | Returns: 358 | The parsed node 359 | """ 360 | tokens_ahead = peek_tokens(parser.stream, 2) 361 | if ( 362 | len(tokens_ahead) == 2 363 | and tokens_ahead[0].type is TOKEN_STRING 364 | and tokens_ahead[1].type is TOKEN_COLON 365 | ): 366 | parser.stream.skip(2) 367 | cycler_name = tokens_ahead[0].value 368 | else: 369 | cycler_name = "" 370 | 371 | args = parser.parse_tuple(with_condexpr=False, simplified=True) 372 | return nodes.Output( 373 | [ 374 | nodes.Call( 375 | nodes.Getattr( 376 | nodes.Name("loop", "load"), "liquid_cycle", "load" 377 | ), 378 | args.items if isinstance(args, nodes.Tuple) else [args], 379 | [nodes.Keyword("name", nodes.Const(cycler_name))], 380 | None, 381 | None, 382 | lineno=token.lineno, 383 | ) 384 | ] 385 | ) 386 | -------------------------------------------------------------------------------- /tests/standard/test_filters.py: -------------------------------------------------------------------------------- 1 | """Tests grabbed from: 2 | https://shopify.github.io/liquid/filters/abs/ 3 | """ 4 | import pytest 5 | from datetime import datetime 6 | from collections import namedtuple 7 | 8 | from liquid import Liquid 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "base,filter,result", 13 | [ 14 | (-17, "abs", 17), 15 | ("/my/fancy/url", 'append: ".html"', "/my/fancy/url.html"), 16 | # (1.2, 'round', 1), 17 | # (2.7, 'round', 3), 18 | (1.2, "round", 1.0), 19 | (2.7, "round", 3.0), 20 | (183.357, "round: 2", 183.36), 21 | (3, "times: 2", 6), 22 | (24, "times: 7", 168), 23 | (183.357, "times: 12", 2200.284), 24 | (4, "plus: 2", 6), 25 | (16, "plus: 4", 20), 26 | (183.357, "plus: 12", 195.357), 27 | (4, "minus: 2", 2), 28 | (16, "minus: 4", 12), 29 | (183.357, "minus: 12", 171.357), 30 | (3, "modulo: 2", 1), 31 | (24, "modulo: 7", 3), 32 | (183.357, "modulo: 12 | round: 3", 3.357), 33 | (4, "at_least: 5", 5), 34 | (4, "at_least: 3", 4), 35 | (4, "at_most: 5", 4), 36 | (4, "at_most: 3", 3), 37 | ("title", "capitalize", "Title"), 38 | ("my great title", "capitalize", "My great title"), 39 | (1.2, "ceil", 2), 40 | (1.2, "floor", 1), 41 | (2.0, "ceil", 2), 42 | (2.0, "floor", 2), 43 | (183.357, "ceil", 184), 44 | (183.357, "floor", 183), 45 | ("3.5", "ceil", 4), 46 | ("3.5", "floor", 3), 47 | ("Fri, Jul 17, 15", 'date: "%a, %b %d, %y"', "Fri, Jul 17, 15"), 48 | ("Fri, Jul 17, 15", 'date: "%Y"', "2015"), 49 | ("now", 'date: "%Y-%m-%d %H"', datetime.now().strftime("%Y-%m-%d %H")), 50 | ("today", 'date: "%Y-%m-%d"', datetime.today().strftime("%Y-%m-%d")), 51 | ("", "default: 2.99", 2.99), 52 | (4.99, "default: 2.99", 4.99), 53 | (0, "default: 2.99", 0), 54 | (False, "default: True, allow_false: True", "False"), 55 | (16, "divided_by: 4", 4), 56 | (5, "divided_by: 3", 1), 57 | (20, "divided_by: 7", 2), 58 | (20, "divided_by: 7.0 | round: 2", 2.86), 59 | ( 60 | "apples, oranges, and bananas", 61 | 'prepend: "Some fruit: "', 62 | "Some fruit: apples, oranges, and bananas", 63 | ), 64 | ("Parker Moore", "downcase", "parker moore"), 65 | ("apple", "downcase", "apple"), 66 | ("Parker Moore", "upcase", "PARKER MOORE"), 67 | ("apple", "upcase", "APPLE"), 68 | ( 69 | "Have you read 'James & the Giant Peach'?", 70 | "escape", 71 | "Have you read 'James & the Giant Peach'?", 72 | ), 73 | ("Tetsuro Takara", "escape", "Tetsuro Takara"), 74 | ("1 < 2 & 3", "escape_once", "1 < 2 & 3"), 75 | ("1 < 2 & 3", "escape_once", "1 < 2 & 3"), 76 | ("Ground control to Major Tom.", 'split: " " | first', "Ground"), 77 | ( 78 | "John, Paul, George, Ringo", 79 | 'split: ", " | join: " and "', 80 | "John and Paul and George and Ringo", 81 | ), 82 | ("Ground control to Major Tom.", 'split: " " | last', "Tom."), 83 | ( 84 | " So much room for activities! ", 85 | "lstrip", 86 | "So much room for activities! ", 87 | ), 88 | ( 89 | " So much room for activities! ", 90 | "rstrip", 91 | " So much room for activities!", 92 | ), 93 | ( 94 | " So much room for activities! ", 95 | "strip", 96 | "So much room for activities!", 97 | ), 98 | ( 99 | "I strained to see the train through the rain", 100 | 'remove: "rain"', 101 | "I sted to see the t through the ", 102 | ), 103 | ( 104 | "I strained to see the train through the rain", 105 | 'remove_first: "rain"', 106 | "I sted to see the train through the rain", 107 | ), 108 | ( 109 | "Take my protein pills and put my helmet on", 110 | 'replace: "my", "your"', 111 | "Take your protein pills and put your helmet on", 112 | ), 113 | ( 114 | "Take my protein pills and put my helmet on", 115 | 'replace_first: "my", "your"', 116 | "Take your protein pills and put my helmet on", 117 | ), 118 | ( 119 | "apples, oranges, peaches, plums", 120 | 'split: ", " | reverse | join: ", "', 121 | "plums, peaches, oranges, apples", 122 | ), 123 | ( 124 | "Ground control to Major Tom.", 125 | 'split: "" | reverse | join: ""', 126 | ".moT rojaM ot lortnoc dnuorG", 127 | ), 128 | ("Ground control to Major Tom.", "size", 28), 129 | ("Ground control to Major Tom.", 'split: " " | size', 5), 130 | ("%27Stop%21%27+said+Fred", "url_decode", "'Stop!'+said+Fred"), 131 | ("john@liquid.com", "url_encode", "john%40liquid.com"), 132 | ("Tetsuro Takara", "url_encode", "Tetsuro+Takara"), 133 | ("Liquid", "slice: 0", "L"), 134 | ("Liquid", "slice: 2", "q"), 135 | ("Liquid", "slice: 2, 5", "quid"), 136 | ("Liquid", "slice: -3, 2", "ui"), 137 | ( 138 | "zebra, octopus, giraffe, Sally Snake", 139 | 'split: ", " | sort | join: ", "', 140 | "Sally Snake, giraffe, octopus, zebra", 141 | ), 142 | ( 143 | "zebra, octopus, giraffe, Sally Snake", 144 | 'split: ", " | sort_natural | join: ", "', 145 | "giraffe, octopus, Sally Snake, zebra", 146 | ), 147 | ( 148 | "John, Paul, George, Ringo", 149 | 'split: ", "', 150 | ["John", "Paul", "George", "Ringo"], 151 | ), 152 | ( 153 | "Have you read Ulysses?", 154 | "strip_html", 155 | "Have you read Ulysses?", 156 | ), 157 | ( 158 | "Ground control to Major Tom.", 159 | "truncate: 20", 160 | "Ground control to...", 161 | ), 162 | ( 163 | "Ground control to Major Tom.", 164 | "truncate: 120", 165 | "Ground control to Major Tom.", 166 | ), 167 | ( 168 | "Ground control to Major Tom.", 169 | 'truncate: 25, ", and so on"', 170 | "Ground control, and so on", 171 | ), 172 | ( 173 | "Ground control to Major Tom.", 174 | 'truncate: 20, ""', 175 | "Ground control to Ma", 176 | ), 177 | ( 178 | "Ground control to Major Tom.", 179 | "truncatewords: 3", 180 | "Ground control to...", 181 | ), 182 | ( 183 | "Ground control to Major Tom.", 184 | "truncatewords: 30", 185 | "Ground control to Major Tom.", 186 | ), 187 | ( 188 | "Ground control to Major Tom.", 189 | 'truncatewords: 3, "--"', 190 | "Ground control to--", 191 | ), 192 | ( 193 | "Ground control to Major Tom.", 194 | 'truncatewords: 3, ""', 195 | "Ground control to", 196 | ), 197 | ( 198 | "ants, bugs, bees, bugs, ants", 199 | 'split: ", " | uniq | join: ", "', 200 | "ants, bugs, bees", 201 | ), 202 | ], 203 | ) 204 | def test_single_filter(base, filter, result, set_default_standard): 205 | tpl = f"{{{{ {base!r} | {filter} }}}}" 206 | assert Liquid(tpl).render() == str(result) 207 | 208 | 209 | def test_newline_filters(set_default_standard): 210 | assert Liquid("{{ x | newline_to_br }}").render(x="apple\nwatch") == ( 211 | "apple
watch" 212 | ) 213 | assert Liquid("{{ x | strip_newlines }}").render(x="apple\nwatch") == ( 214 | "applewatch" 215 | ) 216 | 217 | 218 | def test_filter_var_args(set_default_standard): 219 | tpl = """ 220 | {% assign filename = "/index.html" %} 221 | {{ "website.com" | append: filename }} 222 | """ 223 | assert Liquid(tpl).render().strip() == "website.com/index.html" 224 | 225 | 226 | def test_compact(set_default_standard): 227 | tpl = """ 228 | {%- assign site_categories = site.pages | map: "category" -%} 229 | {%- for category in site_categories %} 230 | - {{ category -}}. 231 | {%- endfor %} 232 | """ 233 | rendered = Liquid(tpl).render( 234 | site={ 235 | "pages": [ 236 | {"category": "business"}, 237 | {"category": "celebrities"}, 238 | {"category": ""}, 239 | {"category": "lifestyle"}, 240 | {"category": "sports"}, 241 | {"category": ""}, 242 | {"category": "technology"}, 243 | ] 244 | } 245 | ) 246 | assert ( 247 | rendered 248 | == """ 249 | - business. 250 | - celebrities. 251 | - . 252 | - lifestyle. 253 | - sports. 254 | - . 255 | - technology. 256 | """ 257 | ) 258 | 259 | tpl = """ 260 | {%- assign site_categories = site.pages | map: "category" | compact -%} 261 | {%- for category in site_categories %} 262 | - {{ category }}. 263 | {%- endfor %} 264 | """ 265 | rendered = Liquid(tpl).render( 266 | site={ 267 | "pages": [ 268 | {"category": "business"}, 269 | {"category": "celebrities"}, 270 | {"category": ""}, 271 | {"category": "lifestyle"}, 272 | {"category": "sports"}, 273 | {"category": ""}, 274 | {"category": "technology"}, 275 | ] 276 | } 277 | ) 278 | assert ( 279 | rendered 280 | == """ 281 | - business. 282 | - celebrities. 283 | - lifestyle. 284 | - sports. 285 | - technology. 286 | """ 287 | ) 288 | 289 | 290 | def test_concat(set_default_standard): 291 | tpl = """ 292 | {%- assign fruits = "apples, oranges, peaches" | split: ", " -%} 293 | {%- assign vegetables = "carrots, turnips, potatoes" | split: ", " -%} 294 | {%- assign everything = fruits | concat: vegetables -%} 295 | {%- for item in everything %} 296 | - {{ item }} 297 | {%- endfor %} 298 | """ 299 | assert ( 300 | Liquid(tpl).render() 301 | == """ 302 | - apples 303 | - oranges 304 | - peaches 305 | - carrots 306 | - turnips 307 | - potatoes 308 | """ 309 | ) 310 | 311 | tpl = """ 312 | {%- assign fruits = "apples, oranges, peaches" | split: ", " -%} 313 | {%- assign furniture = "chairs, tables, shelves" | split: ", " -%} 314 | {%- assign vegetables = "carrots, turnips, potatoes" | split: ", " -%} 315 | {%- assign everything = fruits | concat: vegetables | concat: furniture -%} 316 | {%- for item in everything %} 317 | - {{ item }} 318 | {%- endfor %} 319 | """ 320 | assert ( 321 | Liquid(tpl).render() 322 | == """ 323 | - apples 324 | - oranges 325 | - peaches 326 | - carrots 327 | - turnips 328 | - potatoes 329 | - chairs 330 | - tables 331 | - shelves 332 | """ 333 | ) 334 | 335 | 336 | # for coverage 337 | def test_dot(set_default_standard): 338 | a = lambda: None 339 | setattr(a, "a-b", 1) 340 | b = lambda: None 341 | setattr(b, "a-b", 2) 342 | assert ( 343 | Liquid("{{a | where: 'a-b', 1 | map: 'a-b' | first}}").render(a=[a, b]) 344 | == "1" 345 | ) 346 | 347 | 348 | @pytest.mark.parametrize( 349 | "tpl,out", 350 | [ 351 | ('{{ 0 | date: "%s" | plus: 86400 }}', "86400"), 352 | ('{{ "now" | date: "%Y" | plus: 1 }}', str(datetime.today().year + 1)), 353 | ('{{ 0 | date: "%s" | minus: 86400 }}', "-86400"), 354 | ('{{ 0 | date: "%s" | times: 86400 }}', "0"), 355 | ('{{ 1 | date: "%s" | times: 86400 }}', "86400"), 356 | ('{{ 10 | date: "%s" | divided_by: 2 | int }}', "5"), 357 | ('{{ 10 | date: "%s" | modulo: 3 }}', "1"), 358 | ], 359 | ) 360 | def test_date_arithmetic(tpl, out, set_default_standard): 361 | assert Liquid(tpl).render() == out 362 | 363 | 364 | def test_emptydrop(set_default_standard): 365 | assert Liquid("{{ obj | sort_natural}}").render(obj=[]) == "" 366 | assert ( 367 | Liquid("{{ obj | sort | bool}}", mode="wild").render(obj=[]) == "False" 368 | ) 369 | assert ( 370 | Liquid("{{ slice(obj, 0) == False}}", mode="wild").render(obj=[]) 371 | == "True" 372 | ) 373 | assert ( 374 | Liquid("{{ uniq(obj) != False}}", mode="wild").render(obj=[]) 375 | == "False" 376 | ) 377 | 378 | 379 | def test_regex_replace(set_default_standard): 380 | assert Liquid('{{1 | regex_replace: "a"}}').render() == "1" 381 | assert Liquid('{{"abc" | regex_replace: "a", "b"}}').render() == "bbc" 382 | assert ( 383 | Liquid( 384 | '{{"abc" | regex_replace: "A", "b", case_sensitive=False}}' 385 | ).render() 386 | == "bbc" 387 | ) 388 | 389 | 390 | def test_basic_typecasting(set_default_standard): 391 | assert Liquid('{{ "1" | int | plus: 1 }}').render() == "2" 392 | assert Liquid('{{ "1" | float | plus: 1 }}').render() == "2.0" 393 | assert Liquid('{{ 1 | str | append: "1" }}').render() == "11" 394 | assert Liquid('{{ 1 | bool }}').render() == "True" 395 | assert Liquid('{{ int("1") | plus: 1 }}').render() == "2" 396 | assert Liquid('{{ float("1") | plus: 1 }}').render() == "2.0" 397 | assert Liquid('{{ str(1) | append: "1" }}').render() == "11" 398 | assert Liquid('{{ bool(1) }}').render() == "True" 399 | 400 | 401 | def test_attr(set_default_standard): 402 | assert Liquid('{{x | attr: "y"}}').render(x={}) == "None" 403 | assert Liquid('{{x | attr: "y" | default: 1}}').render(x={}) == "1" 404 | assert Liquid('{{x | attr: "y"}}').render(x={"y": 1}) == "1" 405 | assert Liquid('{{x | attr: "y"}}').render(x=namedtuple("X", "y")(2)) == "2" 406 | 407 | 408 | def test_liquid_map(set_default_standard): 409 | # with standard mode, map works the same as liquid_map 410 | assert Liquid('{{x | map: "y" | first}}').render(x=[{}]) == "None" 411 | assert Liquid('{{x | liquid_map: "y" | first}}').render(x=[{"y": 1}]) == "1" 412 | assert Liquid('{{x | liquid_map: "y" | last}}').render( 413 | x=[namedtuple("X", "y")(2)] 414 | ) == "2" 415 | -------------------------------------------------------------------------------- /tests/standard/test_tags.py: -------------------------------------------------------------------------------- 1 | """Tests grabbed from: 2 | https://shopify.github.io/liquid/tags/comment/ 3 | """ 4 | from jinja2.exceptions import TemplateSyntaxError 5 | import pytest 6 | 7 | from liquid import Liquid 8 | 9 | 10 | def test_comment(set_default_standard): 11 | tpl = """Anything you put between {% comment %} and {% endcomment %} tags 12 | is turned into a comment.""" 13 | Liquid( 14 | tpl 15 | ).render() == """Anything you put between tags 16 | is turned into a comment.""" 17 | 18 | 19 | def test_if(set_default_standard): 20 | tpl = """ 21 | {% if product.title == "Awesome Shoes" %} 22 | These shoes are awesome! 23 | {% endif %} 24 | """ 25 | assert ( 26 | Liquid(tpl).render(product={"title": "Awesome Shoes"}).strip() 27 | == "These shoes are awesome!" 28 | ) 29 | 30 | 31 | def test_unless(set_default_standard): 32 | tpl = """ 33 | {% unless product.title == "Awesome Shoes" %} 34 | These shoes are not awesome. 35 | {% endunless %} 36 | """ 37 | assert ( 38 | Liquid(tpl).render(product={"title": "Not Awesome Shoes"}).strip() 39 | == "These shoes are not awesome." 40 | ) 41 | 42 | # in python: 43 | # bool(0.0) == False 44 | # bool("") == False 45 | # assert Liquid('{% unless "" %}1{% endunless %}').render() == "" 46 | assert Liquid('{% unless "" %}{% else %}1{% endunless %}').render() == "" 47 | assert Liquid("{% unless 0.0 %}{% else %}1{% endunless %}").render() == "" 48 | assert Liquid("{% unless empty %}1{% endunless %}").render() == "1" 49 | 50 | 51 | def test_else_elsif(set_default_standard): 52 | tpl = """ 53 | {% if customer.name == "kevin" %} 54 | Hey Kevin! 55 | {% elsif customer.name == "anonymous" %} 56 | Hey Anonymous! 57 | {% else %} 58 | Hi Stranger! 59 | {% endif %} 60 | """ 61 | assert ( 62 | Liquid(tpl).render(customer={"name": "anonymous"}).strip() 63 | == "Hey Anonymous!" 64 | ) 65 | 66 | 67 | def test_case_when(set_default_standard): 68 | tpl = """ 69 | {% assign handle = handle %} 70 | {% case handle %} 71 | {% when "cake" %} 72 | This is a cake 73 | {% when "cookie" %} 74 | This is a cookie 75 | {% else %} 76 | This is not a cake nor a cookie 77 | {% endcase %} 78 | """ 79 | assert Liquid(tpl).render(handle="cake").strip() == "This is a cake" 80 | 81 | assert ( 82 | Liquid( 83 | """ 84 | {% case true %} 85 | {% when false %} 86 | {% endcase %} 87 | """ 88 | ) 89 | .render() 90 | .strip() 91 | == "" 92 | ) 93 | 94 | 95 | def test_for(set_default_standard): 96 | tpl = """ 97 | {%- for product in collection.products %} {{ product.title }} 98 | {%- endfor -%} 99 | """ 100 | assert ( 101 | Liquid(tpl).render( 102 | collection={ 103 | "products": [ 104 | {"title": "hat"}, 105 | {"title": "shirt"}, 106 | {"title": "pants"}, 107 | ] 108 | } 109 | ) 110 | == " hat shirt pants" 111 | ) 112 | 113 | tpl = """ 114 | {%- for product in collection.products %} 115 | {{ product.title }} 116 | {% else %} 117 | The collection is empty. 118 | {%- endfor -%} 119 | """ 120 | assert ( 121 | Liquid(tpl).render(collection={"products": []}).strip() 122 | == "The collection is empty." 123 | ) 124 | 125 | assert ( 126 | Liquid("{{(1..5) | list}}", filters={"list": list}).render() 127 | == "[1, 2, 3, 4, 5]" 128 | ) 129 | 130 | tpl = """ 131 | {% for i in (1..5) %} 132 | {% if i == 4 %} 133 | {% break %} 134 | {% else %} 135 | {{ i }} 136 | {% endif %} 137 | {% endfor %} 138 | """ 139 | assert Liquid(tpl).render().split() == ["1", "2", "3"] 140 | 141 | 142 | def test_for_continue(set_default_standard): 143 | tpl = """ 144 | {% for i in (1..5) %} 145 | {% if i == 4 %} 146 | {% continue %} 147 | {% else %} 148 | {{ i }} 149 | {% endif %} 150 | {% endfor %} 151 | """ 152 | assert Liquid(tpl).render().split() == ["1", "2", "3", "5"] 153 | 154 | 155 | def test_forloop(set_default_standard): 156 | tpl = """ 157 | {% for product in products %} 158 | {% if forloop.first == true %} 159 | First time through! 160 | {% else %} 161 | Not the first time. 162 | {% endif %} 163 | {% endfor %} 164 | """ 165 | rendered = Liquid(tpl).render(products=range(5)).splitlines() 166 | rendered = [ren.strip() for ren in rendered if ren.strip()] 167 | assert rendered == [ 168 | "First time through!", 169 | "Not the first time.", 170 | "Not the first time.", 171 | "Not the first time.", 172 | "Not the first time.", 173 | ] 174 | 175 | tpl = """ 176 | {% for product in products %} 177 | {{ forloop.index }} 178 | {% else %} 179 | // no products in your frontpage collection 180 | {% endfor %} 181 | """ 182 | assert Liquid(tpl).render(products=range(5)).split() == [ 183 | "1", 184 | "2", 185 | "3", 186 | "4", 187 | "5", 188 | ] 189 | 190 | # forloop outside for-loop 191 | assert Liquid("{{forloop}}").render(forloop=1) == "1" 192 | 193 | 194 | def test_forloop_index0(set_default_standard): 195 | tpl = """ 196 | {% for product in products %} 197 | {{ forloop.index0 }} 198 | {% endfor %} 199 | """ 200 | assert Liquid(tpl).render(products=range(5)).split() == [ 201 | "0", 202 | "1", 203 | "2", 204 | "3", 205 | "4", 206 | ] 207 | 208 | tpl = """ 209 | {% for product in products %} 210 | {% if forloop.last == true %} 211 | This is the last iteration! 212 | {% else %} 213 | Keep going... 214 | {% endif %} 215 | {% endfor %} 216 | """ 217 | rendered = Liquid(tpl).render(products=range(5)).splitlines() 218 | rendered = [ren.strip() for ren in rendered if ren.strip()] 219 | assert rendered == [ 220 | "Keep going...", 221 | "Keep going...", 222 | "Keep going...", 223 | "Keep going...", 224 | "This is the last iteration!", 225 | ] 226 | 227 | tpl = """ 228 | {% for product in products %} 229 | {% if forloop.first %} 230 |

This collection has {{ forloop.length }} products:

231 | {% endif %} 232 |

{{ product.title }}

233 | {% endfor %} 234 | """ 235 | rendered = ( 236 | Liquid(tpl) 237 | .render( 238 | products=[ 239 | {"title": "Apple"}, 240 | {"title": "Orange"}, 241 | {"title": "Peach"}, 242 | {"title": "Plum"}, 243 | ] 244 | ) 245 | .splitlines() 246 | ) 247 | rendered = [ren.strip() for ren in rendered if ren.strip()] 248 | assert rendered == [ 249 | "

This collection has 4 products:

", 250 | "

Apple

", 251 | "

Orange

", 252 | "

Peach

", 253 | "

Plum

", 254 | ] 255 | 256 | tpl = """ 257 | {% for product in products %} 258 | {{ forloop.rindex }} 259 | {% endfor %} 260 | """ 261 | assert Liquid(tpl).render(products=range(5)).split() == [ 262 | "5", 263 | "4", 264 | "3", 265 | "2", 266 | "1", 267 | ] 268 | 269 | tpl = """ 270 | {% for product in products %} 271 | {{ forloop.rindex0 }} 272 | {% endfor %} 273 | """ 274 | assert Liquid(tpl).render(products=range(5)).split() == [ 275 | "4", 276 | "3", 277 | "2", 278 | "1", 279 | "0", 280 | ] 281 | 282 | 283 | def test_for_params(set_default_standard): 284 | tpl = """ 285 | {% for item in array limit:2 %} 286 | {{ item }} 287 | {% endfor %} 288 | """ 289 | assert Liquid(tpl).render(array=[1, 2, 3, 4, 5, 6]).split() == ["1", "2"] 290 | 291 | tpl = """ 292 | {% for item in array offset:2 %} 293 | {{ item }} 294 | {% endfor %} 295 | """ 296 | assert Liquid(tpl).render(array=[1, 2, 3, 4, 5, 6]).split() == [ 297 | "3", 298 | "4", 299 | "5", 300 | "6", 301 | ] 302 | 303 | tpl = """ 304 | {% for item in array limit:2 offset:2 %} 305 | {{ item }} 306 | {% endfor %} 307 | """ 308 | assert Liquid(tpl).render(array=[1, 2, 3, 4, 5, 6]).split() == ["3", "4"] 309 | 310 | tpl = """ 311 | {% for i in (3..5) %} 312 | {{ i }} 313 | {% endfor %} 314 | {% assign num = 4 %} 315 | {% for i in (1..num) %} 316 | {{ i }} 317 | {% endfor %} 318 | """ 319 | assert Liquid(tpl).render().split() == ["3", "4", "5", "1", "2", "3", "4"] 320 | 321 | 322 | def test_for_reversed(set_default_standard): 323 | tpl = """ 324 | {% for item in array reversed %} 325 | {{ item }} 326 | {% endfor %} 327 | """ 328 | assert Liquid(tpl).render(array=[1, 2, 3, 4, 5, 6]).split() == [ 329 | "6", 330 | "5", 331 | "4", 332 | "3", 333 | "2", 334 | "1", 335 | ] 336 | 337 | 338 | def test_for_cycle(set_default_standard): 339 | tpl = """ 340 | {% for i in (1..4) %} 341 | {% cycle "one", "two", "three" %} 342 | {% endfor %} 343 | """ 344 | assert Liquid(tpl).render().split() == [ 345 | "one", 346 | "two", 347 | "three", 348 | "one", 349 | ] 350 | 351 | tpl = """ 352 | {% for i in (1..2) %} 353 | {% cycle "first": "one", "two", "three" %} 354 | {% cycle "second": "one", "two", "three" %} 355 | {% endfor %} 356 | """ 357 | assert Liquid(tpl).render().split() == [ 358 | "one", 359 | "one", 360 | "two", 361 | "two", 362 | ] 363 | 364 | 365 | def test_tablerow(set_default_standard): 366 | tpl = """ 367 | 368 | {% tablerow product in collection.products %} 369 | {{ product.title }} 370 | {% endtablerow %} 371 |
372 | """ 373 | 374 | rendered = Liquid(tpl).render( 375 | collection={ 376 | "products": [ 377 | {"title": "Cool Shirt"}, 378 | {"title": "Alien Poster"}, 379 | {"title": "Batman Poster"}, 380 | {"title": "Bullseye Shirt"}, 381 | {"title": "Another Classic Vinyl"}, 382 | {"title": "Awesome Jeans"}, 383 | ] 384 | } 385 | ) 386 | 387 | assert ( 388 | rendered 389 | == """ 390 | 391 | 404 |
392 | Cool Shirt 393 | 394 | Alien Poster 395 | 396 | Batman Poster 397 | 398 | Bullseye Shirt 399 | 400 | Another Classic Vinyl 401 | 402 | Awesome Jeans 403 |
405 | """ 406 | ) 407 | 408 | tpl = """ 409 | 410 | {% tablerow product in collection.products cols:2 %} 411 | {{ product.title }} 412 | {% endtablerow %} 413 |
414 | """ 415 | 416 | rendered = Liquid(tpl).render( 417 | collection={ 418 | "products": [ 419 | {"title": "Cool Shirt"}, 420 | {"title": "Alien Poster"}, 421 | {"title": "Batman Poster"}, 422 | {"title": "Bullseye Shirt"}, 423 | {"title": "Another Classic Vinyl"}, 424 | {"title": "Awesome Jeans"}, 425 | ] 426 | } 427 | ) 428 | 429 | assert ( 430 | rendered 431 | == """ 432 | 433 | 446 |
434 | Cool Shirt 435 | 436 | Alien Poster 437 |
438 | Batman Poster 439 | 440 | Bullseye Shirt 441 |
442 | Another Classic Vinyl 443 | 444 | Awesome Jeans 445 |
447 | """ 448 | ) 449 | 450 | tpl = """ 451 | 452 | {% tablerow product in collection.products cols:2 limit:3 %} 453 | {{ product.title }} 454 | {% endtablerow %} 455 |
456 | """ 457 | 458 | rendered = Liquid(tpl).render( 459 | collection={ 460 | "products": [ 461 | {"title": "Cool Shirt"}, 462 | {"title": "Alien Poster"}, 463 | {"title": "Batman Poster"}, 464 | {"title": "Bullseye Shirt"}, 465 | {"title": "Another Classic Vinyl"}, 466 | {"title": "Awesome Jeans"}, 467 | ] 468 | } 469 | ) 470 | 471 | assert ( 472 | rendered 473 | == """ 474 | 475 | 482 |
476 | Cool Shirt 477 | 478 | Alien Poster 479 |
480 | Batman Poster 481 |
483 | """ 484 | ) 485 | 486 | tpl = """ 487 | 488 | {% tablerow product in collection.products cols:2 limit:lim %} 489 | {{ product.title }} 490 | {% endtablerow %} 491 |
492 | """ 493 | 494 | rendered = Liquid(tpl).render( 495 | collection={ 496 | "products": [ 497 | {"title": "Cool Shirt"}, 498 | {"title": "Alien Poster"}, 499 | {"title": "Batman Poster"}, 500 | {"title": "Bullseye Shirt"}, 501 | {"title": "Another Classic Vinyl"}, 502 | {"title": "Awesome Jeans"}, 503 | ] 504 | }, 505 | lim=3 506 | ) 507 | 508 | assert ( 509 | rendered 510 | == """ 511 | 512 | 519 |
513 | Cool Shirt 514 | 515 | Alien Poster 516 |
517 | Batman Poster 518 |
520 | """ 521 | ) 522 | 523 | tpl = """ 524 | 525 | {% tablerow product in collection.products cols:2 offset:3 %} 526 | {{ product.title }} 527 | {% endtablerow %} 528 |
529 | """ 530 | 531 | rendered = Liquid(tpl).render( 532 | collection={ 533 | "products": [ 534 | {"title": "Cool Shirt"}, 535 | {"title": "Alien Poster"}, 536 | {"title": "Batman Poster"}, 537 | {"title": "Bullseye Shirt"}, 538 | {"title": "Another Classic Vinyl"}, 539 | {"title": "Awesome Jeans"}, 540 | ] 541 | } 542 | ) 543 | 544 | assert ( 545 | rendered 546 | == """ 547 | 548 | 555 |
549 | Bullseye Shirt 550 | 551 | Another Classic Vinyl 552 |
553 | Awesome Jeans 554 |
556 | """ 557 | ) 558 | 559 | tpl = """ 560 | 561 | {% tablerow product in collection.products cols:2 limit:2 offset:3 %} 562 | {{ product.title }} 563 | {% endtablerow %} 564 |
565 | """ 566 | 567 | rendered = Liquid(tpl).render( 568 | collection={ 569 | "products": [ 570 | {"title": "Cool Shirt"}, 571 | {"title": "Alien Poster"}, 572 | {"title": "Batman Poster"}, 573 | {"title": "Bullseye Shirt"}, 574 | {"title": "Another Classic Vinyl"}, 575 | {"title": "Awesome Jeans"}, 576 | ] 577 | } 578 | ) 579 | 580 | assert ( 581 | rendered 582 | == """ 583 | 584 | 589 |
585 | Bullseye Shirt 586 | 587 | Another Classic Vinyl 588 |
590 | """ 591 | ) 592 | 593 | tpl = """{% assign num = 4 %} 594 | 595 | {% tablerow i in (1..num) %} 596 | {{ i }} 597 | {% endtablerow %} 598 |
599 | 600 | 601 | {% tablerow i in (3..5) %} 602 | {{ i }} 603 | {% endtablerow %} 604 |
605 | """ 606 | rendered = Liquid(tpl).render() 607 | assert ( 608 | rendered 609 | == """ 610 | 611 | 620 |
612 | 1 613 | 614 | 2 615 | 616 | 3 617 | 618 | 4 619 |
621 | 622 | 623 | 630 |
624 | 3 625 | 626 | 4 627 | 628 | 5 629 |
631 | """ 632 | ) 633 | 634 | 635 | def test_raw(set_default_standard): 636 | tpl = """ 637 | {%- raw %} 638 | In Handlebars, {{ this }} will be HTML-escaped, but 639 | {{{ that }}} will not. 640 | {% endraw -%} 641 | """ 642 | assert ( 643 | Liquid(tpl).render() 644 | == """ 645 | In Handlebars, {{ this }} will be HTML-escaped, but 646 | {{{ that }}} will not. 647 | """ 648 | ) 649 | 650 | 651 | def test_assign(set_default_standard): 652 | assert Liquid("{% assign x = 'bar' %}{{x}}").render() == "bar" 653 | 654 | 655 | def test_capture(set_default_standard): 656 | tpl = """{% capture my_variable %}I am being captured.{% endcapture -%} 657 | {{ my_variable }}""" 658 | assert Liquid(tpl).render() == "I am being captured." 659 | 660 | tpl = """ 661 | {% assign favorite_food = "pizza" %} 662 | {% assign age = 35 %} 663 | 664 | {% capture about_me %} 665 | I am {{ age }} and my favorite food is {{ favorite_food }}. 666 | {% endcapture %} 667 | 668 | {{ about_me }} 669 | """ 670 | assert Liquid(tpl).render().strip() == ( 671 | "I am 35 and my favorite food is pizza." 672 | ) 673 | 674 | 675 | def test_xcrement(set_default_standard): 676 | tpl = """ 677 | {% increment my_counter %} 678 | {% increment my_counter %} 679 | {% increment my_counter %} 680 | """ 681 | assert Liquid(tpl).render().split() == ["0", "1", "2"] 682 | 683 | tpl = """ 684 | {% decrement my_counter %} 685 | {% decrement my_counter %} 686 | {% decrement my_counter %} 687 | """ 688 | assert Liquid(tpl).render().split() == ["-1", "-2", "-3"] 689 | 690 | 691 | def test_comment_with_prefix(set_default_standard): 692 | tpl = """{% comment "#" %} 693 | a 694 | b 695 | {%endcomment%} 696 | """ 697 | Liquid(tpl).render() == """# a\n# b\n""" 698 | 699 | 700 | def test_unless_elif(set_default_standard): 701 | tpl = """ 702 | {% unless a == 1 %} 703 | 11 704 | {% elif a == 2 %} 705 | 22 706 | {% else %} 707 | 33 708 | {% endunless %} 709 | """ 710 | assert Liquid(tpl).render(a=1).strip() == "22" 711 | assert Liquid(tpl).render(a=2).strip() == "11" 712 | 713 | 714 | def test_case_when_error(set_default_standard): 715 | with pytest.raises(TemplateSyntaxError, match="Expected nothing"): 716 | Liquid("{% case a %}0{% when 1 %}1{% endcase %}") 717 | 718 | with pytest.raises( 719 | TemplateSyntaxError, match="Expected 'begin of statement block'" 720 | ): 721 | Liquid("{% case a %}") 722 | 723 | 724 | def test_tablerow_arg_error(set_default_standard): 725 | tpl = """ 726 | 727 | {% tablerow product in collection.products limit:"a" %} 728 | {{ product.title }} 729 | {% endtablerow %} 730 |
731 | """ 732 | with pytest.raises( 733 | TemplateSyntaxError, match="Expected an integer or a variable" 734 | ): 735 | Liquid(tpl) 736 | 737 | 738 | def test_unknown_tag(set_default_standard): 739 | with pytest.raises(TemplateSyntaxError, match="unknown tag"): 740 | Liquid("{% a %} {% enda %}") 741 | --------------------------------------------------------------------------------