diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ce10d..6e70ab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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** diff --git a/docs/migration.md b/docs/migration.md index d9960a1..5176b7b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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)) diff --git a/liquid2/__about__.py b/liquid2/__about__.py index 493f741..6a9beea 100644 --- a/liquid2/__about__.py +++ b/liquid2/__about__.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/liquid2/environment.py b/liquid2/environment.py index e3dafc2..a479f08 100644 --- a/liquid2/environment.py +++ b/liquid2/environment.py @@ -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.""" @@ -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 diff --git a/liquid2/lexer.py b/liquid2/lexer.py index 59eadcb..a0d33b5 100644 --- a/liquid2/lexer.py +++ b/liquid2/lexer.py @@ -30,6 +30,7 @@ from .token import is_token_type if TYPE_CHECKING: + from .environment import Environment from .token import TokenT @@ -178,6 +179,7 @@ class Lexer: TOKEN_RULES = _compile(NUMBERS, SYMBOLS, WORD) __slots__ = ( + "env", "in_range", "line_start", "line_statements", @@ -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.""" @@ -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: @@ -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 diff --git a/performance/benchmark_001.py b/performance/benchmark_001.py index 3562024..1dc8589 100644 --- a/performance/benchmark_001.py +++ b/performance/benchmark_001.py @@ -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: @@ -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, diff --git a/performance/profile_001.py b/performance/profile_001.py index aac40e5..24a1514 100644 --- a/performance/profile_001.py +++ b/performance/profile_001.py @@ -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, diff --git a/performance/profile_002.py b/performance/profile_002.py index 53efbdf..8e47c66 100644 --- a/performance/profile_002.py +++ b/performance/profile_002.py @@ -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, diff --git a/tests/test_lexer.py b/tests/test_lexer.py index 1778216..ac2221f 100644 --- a/tests/test_lexer.py +++ b/tests/test_lexer.py @@ -3,7 +3,7 @@ import pytest -from liquid2 import tokenize +from liquid2 import DEFAULT_ENVIRONMENT @dataclass @@ -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 + ) diff --git a/tests/test_shorthand_indexes.py b/tests/test_shorthand_indexes.py new file mode 100644 index 0000000..dd39620 --- /dev/null +++ b/tests/test_shorthand_indexes.py @@ -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!"