From 3fd92f070433a6d938189e8e50ae6c687e540a0b Mon Sep 17 00:00:00 2001 From: James Prior Date: Fri, 16 Aug 2024 08:47:00 +0100 Subject: [PATCH 1/9] Handle interrupts in tablerow tags --- CHANGES.md | 4 +++ liquid/builtin/tags/tablerow_tag.py | 43 +++++++++++++++++++--- liquid/future/environment.py | 2 ++ liquid/future/tags/__init__.py | 2 ++ liquid/future/tags/_tablerow_tag.py | 14 ++++++++ liquid/golden/tablerow_tag.py | 55 +++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 liquid/future/tags/_tablerow_tag.py diff --git a/CHANGES.md b/CHANGES.md index 53fd57d6..d56f9f28 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,10 @@ - Fixed `{% case %}` / `{% when %}` behavior. When using [`liquid.future.Environment`](https://jg-rp.github.io/liquid/api/future-environment), we now render any number of `{% else %}` blocks and allow `{% when %}` tags to appear after `{% else %}` tags. The default `Environment` continues to raise a `LiquidSyntaxError` in such cases. +**Changed** + +- Changed `{% break %}` and `{% continue %}` tag handling when they appear inside a `{% tablerow %}` tag. Now, when using `liquid.future.Environment`, interrupts follow Shopify/Liquid behavior introduced in [#1818](https://github.com/Shopify/liquid/pull/1818). Python Liquid's default environment is unchanged. + ## Version 1.12.1 **Fixes** diff --git a/liquid/builtin/tags/tablerow_tag.py b/liquid/builtin/tags/tablerow_tag.py index 1dd68827..228dbb60 100644 --- a/liquid/builtin/tags/tablerow_tag.py +++ b/liquid/builtin/tags/tablerow_tag.py @@ -12,6 +12,8 @@ from liquid.ast import ChildNode from liquid.ast import Node from liquid.context import Context +from liquid.exceptions import BreakLoop +from liquid.exceptions import ContinueLoop from liquid.expression import NIL from liquid.expression import LoopExpression from liquid.limits import to_int @@ -156,6 +158,9 @@ def step(self) -> None: class TablerowNode(Node): """Parse tree node for the built-in "tablerow" tag.""" + interrupts = False + """If _true_, handle `break` and `continue` interrupts inside a tablerow loop.""" + __slots__ = ("tok", "expression", "block") def __init__( @@ -194,18 +199,33 @@ def render_to_output(self, context: Context, buffer: TextIO) -> Optional[bool]: } buffer.write('\n') + _break = False with context.extend(namespace): for item in tablerow: namespace[name] = item buffer.write(f'') - self.block.render(context=context, buffer=buffer) + + try: + self.block.render(context=context, buffer=buffer) + except BreakLoop: + if self.interrupts: + _break = True + else: + raise + except ContinueLoop: + if not self.interrupts: + raise + buffer.write("") if tablerow.col_last and not tablerow.last: buffer.write(f'\n') - buffer.write("\n") + if _break: + break + + buffer.write("\n") return True async def render_to_output_async( @@ -227,18 +247,33 @@ async def render_to_output_async( } buffer.write('\n') + _break = False with context.extend(namespace): for item in tablerow: namespace[name] = item buffer.write(f'') - await self.block.render_async(context=context, buffer=buffer) + + try: + await self.block.render_async(context=context, buffer=buffer) + except BreakLoop: + if self.interrupts: + _break = True + else: + raise + except ContinueLoop: + if not self.interrupts: + raise + buffer.write("") if tablerow.col_last and not tablerow.last: buffer.write(f'\n') - buffer.write("\n") + if _break: + break + + buffer.write("\n") return True def children(self) -> List[ChildNode]: diff --git a/liquid/future/environment.py b/liquid/future/environment.py index e4807d52..6bd3dbd0 100644 --- a/liquid/future/environment.py +++ b/liquid/future/environment.py @@ -6,6 +6,7 @@ from ..environment import Environment as DefaultEnvironment from ..template import FutureBoundTemplate from .filters import split +from .tags import InterruptingTablerowTag from .tags import LaxCaseTag from .tags import LaxIfTag from .tags import LaxUnlessTag @@ -31,3 +32,4 @@ def setup_tags_and_filters(self) -> None: self.add_tag(LaxCaseTag) self.add_tag(LaxIfTag) self.add_tag(LaxUnlessTag) + self.add_tag(InterruptingTablerowTag) diff --git a/liquid/future/tags/__init__.py b/liquid/future/tags/__init__.py index f33af1f1..d3b2b08e 100644 --- a/liquid/future/tags/__init__.py +++ b/liquid/future/tags/__init__.py @@ -1,8 +1,10 @@ from ._case_tag import LaxCaseTag # noqa: D104 from ._if_tag import LaxIfTag +from ._tablerow_tag import InterruptingTablerowTag from ._unless_tag import LaxUnlessTag __all__ = ( + "InterruptingTablerowTag", "LaxCaseTag", "LaxIfTag", "LaxUnlessTag", diff --git a/liquid/future/tags/_tablerow_tag.py b/liquid/future/tags/_tablerow_tag.py new file mode 100644 index 00000000..47ed5708 --- /dev/null +++ b/liquid/future/tags/_tablerow_tag.py @@ -0,0 +1,14 @@ +from liquid.builtin.tags.tablerow_tag import TablerowNode +from liquid.builtin.tags.tablerow_tag import TablerowTag + + +class InterruptingTablerowNode(TablerowNode): + """A _tablerow_ node with interrupt handling enabled.""" + + interrupts = True + + +class InterruptingTablerowTag(TablerowTag): + """A _tablerow_ tag that handles `break` and `continue` tags.""" + + node_class = InterruptingTablerowNode diff --git a/liquid/golden/tablerow_tag.py b/liquid/golden/tablerow_tag.py index b2648aa9..96aeb64f 100644 --- a/liquid/golden/tablerow_tag.py +++ b/liquid/golden/tablerow_tag.py @@ -282,6 +282,61 @@ "\n" ), ), + Case( + description="break from a tablerow loop", + template=( + r"{% tablerow n in (1..3) cols:2 %}" + r"{{n}}{% break %}{{n}}" + r"{% endtablerow %}" + ), + expect='\n1\n', + future=True, + ), + Case( + description="continue from a tablerow loop", + template=( + r"{% tablerow n in (1..3) cols:2 %}" + r"{{n}}{% continue %}{{n}}" + r"{% endtablerow %}" + ), + expect=( + '\n' + '1' + '2' + "\n" + '' + '3' + "\n" + ), + future=True, + ), + Case( + description="break from a tablerow loop inside a for loop", + template=( + r"{% for i in (1..2) -%}\n" + r"{% for j in (1..2) -%}\n" + r"{% tablerow k in (1..3) %}{% break %}{% endtablerow -%}\n" + r"loop j={{ j }}\n" + r"{% endfor -%}\n" + r"loop i={{ i }}\n" + r"{% endfor -%}\n" + r"after loop\n" + ), + expect="\n".join( + [ + r'\n\n', + r'', + r'\nloop j=1\n\n', + r'', + r'\nloop j=2\n\nloop i=1\n\n\n', + r'', + r'\nloop j=1\n\n', + r'', + r"\nloop j=2\n\nloop i=2\n\nafter loop\n", + ] + ), + future=True, + ), # Case( # description="cols is non number string", # template=( From 9c2ddf06cde7aa6055f444a412c6b657f3bdab94 Mon Sep 17 00:00:00 2001 From: James Prior Date: Fri, 16 Aug 2024 09:02:29 +0100 Subject: [PATCH 2/9] Fix lint issues --- liquid/context.py | 4 +- liquid/expression.py | 15 ++---- liquid/parse.py | 1 - pyproject.toml | 107 ++++++++++++++++++++----------------- tests/filters/test_misc.py | 13 ++--- 5 files changed, 67 insertions(+), 73 deletions(-) diff --git a/liquid/context.py b/liquid/context.py index 24c658cd..03038eb0 100644 --- a/liquid/context.py +++ b/liquid/context.py @@ -72,9 +72,7 @@ class BuiltIn(Mapping[str, object]): """Mapping-like object for resolving built-in, dynamic objects.""" def __contains__(self, item: object) -> bool: - if item in ("now", "today"): - return True - return False + return item in ("now", "today") def __getitem__(self, key: str) -> object: if key == "now": diff --git a/liquid/expression.py b/liquid/expression.py index c685bbac..2f07204e 100644 --- a/liquid/expression.py +++ b/liquid/expression.py @@ -1,4 +1,5 @@ """Liquid expression objects.""" + from __future__ import annotations import sys @@ -79,9 +80,7 @@ class Empty(Expression): def __eq__(self, other: object) -> bool: if isinstance(other, Empty): return True - if isinstance(other, (list, dict, str)) and not other: - return True - return False + return isinstance(other, (list, dict, str)) and not other def __repr__(self) -> str: # pragma: no cover return "Empty()" @@ -107,9 +106,7 @@ def __eq__(self, other: object) -> bool: return True if isinstance(other, (list, dict)) and not other: return True - if isinstance(other, Blank): - return True - return False + return isinstance(other, Blank) def __repr__(self) -> str: # pragma: no cover return "Blank()" @@ -131,9 +128,7 @@ class Continue(Expression): __slots__ = () def __eq__(self, other: object) -> bool: - if isinstance(other, Continue): - return True - return False + return isinstance(other, Continue) def __repr__(self) -> str: # pragma: no cover return "Continue()" @@ -1106,7 +1101,7 @@ def compare(left: object, op: str, right: object) -> bool: # noqa: PLR0911, PLR right = right.__liquid__() def _type_error(_left: object, _right: object) -> NoReturn: - if type(_left) != type(_right): + if type(_left) != type(_right): # noqa: E721 raise LiquidTypeError(f"invalid operator for types '{_left} {op} {_right}'") raise LiquidTypeError(f"unknown operator: {type(_left)} {op} {type(_right)}") diff --git a/liquid/parse.py b/liquid/parse.py index 4d07ae65..c6e2ed41 100644 --- a/liquid/parse.py +++ b/liquid/parse.py @@ -615,7 +615,6 @@ def parse_filter(self, stream: TokenStream) -> expression.Filter: filter_name = stream.current.value stream.next_token() - # args = [] kwargs = {} diff --git a/pyproject.toml b/pyproject.toml index 8a1a1a2c..b897e8fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ build-backend = "hatchling.build" requires = ["hatchling"] [project] -authors = [{name = "James Prior", email = "jamesgr.prior@gmail.com"}] +authors = [{ name = "James Prior", email = "jamesgr.prior@gmail.com" }] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", @@ -18,7 +18,11 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -dependencies = ["python-dateutil>=2.8.1", "typing-extensions>=4.2.0", "importlib-resources>=5.10.0"] +dependencies = [ + "python-dateutil>=2.8.1", + "typing-extensions>=4.2.0", + "importlib-resources>=5.10.0", +] description = "A Python engine for the Liquid template language." dynamic = ["version"] license = "MIT" @@ -126,6 +130,56 @@ warn_unused_configs = true warn_unused_ignores = false [tool.ruff] + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", +] + +# Same as Black. +line-length = 88 + +# Assume Python 3.10. +target-version = "py310" + +[tool.ruff.lint.isort] +force-single-line = true + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"liquid/__about__.py" = ["D100"] +"liquid/__init__.py" = ["D104", "I001"] +"liquid/builtin/filters/__init__.py" = ["D104", "I001"] +"liquid/builtin/loaders/__init__.py" = ["D104", "I001"] +"liquid/builtin/tags/__init__.py" = ["D104", "I001"] +"scripts/__init__.py" = ["D104", "I001"] +"tests/*" = ["D100", "D101", "D104", "D103", "D102", "D209", "D205", "SIM117"] + +[tool.ruff.lint] +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + select = [ "A", "ARG", @@ -153,6 +207,7 @@ select = [ "TCH", "YTT", ] + # TODO: review ignores ignore = [ "S105", @@ -169,51 +224,3 @@ ignore = [ fixable = ["I", "SIM", "D202"] unfixable = [] - -# Exclude a variety of commonly ignored directories. -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".hg", - ".mypy_cache", - ".nox", - ".pants.d", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "node_modules", - "venv", -] - -# Same as Black. -line-length = 88 - -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" - -# Assume Python 3.10. -target-version = "py310" - -[tool.ruff.isort] -force-single-line = true - -[tool.ruff.pydocstyle] -convention = "google" - -[tool.ruff.per-file-ignores] -"liquid/__about__.py" = ["D100"] -"liquid/__init__.py" = ["D104", "I001"] -"liquid/builtin/filters/__init__.py" = ["D104", "I001"] -"liquid/builtin/loaders/__init__.py" = ["D104", "I001"] -"liquid/builtin/tags/__init__.py" = ["D104", "I001"] -"scripts/__init__.py" = ["D104", "I001"] -"tests/*" = ["D100", "D101", "D104", "D103", "D102", "D209", "D205", "SIM117"] diff --git a/tests/filters/test_misc.py b/tests/filters/test_misc.py index f0588942..c479f44b 100644 --- a/tests/filters/test_misc.py +++ b/tests/filters/test_misc.py @@ -1,4 +1,5 @@ """Test miscellaneous filter functions.""" + import datetime import decimal import platform @@ -32,9 +33,7 @@ def __init__(self, val): self.val = val def __eq__(self, other): - if isinstance(other, MockDrop) and self.val == other.val: - return True - return False + return bool(isinstance(other, MockDrop) and self.val == other.val) def __str__(self): return "hello mock drop" @@ -48,9 +47,7 @@ def __init__(self, val): self.val = val def __eq__(self, other): - if isinstance(other, NoLiquidDrop) and self.val == other.val: - return True - return False + return bool(isinstance(other, NoLiquidDrop) and self.val == other.val) def __str__(self): return "hello no liquid drop" @@ -63,9 +60,7 @@ def __init__(self, val): def __eq__(self, other): if isinstance(other, bool) and self.val == other: return True - if isinstance(other, FalsyDrop) and self.val == other.val: - return True - return False + return bool(isinstance(other, FalsyDrop) and self.val == other.val) def __str__(self): return "falsy drop" From e201fb42f87446e3fbb9add67560b69cdf12143f Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 21 Aug 2024 08:08:03 +0100 Subject: [PATCH 3/9] Update Read the Docs requirements --- docs/requirements.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index c6e6fdd5..e09e9721 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ -black==23.11.0 -mkdocs-material==9.4.8 -mkdocstrings-python==1.7.4 -mkdocstrings==0.24.0 -python-dateutil==2.8.2 -typing_extensions==4.8.0 \ No newline at end of file +black==24.8.0 +mkdocs-material==9.5.32 +mkdocstrings-python==1.10.8 +mkdocstrings==0.25.2 +python-dateutil==2.9.0.post0 +typing_extensions==4.12.2 \ No newline at end of file From 7c2806dd81daee03b0b548305e532b75064d29f4 Mon Sep 17 00:00:00 2001 From: James <72664870+jg-rp@users.noreply.github.com> Date: Tue, 24 Sep 2024 18:08:40 +0100 Subject: [PATCH 4/9] Create FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..9b104df1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: jg-rp From b5a09c6aeca8a21593f1597502d8a5eac5e68b26 Mon Sep 17 00:00:00 2001 From: James Prior Date: Tue, 8 Oct 2024 08:35:57 +0100 Subject: [PATCH 5/9] Test against Python version 3.13 --- .github/workflows/tests.yaml | 6 +++++- liquid/builtin/loaders/base_loader.py | 3 ++- pyproject.toml | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index de013dbd..5f55adf4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,12 +9,16 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] exclude: - os: macos-latest python-version: "3.7" - os: windows-latest python-version: "3.7" + - os: macos-latest + python-version: "3.8" + - os: windows-latest + python-version: "3.8" steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/liquid/builtin/loaders/base_loader.py b/liquid/builtin/loaders/base_loader.py index 572b8928..676b93c0 100644 --- a/liquid/builtin/loaders/base_loader.py +++ b/liquid/builtin/loaders/base_loader.py @@ -1,4 +1,5 @@ """Base template loader.""" + from __future__ import annotations from abc import ABC @@ -48,7 +49,7 @@ class BaseLoader(ABC): # noqa: B024 Attributes: caching_loader (bool): Indicates if this loader implements its own cache. - Setting this sto `True` will cause the `Environment` to disable its cache + Setting this to `True` will cause the `Environment` to disable its cache when initialized with a caching loader. """ diff --git a/pyproject.toml b/pyproject.toml index b897e8fb..72320901 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] dependencies = [ "python-dateutil>=2.8.1", From 60b1cbfd937670126ef9006a4d6fcd1dd5505ea4 Mon Sep 17 00:00:00 2001 From: James Prior Date: Tue, 8 Oct 2024 09:15:07 +0100 Subject: [PATCH 6/9] Restrict Markupsage to <3.0 --- liquid/builtin/filters/string.py | 1 + pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/liquid/builtin/filters/string.py b/liquid/builtin/filters/string.py index 0b3233a4..c7f28dfd 100644 --- a/liquid/builtin/filters/string.py +++ b/liquid/builtin/filters/string.py @@ -1,4 +1,5 @@ """Filter functions that operate on strings.""" + from __future__ import annotations import base64 diff --git a/pyproject.toml b/pyproject.toml index 72320901..cb5e44fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ readme = "README.md" requires-python = ">=3.7" [project.optional-dependencies] -autoescape = ["MarkupSafe>=2.0.0"] +autoescape = ["MarkupSafe>=2,<3"] [project.urls] "Change Log" = "https://github.com/jg-rp/liquid/blob/main/CHANGES.md" @@ -64,7 +64,7 @@ dependencies = [ "ruff", "mock", "types-python-dateutil", - "MarkupSafe", + "MarkupSafe>=2, < 3", ] [tool.hatch.envs.default.scripts] From 085b971828bb72f413ca40696479f70b7669cb9e Mon Sep 17 00:00:00 2001 From: James Prior Date: Tue, 8 Oct 2024 09:19:11 +0100 Subject: [PATCH 7/9] Test agains 3.13.0-rc.3 until GitHub action is updated --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5f55adf4..29a1877a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,7 +9,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13.0-rc.3"] exclude: - os: macos-latest python-version: "3.7" From 366b3967628dd27733ad7b65efda3838738a83db Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 16 Oct 2024 10:08:29 +0100 Subject: [PATCH 8/9] Test agains release version of Python 3.13 --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 29a1877a..5f55adf4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,7 +9,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13.0-rc.3"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] exclude: - os: macos-latest python-version: "3.7" From e6bfca8137349ada30c3919cb204bd4960b931d2 Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 16 Oct 2024 10:13:13 +0100 Subject: [PATCH 9/9] Specify py 3.7.17 --- .github/workflows/tests.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 5f55adf4..96c09f92 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,12 +9,12 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.7.17", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] exclude: - os: macos-latest - python-version: "3.7" + python-version: "3.7.17" - os: windows-latest - python-version: "3.7" + python-version: "3.7.17" - os: macos-latest python-version: "3.8" - os: windows-latest