Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Python Liquid2 Change Log

## Version 0.4.0 (unreleased)

**Features**

- Added the `shorthand_indexes` class variable to `liquid2.Environment`. When `shorthand_indexes` is set to `True` (the default is `False`), array indexes in variable paths need not be surrounded by square brackets.

**Changes**

- `liquid2.tokenize` and `liquid2.lexer.Lexer` now require the current `Environment` to be passed as the first argument.

## Version 0.3.0

**Breaking changes**
Expand Down
4 changes: 4 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ Integer and float literals can use scientific notation, like `1.2e3` or `1e-2`.

Filter and tag named arguments can be separated by a `:` or `=`. Previously only `:` was allowed.

### Shorthand array indexes

Optionally allow shorthand dotted notation for array indexes in paths to variables. When the `Environment` class variable `shorthand_indexes` is set to `True` (default is `False`), `{{ foo.0.bar }}` is equivalent to `{{ foo[0].bar }}`.

### Template inheritance

([docs](tag_reference.md#extends))
Expand Down
2 changes: 1 addition & 1 deletion liquid2/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.3.0"
__version__ = "0.4.0"
6 changes: 5 additions & 1 deletion liquid2/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ class Environment:
"""If True (the default), indicates that blocks rendering to whitespace only will
not be output."""

shorthand_indexes: bool = False
"""If True, array indexes can be separated by dots without enclosing square
brackets. The default is `False`."""

lexer_class = Lexer
"""The lexer class to use when scanning template source text."""

Expand Down Expand Up @@ -122,7 +126,7 @@ def setup_tags_and_filters(self) -> None:

def tokenize(self, source: str) -> list[TokenT]:
"""Scan Liquid template _source_ and return a list of Markup objects."""
lexer = self.lexer_class(source)
lexer = self.lexer_class(self, source)
lexer.run()
return lexer.markup

Expand Down
19 changes: 15 additions & 4 deletions liquid2/lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from .token import is_token_type

if TYPE_CHECKING:
from .environment import Environment
from .token import TokenT


Expand Down Expand Up @@ -178,6 +179,7 @@ class Lexer:
TOKEN_RULES = _compile(NUMBERS, SYMBOLS, WORD)

__slots__ = (
"env",
"in_range",
"line_start",
"line_statements",
Expand All @@ -194,7 +196,9 @@ class Lexer:
"template_string_stack",
)

def __init__(self, source: str) -> None:
def __init__(self, env: Environment, source: str) -> None:
self.env = env

self.markup: list[TokenT] = []
"""Markup resulting from scanning a Liquid template."""

Expand Down Expand Up @@ -314,8 +318,15 @@ def accept_path(self, *, carry: bool = False) -> None:
self.pos += match.end() - match.start()
self.start = self.pos
self.path_stack[-1].stop = self.pos
elif self.env.shorthand_indexes:
if match := self.RE_INDEX.match(self.source, self.pos):
self.path_stack[-1].path.append(int(match.group()))
self.pos += match.end() - match.start()
self.start = self.pos
else:
self.error("array indexes must use bracket notation")
else:
self.error("expected a property name")
self.error("expected a property name or array index")

elif c == "]":
if len(self.path_stack) == 1:
Expand Down Expand Up @@ -1168,8 +1179,8 @@ def lex_inside_liquid_block_comment(self) -> StateFn | None:
return self.lex_inside_liquid_tag


def tokenize(source: str) -> list[TokenT]:
def tokenize(env: Environment, source: str) -> list[TokenT]:
"""Scan Liquid template _source_ and return a list of Markup objects."""
lexer = Lexer(source)
lexer = Lexer(env, source)
lexer.run()
return lexer.markup
7 changes: 4 additions & 3 deletions performance/benchmark_001.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ def fixture(path_to_templates: Path) -> dict[str, str]:
return loader_dict


def lex(templates: dict[str, str]) -> None:
def lex(env: Environment, templates: dict[str, str]) -> None:
for source in templates.values():
tokenize(source)
tokenize(env, source)


def parse(env: Environment, templates: dict[str, str]) -> None:
Expand Down Expand Up @@ -69,9 +69,10 @@ def benchmark(search_path: str, number: int = 1000, repeat: int = 5) -> None:
print_result(
"lex template (not expressions)",
timeit.repeat(
"lex(templates)",
"lex(env, templates)",
globals={
"lex": lex,
"env": Environment(),
"search_path": search_path,
"templates": templates,
"tokenize": tokenize,
Expand Down
3 changes: 2 additions & 1 deletion performance/profile_001.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ def benchmark(search_path: str, number: int = 1000, repeat: int = 5) -> None:
print_result(
"scan template",
timeit.repeat(
"tokenize(template)",
"tokenize(env, template)",
globals={
"template": source,
"tokenize": tokenize,
"env": Environment(),
},
number=number,
repeat=repeat,
Expand Down
3 changes: 2 additions & 1 deletion performance/profile_002.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ def benchmark(search_path: str, number: int = 1000, repeat: int = 5) -> None:
print_result(
"scan template",
timeit.repeat(
"tokenize(template)",
"tokenize(env, template)",
globals={
"template": source,
"tokenize": tokenize,
"env": Environment(),
},
number=number,
repeat=repeat,
Expand Down
6 changes: 4 additions & 2 deletions tests/test_lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest

from liquid2 import tokenize
from liquid2 import DEFAULT_ENVIRONMENT


@dataclass
Expand Down Expand Up @@ -222,4 +222,6 @@ class Case:

@pytest.mark.parametrize("case", TEST_CASES, ids=operator.attrgetter("name"))
def test_lexer(case: Case) -> None:
assert "".join(str(t) for t in tokenize(case.source)) == case.want
assert (
"".join(str(t) for t in DEFAULT_ENVIRONMENT.tokenize(case.source)) == case.want
)
81 changes: 81 additions & 0 deletions tests/test_shorthand_indexes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import pytest

from liquid2 import Environment
from liquid2.exceptions import LiquidSyntaxError


def test_shorthand_indexes_are_disabled_by_default() -> None:
env = Environment()
with pytest.raises(LiquidSyntaxError):
env.from_string("{{ foo.0.bar }}")


class MockEnv(Environment):
shorthand_indexes = True


ENV = MockEnv()


def test_shorthand_index() -> None:
data = {"foo": ["World", "Liquid"]}
template = ENV.from_string("Hello, {{ foo.0 }}!")
assert template.render(**data) == "Hello, World!"
template = ENV.from_string("Hello, {{ foo.1 }}!")
assert template.render(**data) == "Hello, Liquid!"


def test_consecutive_shorthand_indexes() -> None:
data = {"foo": [["World", "Liquid"]]}
template = ENV.from_string("Hello, {{ foo.0.0 }}!")
assert template.render(**data) == "Hello, World!"
template = ENV.from_string("Hello, {{ foo.0.1 }}!")
assert template.render(**data) == "Hello, Liquid!"


def test_shorthand_index_dot_property() -> None:
data = {"foo": [{"bar": "World"}, {"bar": "Liquid"}]}
template = ENV.from_string("Hello, {{ foo.0.bar }}!")
assert template.render(**data) == "Hello, World!"
template = ENV.from_string("Hello, {{ foo.1.bar }}!")
assert template.render(**data) == "Hello, Liquid!"


def test_shorthand_index_in_loop_expression() -> None:
data = {"foo": [["World", "Liquid"]]}
template = ENV.from_string("{% for x in foo.0 %}Hello, {{ x }}! {% endfor %}")
assert template.render(**data) == "Hello, World! Hello, Liquid! "


def test_shorthand_index_in_conditional_expression() -> None:
data = {"foo": ["World", "Liquid"]}
template = ENV.from_string("{% if foo.0 %}Hello, {{ foo.0 }}!{% endif %}")
assert template.render(**data) == "Hello, World!"
template = ENV.from_string("{% if foo.2 %}Hello, {{ foo.2 }}!{% endif %}")
assert template.render(**data) == ""


def test_shorthand_indexes_in_case_tag() -> None:
data = {"foo": ["World", "Liquid"]}
template = ENV.from_string(
"{% case foo.0 %}{% when 'World' %}Hello, World!{% endcase %}"
)
assert template.render(**data) == "Hello, World!"


def test_shorthand_indexes_in_ternary_expressions() -> None:
data = {"foo": ["World", "Liquid"]}
template = ENV.from_string("Hello, {{ foo.0 }}!")
assert template.render(**data) == "Hello, World!"
template = ENV.from_string("Hello, {{ 'you' if foo.1 }}!")
assert template.render(**data) == "Hello, you!"
template = ENV.from_string("Hello, {{ 'you' if foo.99 else foo.1 }}!")
assert template.render(**data) == "Hello, Liquid!"


def test_shorthand_indexes_in_logical_not_expressions() -> None:
data = {"foo": ["World", "Liquid"]}
template = ENV.from_string("{% if not foo.0 %}Hello, {{ foo.0 }}!{% endif %}")
assert template.render(**data) == ""
template = ENV.from_string("{% if not foo.2 %}Hello, {{ foo.0 }}!{% endif %}")
assert template.render(**data) == "Hello, World!"