Skip to content

Commit

Permalink
Merge pull request #10 from jg-rp/compat
Browse files Browse the repository at this point in the history
Add Shopify compatible tags and filters
  • Loading branch information
jg-rp authored Dec 31, 2024
2 parents a0b5b68 + 16aeed2 commit 33ae184
Show file tree
Hide file tree
Showing 25 changed files with 951 additions and 85 deletions.
1 change: 1 addition & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@ The following packages are dependencies of Python Liquid2.
- Babel>=2
- python-dateutil
- pytz
- typing-extensions
2 changes: 1 addition & 1 deletion liquid2/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.0.3"
__version__ = "0.0.4"
85 changes: 38 additions & 47 deletions liquid2/builtin/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,10 +383,6 @@ def __init__(self, token: TokenT, path: PathT) -> None:
else:
self.path.append(segment)

if isinstance(self.path[0], Path):
# Flatten root segment
self.path = self.path[0].path + self.path[1:]

def __str__(self) -> str:
it = iter(self.path)
buf = [str(next(it))]
Expand Down Expand Up @@ -1442,33 +1438,14 @@ def _to_iter(self, obj: object) -> tuple[Iterator[Any], int]:
token=self.token,
)

def _eval_int(self, expr: Expression | None, context: RenderContext) -> int | None:
if expr is None:
return None

val = expr.evaluate(context)
if not isinstance(val, int):
raise LiquidTypeError(
f"expected an integer, found {expr.__class__.__name__}",
token=expr.token,
)

return val

async def _eval_int_async(
self, expr: Expression | None, context: RenderContext
) -> int | None:
if expr is None:
return None

val = await expr.evaluate_async(context)
if not isinstance(val, int):
def _to_int(self, obj: object, *, token: TokenT) -> int:
try:
return to_int(obj)
except (ValueError, TypeError) as err:
raise LiquidTypeError(
f"expected an integer, found {expr.__class__.__name__}",
token=expr.token,
)

return val
f"expected an integer, found {obj.__class__.__name__}",
token=token,
) from err

def _slice(
self,
Expand Down Expand Up @@ -1507,36 +1484,46 @@ def _slice(

def evaluate(self, context: RenderContext) -> tuple[Iterator[object], int]:
it, length = self._to_iter(self.iterable.evaluate(context))
limit = self._eval_int(self.limit, context)
limit = (
self._to_int(self.limit.evaluate(context), token=self.limit.token)
if self.limit
else None
)

match self.offset:
case StringLiteral(value=value):
case StringLiteral(value=value, token=token):
offset: str | int | None = value
if offset != "continue":
raise LiquidSyntaxError(
f"expected 'continue' or an integer, found '{offset}'",
token=self.offset.token,
)
offset = self._to_int(offset, token=token)
case None:
offset = None
case _offset:
offset = self._eval_int(_offset, context)
offset = self._to_int(_offset.evaluate(context), token=_offset.token)

return self._slice(it, length, context, limit=limit, offset=offset)

async def evaluate_async(
self, context: RenderContext
) -> tuple[Iterator[object], int]:
it, length = self._to_iter(await self.iterable.evaluate_async(context))
limit = await self._eval_int_async(self.limit, context)
limit = (
self._to_int(
await self.limit.evaluate_async(context), token=self.limit.token
)
if self.limit
else None
)

if isinstance(self.offset, StringLiteral):
offset: str | int | None = self.offset.evaluate(context)
if self.offset is None:
offset: str | int | None = None
elif isinstance(self.offset, StringLiteral):
offset = self.offset.evaluate(context)
if offset != "continue":
raise LiquidSyntaxError(
f"expected 'continue' or an integer, found '{offset}'",
token=self.offset.token,
)
offset = self._to_int(offset, token=self.offset.token)
else:
offset = await self._eval_int_async(self.offset, context)
offset = self._to_int(
await self.offset.evaluate_async(context), token=self.offset.token
)

return self._slice(it, length, context, limit=limit, offset=offset)

Expand Down Expand Up @@ -1568,6 +1555,7 @@ def parse(stream: TokenStream) -> LoopExpression:
reversed_ = False
offset: Expression | None = None
limit: Expression | None = None
cols: Expression | None = None

while True:
arg_token = stream.next()
Expand All @@ -1580,6 +1568,10 @@ def parse(stream: TokenStream) -> LoopExpression:
stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN)
stream.next()
limit = parse_primitive(stream.next())
case "cols":
stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN)
stream.next()
cols = parse_primitive(stream.next())
case "offset":
stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN)
stream.next()
Expand Down Expand Up @@ -1615,7 +1607,7 @@ def parse(stream: TokenStream) -> LoopExpression:
limit=limit,
offset=offset,
reversed_=reversed_,
cols=None,
cols=cols,
)


Expand Down Expand Up @@ -1871,7 +1863,6 @@ def _contains(token: TokenT, left: object, right: object) -> bool:
)


# XXX: copied to avoid import issues
def _to_liquid_string(val: Any, *, auto_escape: bool = False) -> str:
"""Stringify a Python object ready for output in a Liquid template."""
if isinstance(val, str) or (auto_escape and hasattr(val, "__html__")):
Expand Down
2 changes: 2 additions & 0 deletions liquid2/builtin/tags/macro_tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def __init__(
self.args = args
self.block = block
self.end_tag_token = end_tag_token
self.blank = True

def __str__(self) -> str:
assert isinstance(self.token, TagToken)
Expand Down Expand Up @@ -142,6 +143,7 @@ def __init__(
self.name = name
self.args = args
self.kwargs = kwargs
self.blank = False

def __str__(self) -> str:
assert isinstance(self.token, TagToken)
Expand Down
1 change: 1 addition & 0 deletions liquid2/builtin/tags/translate_tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def __init__(
self.singular_block = singular_block
self.plural_block = plural_block
self.end_tag_token = end_tag_token
self.blank = False

def __str__(self) -> str:
assert isinstance(self.token, TagToken)
Expand Down
1 change: 1 addition & 0 deletions liquid2/builtin/tags/with_tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def __init__(
self.args = args
self.block = block
self.end_tag_token = end_tag_token
self.blank = self.block.blank

def __str__(self) -> str:
assert isinstance(self.token, TagToken)
Expand Down
34 changes: 8 additions & 26 deletions liquid2/lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,13 +298,19 @@ def accept_path(self, *, carry: bool = False) -> None:
self.error("unexpected end of path")

if c == ".":
if self.peek() == ".": # probably a range expression delimiter
self.backup()
return

self.ignore()
self.ignore_whitespace()
if match := self.RE_PROPERTY.match(self.source, self.pos):
self.path_stack[-1].path.append(match.group())
self.pos += match.end() - match.start()
self.start = self.pos
self.path_stack[-1].stop = self.pos
else:
self.error("expected a property name")

elif c == "]":
if len(self.path_stack) == 1:
Expand Down Expand Up @@ -335,6 +341,7 @@ def accept_path(self, *, carry: bool = False) -> None:
)
self.next()
self.ignore() # skip closing quote
self.ignore_whitespace()

if self.next() != "]":
self.backup()
Expand All @@ -347,6 +354,7 @@ def accept_path(self, *, carry: bool = False) -> None:
self.path_stack[-1].path.append(int(match.group()))
self.pos += match.end() - match.start()
self.start = self.pos
self.ignore_whitespace()

if self.next() != "]":
self.backup()
Expand Down Expand Up @@ -626,36 +634,10 @@ def accept_token(self, expression: list[TokenT]) -> bool:

if kind == "SINGLE_QUOTE_STRING":
self.ignore()
# self.accept_string(quote="'")
# expression.append(
# Token(
# type_=TokenType.SINGLE_QUOTE_STRING,
# value=self.source[self.start : self.pos],
# index=self.start,
# source=self.source,
# )
# )
# self.start = self.pos
# assert self.next() == "'"
# self.ignore()
self.accept_template_string(quote="'", expression=expression)

elif kind == "DOUBLE_QUOTE_STRING":
self.ignore()
# self.accept_string(quote='"')
# expression.append(
# Token(
# type_=TokenType.DOUBLE_QUOTE_STRING,
# value=self.source[self.start : self.pos],
# index=self.start,
# source=self.source,
# )
# )
# self.start = self.pos
# assert self.next() == '"'
# self.ignore()
self.accept_template_string(quote='"', expression=expression)

elif kind == "LBRACKET":
self.backup()
self.accept_path()
Expand Down
3 changes: 3 additions & 0 deletions liquid2/shopify/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
# noqa: D104
from .environment import Environment

__all__ = ("Environment",)
25 changes: 25 additions & 0 deletions liquid2/shopify/environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""An environment that's configured for maximum compatibility with Shopify/Liquid.
This environment will be updated without concern for backwards incompatible changes to
template rendering behavior.
"""

from ..environment import Environment as DefaultEnvironment # noqa: TID252
from .filters._base64 import base64_decode
from .filters._base64 import base64_encode
from .filters._base64 import base64_url_safe_decode
from .filters._base64 import base64_url_safe_encode
from .tags.tablerow_tag import TablerowTag


class Environment(DefaultEnvironment):
"""An environment configured for maximum compatibility with Shopify/Liquid."""

def setup_tags_and_filters(self) -> None:
"""Set up Shopify compatible tags and filters."""
super().setup_tags_and_filters()
self.tags["tablerow"] = TablerowTag(self)
self.filters["base64_decode"] = base64_decode
self.filters["base64_encode"] = base64_encode
self.filters["base64_url_safe_decode"] = base64_url_safe_decode
self.filters["base64_url_safe_encode"] = base64_url_safe_encode
1 change: 1 addition & 0 deletions liquid2/shopify/filters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# noqa: D104
45 changes: 45 additions & 0 deletions liquid2/shopify/filters/_base64.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Filter functions that operate on strings."""

from __future__ import annotations

import base64
import binascii

from liquid2.exceptions import LiquidValueError
from liquid2.filter import string_filter


@string_filter
def base64_encode(val: str) -> str:
"""Return _val_ encoded in base64."""
return base64.b64encode(val.encode()).decode()


@string_filter
def base64_decode(val: str) -> str:
"""Return _val_ decoded as base64.
The decoded value is assumed to be UTF-8 and will be decoded as UTF-8.
"""
try:
return base64.b64decode(val).decode()
except binascii.Error as err:
raise LiquidValueError("invalid base64-encoded string", token=None) from err


@string_filter
def base64_url_safe_encode(val: str) -> str:
"""Return _val_ encoded in URL-safe base64."""
return base64.urlsafe_b64encode(val.encode()).decode()


@string_filter
def base64_url_safe_decode(val: str) -> str:
"""Return _val_ decoded as URL-safe base64.
The decoded value is assumed to be UTF-8 and will be decoded as UTF-8.
"""
try:
return base64.urlsafe_b64decode(val).decode()
except binascii.Error as err:
raise LiquidValueError("invalid base64-encoded string", token=None) from err
Loading

0 comments on commit 33ae184

Please sign in to comment.