From 52fd8154bb57f0dda3aa7f8bb88e252707ed9644 Mon Sep 17 00:00:00 2001 From: James Prior Date: Sat, 18 Jan 2025 08:35:15 +0000 Subject: [PATCH 01/14] Pass env around when parsing built-in expressions --- liquid2/builtin/expressions.py | 131 +++++++++++++++----------- liquid2/builtin/output.py | 2 +- liquid2/builtin/tags/assign_tag.py | 2 +- liquid2/builtin/tags/case_tag.py | 6 +- liquid2/builtin/tags/cycle_tag.py | 4 +- liquid2/builtin/tags/echo_tag.py | 2 +- liquid2/builtin/tags/for_tag.py | 2 +- liquid2/builtin/tags/if_tag.py | 4 +- liquid2/builtin/tags/include_tag.py | 6 +- liquid2/builtin/tags/macro_tag.py | 4 +- liquid2/builtin/tags/render_tag.py | 9 +- liquid2/builtin/tags/translate_tag.py | 4 +- liquid2/builtin/tags/unless_tag.py | 4 +- liquid2/builtin/tags/with_tag.py | 2 +- liquid2/shopify/tags/tablerow_tag.py | 2 +- liquid2/stream.py | 3 +- 16 files changed, 104 insertions(+), 83 deletions(-) diff --git a/liquid2/builtin/expressions.py b/liquid2/builtin/expressions.py index 19ad64f..6983d41 100644 --- a/liquid2/builtin/expressions.py +++ b/liquid2/builtin/expressions.py @@ -38,6 +38,7 @@ from liquid2.unescape import unescape if TYPE_CHECKING: + from liquid2 import Environment from liquid2 import OutputToken from liquid2 import PathT from liquid2 import RenderContext @@ -333,12 +334,12 @@ def children(self) -> list[Expression]: return self.items @staticmethod - def parse(stream: TokenStream, left: Expression) -> ArrayLiteral: + def parse(env: Environment, stream: TokenStream, left: Expression) -> ArrayLiteral: items: list[Expression] = [left] while stream.current().type_ == TokenType.COMMA: stream.next() # ignore comma try: - items.append(parse_primitive(stream.current())) + items.append(parse_primitive(env, stream.current())) stream.next() except LiquidSyntaxError: # Trailing commas are OK. @@ -349,7 +350,9 @@ def parse(stream: TokenStream, left: Expression) -> ArrayLiteral: class TemplateString(Expression): __slots__ = ("template",) - def __init__(self, token: TokenT, template: list[Token | OutputToken]): + def __init__( + self, env: Environment, token: TokenT, template: list[Token | OutputToken] + ): super().__init__(token) self.template: list[Expression] = [] @@ -366,7 +369,7 @@ def __init__(self, token: TokenT, template: list[Token | OutputToken]): ) elif is_output_token(_token): self.template.append( - FilteredExpression.parse(TokenStream(_token.expression)) + FilteredExpression.parse(env, TokenStream(_token.expression)) ) else: raise LiquidSyntaxError( @@ -532,24 +535,26 @@ def children(self) -> list[Expression]: return children @staticmethod - def parse(stream: TokenStream) -> FilteredExpression | TernaryFilteredExpression: + def parse( + env: Environment, stream: TokenStream + ) -> FilteredExpression | TernaryFilteredExpression: """Return a new FilteredExpression parsed from _stream_.""" - left = parse_primitive(stream.next()) + left = parse_primitive(env, stream.next()) if stream.current().type_ == TokenType.COMMA: # Array literal syntax - left = ArrayLiteral.parse(stream, left) - filters = Filter.parse(stream, delim=(TokenType.PIPE,)) + left = ArrayLiteral.parse(env, stream, left) + filters = Filter.parse(env, stream, delim=(TokenType.PIPE,)) if is_token_type(stream.current(), TokenType.IF): return TernaryFilteredExpression.parse( - FilteredExpression(left.token, left, filters), stream + env, FilteredExpression(left.token, left, filters), stream ) stream.expect_eos() return FilteredExpression(left.token, left, filters) -def parse_primitive(token: TokenT) -> Expression: # noqa: PLR0911 +def parse_primitive(env: Environment, token: TokenT) -> Expression: # noqa: PLR0911 """Parse _token_ as a primitive expression.""" if is_token_type(token, TokenType.TRUE): return TrueLiteral(token=token) @@ -582,14 +587,16 @@ def parse_primitive(token: TokenT) -> Expression: # noqa: PLR0911 ) if is_template_string_token(token): - return TemplateString(token, token.template) + return TemplateString(env, token, token.template) if is_path_token(token): return Path(token, token.path) if is_range_token(token): return RangeLiteral( - token, parse_primitive(token.range_start), parse_primitive(token.range_stop) + token, + parse_primitive(env, token.range_start), + parse_primitive(env, token.range_stop), ) raise LiquidSyntaxError( @@ -684,26 +691,26 @@ def children(self) -> list[Expression]: @staticmethod def parse( - expr: FilteredExpression, stream: TokenStream + env: Environment, expr: FilteredExpression, stream: TokenStream ) -> TernaryFilteredExpression: """Return a new TernaryFilteredExpression parsed from tokens in _stream_.""" stream.expect(TokenType.IF) stream.next() # move past `if` - condition = BooleanExpression.parse(stream, inline=True) + condition = BooleanExpression.parse(env, stream, inline=True) alternative: Expression | None = None filters: list[Filter] | None = None tail_filters: list[Filter] | None = None if is_token_type(stream.current(), TokenType.ELSE): stream.next() # move past `else` - alternative = parse_primitive(stream.next()) + alternative = parse_primitive(env, stream.next()) if stream.current().type_ == TokenType.PIPE: - filters = Filter.parse(stream, delim=(TokenType.PIPE,)) + filters = Filter.parse(env, stream, delim=(TokenType.PIPE,)) if stream.current().type_ == TokenType.DOUBLE_PIPE: tail_filters = Filter.parse( - stream, delim=(TokenType.PIPE, TokenType.DOUBLE_PIPE) + env, stream, delim=(TokenType.PIPE, TokenType.DOUBLE_PIPE) ) stream.expect_eos() @@ -786,6 +793,7 @@ def children(self) -> list[Expression]: @staticmethod def parse( # noqa: PLR0912 + env: Environment, stream: TokenStream, *, delim: tuple[TokenType, ...], @@ -814,7 +822,7 @@ def parse( # noqa: PLR0912 stream.next() filter_arguments.append( KeywordArgument( - token.value, parse_primitive(stream.current()) + token.value, parse_primitive(env, stream.current()) ) ) else: @@ -824,7 +832,9 @@ def parse( # noqa: PLR0912 ) elif is_template_string_token(token): filter_arguments.append( - PositionalArgument(TemplateString(token, token.template)) + PositionalArgument( + TemplateString(env, token, token.template) + ) ) elif is_path_token(token): filter_arguments.append( @@ -840,7 +850,7 @@ def parse( # noqa: PLR0912 TokenType.NULL, ): filter_arguments.append( - PositionalArgument(parse_primitive(stream.current())) + PositionalArgument(parse_primitive(env, stream.current())) ) elif token.type_ == TokenType.COMMA: # Leading, trailing and duplicate commas are OK @@ -949,13 +959,15 @@ async def evaluate_async(self, context: RenderContext) -> object: return is_truthy(await self.expression.evaluate_async(context)) @staticmethod - def parse(stream: TokenStream, *, inline: bool = False) -> BooleanExpression: + def parse( + env: Environment, stream: TokenStream, *, inline: bool = False + ) -> BooleanExpression: """Return a new BooleanExpression parsed from tokens in _stream_. If _inline_ is `False`, we expect the stream to be empty after parsing a Boolean expression and will raise a syntax error if it's not. """ - expr = parse_boolean_primitive(stream) + expr = parse_boolean_primitive(env, stream) if not inline: stream.expect_eos() return BooleanExpression(expr.token, expr) @@ -1004,7 +1016,7 @@ def children(self) -> list[Expression]: def parse_boolean_primitive( # noqa: PLR0912 - stream: TokenStream, precedence: int = PRECEDENCE_LOWEST + env: Environment, stream: TokenStream, precedence: int = PRECEDENCE_LOWEST ) -> Expression: """Parse a Boolean expression from tokens in _stream_.""" left: Expression @@ -1034,17 +1046,19 @@ def parse_boolean_primitive( # noqa: PLR0912 token, unescape(token.value.replace("\\'", "'"), token=token) ) elif is_template_string_token(token): - left = TemplateString(token, token.template) + left = TemplateString(env, token, token.template) elif is_path_token(token): left = Path(token, token.path) elif is_range_token(token): left = RangeLiteral( - token, parse_primitive(token.range_start), parse_primitive(token.range_stop) + token, + parse_primitive(env, token.range_start), + parse_primitive(env, token.range_stop), ) elif is_token_type(token, TokenType.NOT_WORD): - left = LogicalNotExpression.parse(stream) + left = LogicalNotExpression.parse(env, stream) elif is_token_type(token, TokenType.LPAREN): - left = parse_grouped_expression(stream) + left = parse_grouped_expression(env, stream) else: raise LiquidSyntaxError( f"expected a primitive expression, found {token.type_.name}", @@ -1062,12 +1076,14 @@ def parse_boolean_primitive( # noqa: PLR0912 if token.type_ not in BINARY_OPERATORS: return left - left = parse_infix_expression(stream, left) + left = parse_infix_expression(env, stream, left) return left -def parse_infix_expression(stream: TokenStream, left: Expression) -> Expression: # noqa: PLR0911 +def parse_infix_expression( + env: Environment, stream: TokenStream, left: Expression +) -> Expression: # noqa: PLR0911 """Return a logical, comparison, or membership expression parsed from _stream_.""" token = stream.next() assert token is not None @@ -1076,43 +1092,43 @@ def parse_infix_expression(stream: TokenStream, left: Expression) -> Expression: match token.type_: case TokenType.EQ: return EqExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.LT: return LtExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.GT: return GtExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.NE: return NeExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.LE: return LeExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.GE: return GeExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.CONTAINS: return ContainsExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.IN: return InExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.AND_WORD: return LogicalAndExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.OR_WORD: return LogicalOrExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case _: raise LiquidSyntaxError( @@ -1121,9 +1137,9 @@ def parse_infix_expression(stream: TokenStream, left: Expression) -> Expression: ) -def parse_grouped_expression(stream: TokenStream) -> Expression: +def parse_grouped_expression(env: Environment, stream: TokenStream) -> Expression: """Parse an expression from tokens in _stream_ until the next right parenthesis.""" - expr = parse_boolean_primitive(stream) + expr = parse_boolean_primitive(env, stream) token = stream.next() while token.type_ != TokenType.RPAREN: @@ -1137,7 +1153,7 @@ def parse_grouped_expression(stream: TokenStream) -> Expression: token=token, ) - expr = parse_infix_expression(stream, expr) + expr = parse_infix_expression(env, stream, expr) if token.type_ != TokenType.RPAREN: raise LiquidSyntaxError("unbalanced parentheses", token=token) @@ -1162,8 +1178,8 @@ async def evaluate_async(self, context: RenderContext) -> object: return not is_truthy(await self.expression.evaluate_async(context)) @staticmethod - def parse(stream: TokenStream) -> Expression: - expr = parse_boolean_primitive(stream) + def parse(env: Environment, stream: TokenStream) -> Expression: + expr = parse_boolean_primitive(env, stream) return LogicalNotExpression(expr.token, expr) def children(self) -> list[Expression]: @@ -1593,14 +1609,14 @@ def children(self) -> list[Expression]: return children @staticmethod - def parse(stream: TokenStream) -> LoopExpression: + def parse(env: Environment, stream: TokenStream) -> LoopExpression: """Parse tokens from _stream_ as a for loop expression.""" token = stream.current() identifier = parse_identifier(token) stream.next() stream.expect(TokenType.IN) stream.next() # Move past 'in' - iterable = parse_primitive(stream.next()) + iterable = parse_primitive(env, stream.next()) # We're looking for a comma that isn't followed by a known keyword. # This means we have an array literal. @@ -1617,7 +1633,7 @@ def parse(stream: TokenStream) -> LoopExpression: ) ): # Array literal syntax - iterable = ArrayLiteral.parse(stream, iterable) + iterable = ArrayLiteral.parse(env, stream, iterable) # Arguments are not allowed to follow an array literal. stream.expect_eos() return LoopExpression( @@ -1645,11 +1661,11 @@ def parse(stream: TokenStream) -> LoopExpression: case "limit": stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN) stream.next() - limit = parse_primitive(stream.next()) + limit = parse_primitive(env, stream.next()) case "cols": stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN) stream.next() - cols = parse_primitive(stream.next()) + cols = parse_primitive(env, stream.next()) case "offset": stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN) stream.next() @@ -1660,7 +1676,7 @@ def parse(stream: TokenStream) -> LoopExpression: ): offset = StringLiteral(token=offset_token, value="continue") else: - offset = parse_primitive(offset_token) + offset = parse_primitive(env, offset_token) case _: raise LiquidSyntaxError( "expected 'reversed', 'offset' or 'limit', ", @@ -1773,7 +1789,9 @@ def parse_string_or_path(token: TokenT) -> StringLiteral | Path: ) -def parse_keyword_arguments(tokens: TokenStream) -> list[KeywordArgument]: +def parse_keyword_arguments( + env: Environment, tokens: TokenStream +) -> list[KeywordArgument]: """Parse _tokens_ into a list or keyword arguments. Argument keys and values can be separated by a colon (`:`) or an equals sign @@ -1794,7 +1812,7 @@ def parse_keyword_arguments(tokens: TokenStream) -> list[KeywordArgument]: if is_token_type(token, TokenType.WORD): tokens.expect_one_of(TokenType.COLON, TokenType.ASSIGN) tokens.next() # Move past ":" or "=" - value = parse_primitive(tokens.next()) + value = parse_primitive(env, tokens.next()) args.append(KeywordArgument(token.value, value)) else: raise LiquidSyntaxError( @@ -1806,6 +1824,7 @@ def parse_keyword_arguments(tokens: TokenStream) -> list[KeywordArgument]: def parse_positional_and_keyword_arguments( + env: Environment, tokens: TokenStream, ) -> tuple[list[PositionalArgument], list[KeywordArgument]]: """Parse _tokens_ into a lists of keyword and positional arguments. @@ -1832,16 +1851,16 @@ def parse_positional_and_keyword_arguments( ): # A keyword argument tokens.next() # Move past ":" or "=" - value = parse_primitive(tokens.next()) + value = parse_primitive(env, tokens.next()) kwargs.append(KeywordArgument(token.value, value)) else: # A primitive as a positional argument - args.append(PositionalArgument(parse_primitive(token))) + args.append(PositionalArgument(parse_primitive(env, token))) return args, kwargs -def parse_parameters(tokens: TokenStream) -> dict[str, Parameter]: +def parse_parameters(env: Environment, tokens: TokenStream) -> dict[str, Parameter]: """Parse _tokens_ as a list of arguments suitable for a macro definition.""" params: dict[str, Parameter] = {} @@ -1862,7 +1881,7 @@ def parse_parameters(tokens: TokenStream) -> dict[str, Parameter]: ): # A parameter with a default value tokens.next() # Move past ":" or "=" - value = parse_primitive(tokens.next()) + value = parse_primitive(env, tokens.next()) params[token.value] = Parameter(token, token.value, value) else: params[token.value] = Parameter(token, token.value, None) diff --git a/liquid2/builtin/output.py b/liquid2/builtin/output.py index cdc2621..a742277 100644 --- a/liquid2/builtin/output.py +++ b/liquid2/builtin/output.py @@ -74,5 +74,5 @@ def parse(self, stream: TokenStream) -> Node: raise LiquidSyntaxError("missing expression", token=token) return self.node_class( - token, FilteredExpression.parse(TokenStream(token.expression)) + token, FilteredExpression.parse(self.env, TokenStream(token.expression)) ) diff --git a/liquid2/builtin/tags/assign_tag.py b/liquid2/builtin/tags/assign_tag.py index 4477fb4..92554b1 100644 --- a/liquid2/builtin/tags/assign_tag.py +++ b/liquid2/builtin/tags/assign_tag.py @@ -86,5 +86,5 @@ def parse(self, stream: TokenStream) -> Node: return self.node_class( token, name=name, - expression=FilteredExpression.parse(expr_stream), + expression=FilteredExpression.parse(self.env, expr_stream), ) diff --git a/liquid2/builtin/tags/case_tag.py b/liquid2/builtin/tags/case_tag.py index 59ab2d6..39d8a45 100644 --- a/liquid2/builtin/tags/case_tag.py +++ b/liquid2/builtin/tags/case_tag.py @@ -126,7 +126,7 @@ def parse(self, stream: TokenStream) -> Node: token = stream.current() assert isinstance(token, TagToken) expr_stream = stream.into_inner() - left = parse_primitive(expr_stream.next()) + left = parse_primitive(self.env, expr_stream.next()) expr_stream.expect_eos() # Check for content or markup between the _case_ tag and the first _when_ or @@ -194,10 +194,10 @@ def parse(self, stream: TokenStream) -> Node: ) def _parse_when_expression(self, stream: TokenStream) -> list[Expression]: - expressions: list[Expression] = [parse_primitive(stream.next())] + expressions: list[Expression] = [parse_primitive(self.env, stream.next())] while stream.current().type_ in (TokenType.COMMA, TokenType.OR_WORD): stream.next() - expressions.append(parse_primitive(stream.next())) + expressions.append(parse_primitive(self.env, stream.next())) stream.expect_eos() return expressions diff --git a/liquid2/builtin/tags/cycle_tag.py b/liquid2/builtin/tags/cycle_tag.py index e7105d5..d14596f 100644 --- a/liquid2/builtin/tags/cycle_tag.py +++ b/liquid2/builtin/tags/cycle_tag.py @@ -96,7 +96,7 @@ def parse(self, stream: TokenStream) -> Node: items: list[Expression] = [] # We must have at least one item - items.append(parse_primitive(expr_stream.next())) + items.append(parse_primitive(self.env, expr_stream.next())) while True: item_token = expr_stream.next() @@ -117,6 +117,6 @@ def parse(self, stream: TokenStream) -> Node: if item_token.type_ == TokenType.EOI: break - items.append(parse_primitive(item_token)) + items.append(parse_primitive(self.env, item_token)) return self.node_class(token, name, items) diff --git a/liquid2/builtin/tags/echo_tag.py b/liquid2/builtin/tags/echo_tag.py index f1c6537..e57965e 100644 --- a/liquid2/builtin/tags/echo_tag.py +++ b/liquid2/builtin/tags/echo_tag.py @@ -74,7 +74,7 @@ def parse(self, stream: TokenStream) -> Node: raise LiquidSyntaxError("missing expression", token=token) expr_stream = TokenStream(token.expression) - expr = FilteredExpression.parse(expr_stream) + expr = FilteredExpression.parse(self.env, expr_stream) expr_stream.expect_eos() return self.node_class(token, expr) diff --git a/liquid2/builtin/tags/for_tag.py b/liquid2/builtin/tags/for_tag.py index e7a21b8..160febc 100644 --- a/liquid2/builtin/tags/for_tag.py +++ b/liquid2/builtin/tags/for_tag.py @@ -178,7 +178,7 @@ def parse(self, stream: TokenStream) -> Node: if not token.expression: raise LiquidSyntaxError("missing expression", token=token) - expression = LoopExpression.parse(TokenStream(token.expression)) + expression = LoopExpression.parse(self.env, TokenStream(token.expression)) parse_block = self.env.parser.parse_block diff --git a/liquid2/builtin/tags/if_tag.py b/liquid2/builtin/tags/if_tag.py index 20bac2e..7e9d5da 100644 --- a/liquid2/builtin/tags/if_tag.py +++ b/liquid2/builtin/tags/if_tag.py @@ -133,7 +133,7 @@ def parse(self, stream: TokenStream) -> Node: if not token.expression: raise LiquidSyntaxError("missing expression", token=token) - condition = parse_expression(TokenStream(token.expression)) + condition = parse_expression(self.env, TokenStream(token.expression)) block_token = stream.current() assert block_token is not None @@ -150,7 +150,7 @@ def parse(self, stream: TokenStream) -> Node: raise LiquidSyntaxError("missing expression", token=alternative_token) alternative_expression = parse_expression( - TokenStream(alternative_token.expression) + self.env, TokenStream(alternative_token.expression) ) alternative_block = BlockNode( diff --git a/liquid2/builtin/tags/include_tag.py b/liquid2/builtin/tags/include_tag.py index 4349257..3331381 100644 --- a/liquid2/builtin/tags/include_tag.py +++ b/liquid2/builtin/tags/include_tag.py @@ -245,7 +245,7 @@ def parse(self, stream: TokenStream) -> Node: ): tokens.next() # Move past "for" loop = True - var = parse_primitive(tokens.next()) + var = parse_primitive(self.env, tokens.next()) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) @@ -254,11 +254,11 @@ def parse(self, stream: TokenStream) -> Node: TokenType.COMMA, ): tokens.next() # Move past "with" - var = parse_primitive(tokens.next()) + var = parse_primitive(self.env, tokens.next()) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) - args = parse_keyword_arguments(tokens) + args = parse_keyword_arguments(self.env, tokens) tokens.expect_eos() return self.node_class(token, name, loop=loop, var=var, alias=alias, args=args) diff --git a/liquid2/builtin/tags/macro_tag.py b/liquid2/builtin/tags/macro_tag.py index 436530e..8b6a7c3 100644 --- a/liquid2/builtin/tags/macro_tag.py +++ b/liquid2/builtin/tags/macro_tag.py @@ -114,7 +114,7 @@ def parse(self, stream: TokenStream) -> Node: tokens = TokenStream(token.expression) name = parse_string_or_identifier(tokens.next()) - args = parse_parameters(tokens) + args = parse_parameters(self.env, tokens) block = BlockNode( stream.current(), self.env.parser.parse_block(stream, ("endmacro",)) ) @@ -279,5 +279,5 @@ def parse(self, stream: TokenStream) -> Node: tokens = TokenStream(token.expression) name = parse_string_or_identifier(tokens.next()) - args, kwargs = parse_positional_and_keyword_arguments(tokens) + args, kwargs = parse_positional_and_keyword_arguments(self.env, tokens) return self.node_class(token, name, args, kwargs) diff --git a/liquid2/builtin/tags/render_tag.py b/liquid2/builtin/tags/render_tag.py index 0beaf6b..2e84375 100644 --- a/liquid2/builtin/tags/render_tag.py +++ b/liquid2/builtin/tags/render_tag.py @@ -74,8 +74,7 @@ def __str__(self) -> str: var += "," args = " " + ", ".join(str(arg) for arg in self.args) if self.args else "" return ( - f"{{%{self.token.wc[0]} render " - f"{self.name}{var}{args} {self.token.wc[1]}%}}" + f"{{%{self.token.wc[0]} render {self.name}{var}{args} {self.token.wc[1]}%}}" ) def render_to_output(self, context: RenderContext, buffer: TextIO) -> int: @@ -299,7 +298,7 @@ def parse(self, stream: TokenStream) -> Node: ): tokens.next() # Move past "for" loop = True - var = parse_primitive(tokens.next()) + var = parse_primitive(self.env, tokens.next()) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) @@ -308,11 +307,11 @@ def parse(self, stream: TokenStream) -> Node: TokenType.COMMA, ): tokens.next() # Move past "with" - var = parse_primitive(tokens.next()) + var = parse_primitive(self.env, tokens.next()) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) - args = parse_keyword_arguments(tokens) + args = parse_keyword_arguments(self.env, tokens) tokens.expect_eos() return self.node_class(token, name, loop=loop, var=var, alias=alias, args=args) diff --git a/liquid2/builtin/tags/translate_tag.py b/liquid2/builtin/tags/translate_tag.py index bf2da5c..d542836 100644 --- a/liquid2/builtin/tags/translate_tag.py +++ b/liquid2/builtin/tags/translate_tag.py @@ -295,7 +295,9 @@ def parse(self, stream: TokenStream) -> TranslateNode: if token.expression: args = { arg.name: arg - for arg in parse_keyword_arguments(TokenStream(token.expression)) + for arg in parse_keyword_arguments( + self.env, TokenStream(token.expression) + ) } else: args = {} diff --git a/liquid2/builtin/tags/unless_tag.py b/liquid2/builtin/tags/unless_tag.py index 9b6ff92..355a20d 100644 --- a/liquid2/builtin/tags/unless_tag.py +++ b/liquid2/builtin/tags/unless_tag.py @@ -133,7 +133,7 @@ def parse(self, stream: TokenStream) -> Node: parse_block = self.env.parser.parse_block parse_expression = BooleanExpression.parse - condition = parse_expression(TokenStream(token.expression)) + condition = parse_expression(self.env, TokenStream(token.expression)) block_token = stream.current() assert block_token is not None @@ -153,7 +153,7 @@ def parse(self, stream: TokenStream) -> Node: raise LiquidSyntaxError("missing expression", token=alternative_token) alternative_expression = parse_expression( - TokenStream(alternative_token.expression) + self.env, TokenStream(alternative_token.expression) ) alternative_block = BlockNode( diff --git a/liquid2/builtin/tags/with_tag.py b/liquid2/builtin/tags/with_tag.py index 91ebb4a..50431e1 100644 --- a/liquid2/builtin/tags/with_tag.py +++ b/liquid2/builtin/tags/with_tag.py @@ -91,7 +91,7 @@ def parse(self, stream: TokenStream) -> Node: assert isinstance(token, TagToken) tokens = TokenStream(token.expression) - args = parse_keyword_arguments(tokens) + args = parse_keyword_arguments(self.env, tokens) block = BlockNode( stream.current(), self.env.parser.parse_block(stream, ("endwith",)) ) diff --git a/liquid2/shopify/tags/tablerow_tag.py b/liquid2/shopify/tags/tablerow_tag.py index 249523a..2f38723 100644 --- a/liquid2/shopify/tags/tablerow_tag.py +++ b/liquid2/shopify/tags/tablerow_tag.py @@ -201,7 +201,7 @@ def parse(self, stream: TokenStream) -> Node: if not token.expression: raise LiquidSyntaxError("missing expression", token=token) - expression = LoopExpression.parse(TokenStream(token.expression)) + expression = LoopExpression.parse(self.env, TokenStream(token.expression)) block_token = stream.current() assert block_token is not None block = BlockNode( diff --git a/liquid2/stream.py b/liquid2/stream.py index 1f28377..1ea0c00 100644 --- a/liquid2/stream.py +++ b/liquid2/stream.py @@ -20,11 +20,12 @@ class TokenStream: """Step through a stream of tokens.""" + eoi = Token(type_=TokenType.EOI, value="", index=-1, source="") + def __init__(self, tokens: Sequence[TokenT]) -> None: self.tokens = tokens self.pos = 0 self.trim_carry = WhitespaceControl.DEFAULT - self.eoi = Token(type_=TokenType.EOI, value="", index=-1, source="") def current(self) -> TokenT: """Return the item at self[0] without advancing the iterator.""" From d20c0333b839b479acf8629af8540b0a6238ceac Mon Sep 17 00:00:00 2001 From: James Prior Date: Sat, 18 Jan 2025 09:32:29 +0000 Subject: [PATCH 02/14] Optionally validate filter aguments when parsing templates. --- liquid2/builtin/expressions.py | 18 +++++++++++- liquid2/environment.py | 5 ++++ tests/test_validate_filters.py | 53 ++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/test_validate_filters.py diff --git a/liquid2/builtin/expressions.py b/liquid2/builtin/expressions.py index 6983d41..774a322 100644 --- a/liquid2/builtin/expressions.py +++ b/liquid2/builtin/expressions.py @@ -33,6 +33,7 @@ from liquid2 import is_token_type from liquid2.exceptions import LiquidSyntaxError from liquid2.exceptions import LiquidTypeError +from liquid2.exceptions import UnknownFilterError from liquid2.expression import Expression from liquid2.limits import to_int from liquid2.unescape import unescape @@ -724,6 +725,7 @@ class Filter: def __init__( self, + env: Environment, token: TokenT, name: str, arguments: list[KeywordArgument | PositionalArgument], @@ -732,11 +734,25 @@ def __init__( self.name = name self.args = arguments + if env.validate_filter_arguments: + self.validate_filter_arguments(env) + def __str__(self) -> str: if self.args: return f"{self.name}: {''.join(str(arg) for arg in self.args)}" return self.name + def validate_filter_arguments(self, env: Environment) -> None: + try: + func = env.filters[self.name] + except KeyError as err: + raise UnknownFilterError( + f"unknown filter '{self.name}'", token=self.token + ) from err + + if hasattr(func, "validate"): + func.validate(env, self.token, self.name, self.args) + def evaluate(self, left: object, context: RenderContext) -> object: func = context.filter(self.name, token=self.token) positional_args, keyword_args = self.evaluate_args(context) @@ -860,7 +876,7 @@ def parse( # noqa: PLR0912 stream.next() - filters.append(Filter(filter_token, filter_name, filter_arguments)) + filters.append(Filter(env, filter_token, filter_name, filter_arguments)) return filters diff --git a/liquid2/environment.py b/liquid2/environment.py index 3a116c0..e3dafc2 100644 --- a/liquid2/environment.py +++ b/liquid2/environment.py @@ -48,6 +48,9 @@ class Environment: default_trim: The automatic whitespace stripping mode to use. This mode can then be overridden by template authors per Liquid tag using whitespace control symbols (`-`, `+`, `~`). + validate_filter_arguments: If `True`, class-based filters that define a + `validate()` method will have their arguments validated as each template is + parsed. """ context_depth_limit: ClassVar[int] = 30 @@ -85,11 +88,13 @@ def __init__( auto_escape: bool = False, undefined: Type[Undefined] = Undefined, default_trim: WhitespaceControl = WhitespaceControl.PLUS, + validate_filter_arguments: bool = True, ) -> None: self.loader = loader or DictLoader({}) self.globals = globals or {} self.auto_escape = auto_escape self.undefined = undefined + self.validate_filter_arguments = validate_filter_arguments self.default_trim: WhitespaceControl = ( WhitespaceControl.PLUS diff --git a/tests/test_validate_filters.py b/tests/test_validate_filters.py new file mode 100644 index 0000000..f1416a2 --- /dev/null +++ b/tests/test_validate_filters.py @@ -0,0 +1,53 @@ +import pytest + +from liquid2 import Environment +from liquid2 import TokenT +from liquid2.builtin import KeywordArgument +from liquid2.builtin import PositionalArgument +from liquid2.exceptions import LiquidSyntaxError +from liquid2.exceptions import UnknownFilterError + + +def test_unknown_filter_without_validation() -> None: + env = Environment(validate_filter_arguments=False) + # No exception at parse time + template = env.from_string("{{ x | nosuchthing }}") + + with pytest.raises(UnknownFilterError): + template.render(x="foo") + + +def test_unknown_filter_with_validation() -> None: + env = Environment(validate_filter_arguments=True) + # Exception is raised at parse time + with pytest.raises(UnknownFilterError): + env.from_string("{{ x | nosuchthing }}") + + +class MockInvalidFilter: + def __call__(self, _left: object) -> str: + return self.__class__.__name__ + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + _args: list[KeywordArgument | PositionalArgument], + ) -> None: + raise LiquidSyntaxError(f"{name!r} is invalid", token=token) + + +def test_invalid_filter_without_validation() -> None: + env = Environment(validate_filter_arguments=False) + env.filters["mock"] = MockInvalidFilter() + template = env.from_string("{{ x | mock }}") + assert template.render(x="foo") == "MockInvalidFilter" + + +def test_invalid_filter_with_validation() -> None: + env = Environment(validate_filter_arguments=True) + env.filters["mock"] = MockInvalidFilter() + + with pytest.raises(LiquidSyntaxError, match=r"'mock' is invalid"): + env.from_string("{{ x | mock }}") From c23a811bd0eb7760c2414d0458f41087d99f630c Mon Sep 17 00:00:00 2001 From: James Prior Date: Sat, 18 Jan 2025 09:59:35 +0000 Subject: [PATCH 03/14] Add an arrow token `=>` --- liquid2/lexer.py | 2 ++ liquid2/token.py | 30 +++++++++++++++++++++++------- tests/test_lexer.py | 21 ++++++++++++++++++--- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/liquid2/lexer.py b/liquid2/lexer.py index db380ff..59eadcb 100644 --- a/liquid2/lexer.py +++ b/liquid2/lexer.py @@ -69,6 +69,7 @@ class Lexer: ESCAPES = frozenset(["b", "f", "n", "r", "t", "u", "/", "\\", "$"]) SYMBOLS: dict[str, str] = { + "ARROW": r"=>", "GE": r">=", "LE": r"<=", "EQ": r"==", @@ -139,6 +140,7 @@ class Lexer: "PIPE": TokenType.PIPE, "EXCLAIM": TokenType.EXCLAIM, "QUESTION": TokenType.QUESTION, + "ARROW": TokenType.ARROW, } MARKUP: dict[str, str] = { diff --git a/liquid2/token.py b/liquid2/token.py index 927be26..ef25362 100644 --- a/liquid2/token.py +++ b/liquid2/token.py @@ -180,17 +180,22 @@ def __str__(self) -> str: def _expression_as_string(expression: list[TokenT]) -> str: buf: list[str] = [] + skip_next_space = False for token in expression: + if skip_next_space: + buf.append(str(token)) + skip_next_space = False + continue + if isinstance(token, Token): - if token.type_ == TokenType.SINGLE_QUOTE_STRING: - buf.append(f" '{token.value}'") - elif token.type_ == TokenType.DOUBLE_QUOTE_STRING: - buf.append(f' "{token.value}"') - elif token.type_ == TokenType.COMMA: - buf.append(",") # no leading space + if token.type_ in (TokenType.COMMA, TokenType.COLON, TokenType.RPAREN): + buf.append(str(token)) # no leading space else: - buf.append(f" {token.value}") + buf.append(f" {token}") + + if token.type_ == TokenType.LPAREN: + skip_next_space = True else: buf.append(f" {token}") @@ -215,6 +220,13 @@ class Token(TokenT): index: int source: str = field(repr=False) + def __str__(self) -> str: + if self.type_ == TokenType.SINGLE_QUOTE_STRING: + return f"'{self.value}'" + if self.type_ == TokenType.DOUBLE_QUOTE_STRING: + return f'"{self.value}"' + return self.value + @property def start(self) -> int: """Return the start position of this token.""" @@ -294,6 +306,9 @@ class RangeToken(TokenT): stop: int source: str = field(repr=False) + def __str__(self) -> str: + return f"({self.range_start}..{self.range_stop})" + @dataclass(kw_only=True, slots=True) class ErrorToken(TokenT): @@ -411,6 +426,7 @@ class TokenType(Enum): RANGE = auto() AND_WORD = auto() # and + ARROW = auto() # => AS = auto() ASSIGN = auto() # = COLON = auto() diff --git a/tests/test_lexer.py b/tests/test_lexer.py index 77f8982..7f9f2b1 100644 --- a/tests/test_lexer.py +++ b/tests/test_lexer.py @@ -90,12 +90,12 @@ class Case: Case( name="assign tag, filter", source="{% assign x = true | default: foo %}", - want="{% assign x = true | default : foo %}", + want="{% assign x = true | default: foo %}", ), Case( name="assign tag, filters", source="{% assign x = true | default: foo | upcase %}", - want="{% assign x = true | default : foo | upcase %}", + want="{% assign x = true | default: foo | upcase %}", ), Case( name="assign tag, condition", @@ -110,7 +110,7 @@ class Case: Case( name="assign tag, condition, tail filters", source="{% assign x = true if y || upcase | join : 'foo' %}", - want="{% assign x = true if y || upcase | join : 'foo' %}", + want="{% assign x = true if y || upcase | join: 'foo' %}", ), Case( name="assign tag, condition and alternative", @@ -202,6 +202,21 @@ class Case: source='{{ "${you}" }}', want='{{ "${you}" }}', ), + Case( + name="arrow expression", + source="{% assign x | map: i => i.foo.bar %}", + want="{% assign x | map: i => i.foo.bar %}", + ), + Case( + name="arrow expression, two arguments", + source="{% assign x | map: (item, index) => item.foo.bar %}", + want="{% assign x | map: (item, index) => item.foo.bar %}", + ), + Case( + name="range expression as filter argument", + source="{% assign x | foo: (1..4) %}", + want="{% assign x | foo: (1..4) %}", + ), ] From c2ed2f21cee07df9413d05bd368c1338c027c0b9 Mon Sep 17 00:00:00 2001 From: James Prior Date: Sat, 18 Jan 2025 14:28:42 +0000 Subject: [PATCH 04/14] Prototype `map` filter --- liquid2/builtin/__init__.py | 8 +- liquid2/builtin/expressions.py | 110 ++++++++++++++++- liquid2/builtin/filters/map_arrow.py | 112 ++++++++++++++++++ liquid2/filter.py | 29 +++-- liquid2/stream.py | 5 + liquid2/undefined.py | 1 + tests/liquid2-compliance-test-suite/cts.json | 72 +++++++++++ .../tests/filters/map.json | 54 +++++++++ tests/test_lexer.py | 12 +- 9 files changed, 382 insertions(+), 21 deletions(-) create mode 100644 liquid2/builtin/filters/map_arrow.py diff --git a/liquid2/builtin/__init__.py b/liquid2/builtin/__init__.py index e19e111..c61ac9d 100644 --- a/liquid2/builtin/__init__.py +++ b/liquid2/builtin/__init__.py @@ -7,6 +7,7 @@ from .comment import Comment from .content import Content +from .expressions import ArrowFunction from .expressions import Blank from .expressions import BooleanExpression from .expressions import Continue @@ -47,7 +48,7 @@ from .filters.array import first from .filters.array import join from .filters.array import last -from .filters.array import map_ +from .filters.array import map_ # noqa: F401 from .filters.array import reverse from .filters.array import sort from .filters.array import sort_natural @@ -59,6 +60,7 @@ from .filters.babel import DateTime from .filters.babel import Number from .filters.babel import Unit +from .filters.map_arrow import MapFilter from .filters.math import abs_ from .filters.math import at_least from .filters.math import at_most @@ -145,6 +147,7 @@ __all__ = ( "abs_", + "ArrowFunction", "AssignTag", "at_least", "at_most", @@ -249,7 +252,8 @@ def register_default_tags_and_filters(env: Environment) -> None: # noqa: PLR091 env.filters["first"] = first env.filters["last"] = last env.filters["concat"] = concat - env.filters["map"] = map_ + # env.filters["map"] = map_ + env.filters["map"] = MapFilter() env.filters["reverse"] = reverse env.filters["sort"] = sort env.filters["sort_natural"] = sort_natural diff --git a/liquid2/builtin/expressions.py b/liquid2/builtin/expressions.py index 774a322..f973820 100644 --- a/liquid2/builtin/expressions.py +++ b/liquid2/builtin/expressions.py @@ -6,10 +6,12 @@ import sys from decimal import Decimal from itertools import islice +from itertools import zip_longest from typing import TYPE_CHECKING from typing import Any from typing import Collection from typing import Generic +from typing import Iterable from typing import Iterator from typing import Mapping from typing import Sequence @@ -411,6 +413,85 @@ def children(self) -> list[Expression]: return self.template +class ArrowFunction(Expression): + __slots__ = ("params", "expression") + + def __init__(self, token: TokenT, params: list[Identifier], expression: Expression): + super().__init__(token) + self.params = params + self.expression = expression + + def __str__(self) -> str: + if len(self.params) == 1: + return f"{self.params[0]} => {self.expression}" + return f"({', '.join(self.params)}) => {self.expression}" + + def __hash__(self) -> int: + return hash((tuple(self.params), hash(self.expression))) + + def __sizeof__(self) -> int: + return sys.getsizeof(self.expression) + + def evaluate(self, _context: RenderContext) -> object: + return self + + def children(self) -> list[Expression]: + # XXX: This expression has its own scope, a scope that is not controlled by a + # tag. + return [self.expression] + + def getitem(self, context: RenderContext, args: Iterable[object]) -> object: + with context.extend(dict(zip_longest(self.params, args))): + # XXX: Ignoring potential `None` keys. + return self.expression.evaluate(context) + + async def getitem_async( + self, context: RenderContext, args: Iterable[object] + ) -> object: + # TODO: now we need async filters + with context.extend(dict(zip_longest(self.params, args))): + # XXX: Ignoring potential `None` keys. + return await self.expression.evaluate_async(context) + + @staticmethod + def parse(env: Environment, stream: TokenStream) -> ArrowFunction: + """Parse an arrow function from tokens in _stream_.""" + token = stream.next() + + if is_token_type(token, TokenType.WORD): + # A single param function without parens. + stream.expect(TokenType.ARROW) + stream.next() + expr = parse_boolean_primitive(env, stream) + stream.backup() + return ArrowFunction( + token, + [parse_identifier(token)], + expr, + ) + + assert token.type_ == TokenType.LPAREN + params: list[Identifier] = [] + + while stream.current().type_ != TokenType.RPAREN: + params.append(parse_identifier(stream.next())) + if stream.current().type_ == TokenType.COMMA: + stream.next() + + stream.expect(TokenType.RPAREN) + stream.next() + stream.expect(TokenType.ARROW) + stream.next() + expr = parse_boolean_primitive(env, stream) + stream.backup() + + return ArrowFunction( + token, + params, + expr, + ) + + RE_PROPERTY = re.compile(r"[\u0080-\uFFFFa-zA-Z_][\u0080-\uFFFFa-zA-Z0-9_-]*") Segments: TypeAlias = tuple[Union[str, int, "Segments"], ...] @@ -836,10 +917,26 @@ def parse( # noqa: PLR0912 # A named or keyword argument stream.next() # skip = or : stream.next() - filter_arguments.append( - KeywordArgument( - token.value, parse_primitive(env, stream.current()) + + if stream.peek().type_ == TokenType.ARROW: + filter_arguments.append( + KeywordArgument( + token.value, + ArrowFunction.parse(env, stream), + ) + ) + else: + filter_arguments.append( + KeywordArgument( + token.value, + parse_primitive(env, stream.current()), + ) ) + elif stream.peek().type_ == TokenType.ARROW: + # A positional argument that is an arrow function with a + # single parameter. + filter_arguments.append( + PositionalArgument(ArrowFunction.parse(env, stream)) ) else: # A positional query that is a single word @@ -864,10 +961,17 @@ def parse( # noqa: PLR0912 TokenType.FALSE, TokenType.TRUE, TokenType.NULL, + TokenType.RANGE, ): filter_arguments.append( PositionalArgument(parse_primitive(env, stream.current())) ) + elif token.type_ == TokenType.LPAREN: + # A positional argument that is an arrow function with + # parameters surrounded by parentheses. + filter_arguments.append( + PositionalArgument(ArrowFunction.parse(env, stream)) + ) elif token.type_ == TokenType.COMMA: # Leading, trailing and duplicate commas are OK pass diff --git a/liquid2/builtin/filters/map_arrow.py b/liquid2/builtin/filters/map_arrow.py new file mode 100644 index 0000000..5f4c5cd --- /dev/null +++ b/liquid2/builtin/filters/map_arrow.py @@ -0,0 +1,112 @@ +"""An implementation of a `map` filter that accepts an arrow function.""" + +from __future__ import annotations + +from operator import getitem +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable + +from liquid2.builtin import ArrowFunction +from liquid2.builtin import Null +from liquid2.builtin import Path +from liquid2.builtin import PositionalArgument +from liquid2.exceptions import LiquidSyntaxError +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + + +class _Null: + """A null without a token for use by the map filter.""" + + def __eq__(self, other: object) -> bool: + return other is None or isinstance(other, (_Null, Null)) + + def __str__(self) -> str: # pragma: no cover + return "" + + +_NULL = _Null() + + +def _getitem(sequence: Any, key: object, default: object = None) -> Any: + """Helper for the map filter. + + Same as sequence[key], but returns a default value if key does not exist + in sequence. + """ + try: + return getitem(sequence, key) + except (KeyError, IndexError): + return default + except TypeError: + if not hasattr(sequence, "__getitem__"): + raise + return default + + +class MapFilter: + """An implementation of a `map` filter that accepts an arrow function.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidSyntaxError` if _args_ are not valid.""" + if len(args) != 1: + raise LiquidSyntaxError( + f"{name!r} expects exactly one argument, got {len(args)}", + token=token, + ) + + if not isinstance(args[0], PositionalArgument): + raise LiquidSyntaxError( + f"{name!r} takes no keyword arguments", + token=token, + ) + + arg = args[0].value + + if isinstance(arg, ArrowFunction) and not isinstance(arg.expression, Path): + raise LiquidSyntaxError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + + def __call__( + self, + left: Iterable[object], + expr: str | ArrowFunction, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + if isinstance(expr, ArrowFunction): + if len(expr.params) == 1: + items = (expr.getitem(context, (item,)) for item in left) + else: + items = ( + expr.getitem(context, (item, index)) + for index, item in enumerate(left) + ) + + return [_NULL if is_undefined(item) else item for item in items] + + try: + return [_getitem(itm, str(expr), default=_NULL) for itm in left] + except TypeError as err: + raise LiquidTypeError("can't map sequence", token=None) from err diff --git a/liquid2/filter.py b/liquid2/filter.py index d625b86..c305e22 100644 --- a/liquid2/filter.py +++ b/liquid2/filter.py @@ -137,6 +137,22 @@ def wrapper(val: object, *args: Any, **kwargs: Any) -> Any: return wrapper +def sequence_arg(val: object) -> Iterable[object]: + """Return _val_ as an iterable.""" + if is_undefined(val): + val.poke() + return [] + if isinstance(val, str): + return list(val) + if isinstance(val, Sequence): + return _flatten(val) + if isinstance(val, Mapping): + return [val] + if isinstance(val, Iterable): + return val + return [val] + + def sequence_filter(_filter: Callable[..., Any]) -> Callable[..., Any]: """Coerce the left value to sequence. @@ -146,16 +162,7 @@ def sequence_filter(_filter: Callable[..., Any]) -> Callable[..., Any]: @wraps(_filter) def wrapper(val: object, *args: Any, **kwargs: Any) -> Any: - if is_undefined(val): - val.poke() - val = [] - elif isinstance(val, str): - val = list(val) - elif isinstance(val, Sequence): - val = _flatten(val) - elif isinstance(val, Mapping) or not isinstance(val, Iterable): - val = [val] - return _filter(val, *args, **kwargs) + return _filter(sequence_arg(val), *args, **kwargs) return wrapper @@ -165,6 +172,8 @@ def math_filter(_filter: Callable[..., Any]) -> Callable[..., Any]: @wraps(_filter) def wrapper(val: object, *args: Any, **kwargs: Any) -> Any: + if is_undefined(val): + val.poke() val = num_arg(val, default=0) return _filter(val, *args, **kwargs) diff --git a/liquid2/stream.py b/liquid2/stream.py index 1ea0c00..ebbcade 100644 --- a/liquid2/stream.py +++ b/liquid2/stream.py @@ -50,6 +50,11 @@ def peek(self) -> TokenT: except IndexError: return self.eoi + def backup(self) -> None: + """Go back one token.""" + if self.pos != 0: + self.pos -= 1 + def expect(self, typ: TokenType) -> None: """Raise a _LiquidSyntaxError_ if the current token type doesn't match _typ_.""" token = self.current() diff --git a/liquid2/undefined.py b/liquid2/undefined.py index 15fb641..8e1a533 100644 --- a/liquid2/undefined.py +++ b/liquid2/undefined.py @@ -109,6 +109,7 @@ class StrictUndefined(Undefined): allowed_properties = frozenset( [ "__repr__", + "__class__", "force_liquid_default", "name", "hint", diff --git a/tests/liquid2-compliance-test-suite/cts.json b/tests/liquid2-compliance-test-suite/cts.json index d3c27c9..5616758 100644 --- a/tests/liquid2-compliance-test-suite/cts.json +++ b/tests/liquid2-compliance-test-suite/cts.json @@ -2178,6 +2178,78 @@ }, "result": "foo" }, + { + "name": "filters, map, array of objects, lambda expression", + "template": "{{ a | map: i => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "foo#bar#baz" + }, + { + "name": "filters, map, array of objects, lambda expression, parentheses", + "template": "{{ a | map: (i) => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "foo#bar#baz" + }, + { + "name": "filters, map, array of objects, lambda expression, two params", + "template": "{{ a | map: (i, j) => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "foo#bar#baz" + }, { "name": "filters, minus, integer value and integer arg", "template": "{{ 10 | minus: 2 }}", diff --git a/tests/liquid2-compliance-test-suite/tests/filters/map.json b/tests/liquid2-compliance-test-suite/tests/filters/map.json index 05816b6..8c77c6e 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/map.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/map.json @@ -106,6 +106,60 @@ } }, "result": "foo" + }, + { + "name": "array of objects, lambda expression", + "template": "{{ a | map: i => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": "baz" } + } + ] + }, + "result": "foo#bar#baz" + }, + { + "name": "array of objects, lambda expression, parentheses", + "template": "{{ a | map: (i) => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": "baz" } + } + ] + }, + "result": "foo#bar#baz" + }, + { + "name": "array of objects, lambda expression, two params", + "template": "{{ a | map: (i, j) => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": "baz" } + } + ] + }, + "result": "foo#bar#baz" } ] } diff --git a/tests/test_lexer.py b/tests/test_lexer.py index 7f9f2b1..1778216 100644 --- a/tests/test_lexer.py +++ b/tests/test_lexer.py @@ -204,18 +204,18 @@ class Case: ), Case( name="arrow expression", - source="{% assign x | map: i => i.foo.bar %}", - want="{% assign x | map: i => i.foo.bar %}", + source="{% assign x = a | map: i => i.foo.bar %}", + want="{% assign x = a | map: i => i.foo.bar %}", ), Case( name="arrow expression, two arguments", - source="{% assign x | map: (item, index) => item.foo.bar %}", - want="{% assign x | map: (item, index) => item.foo.bar %}", + source="{% assign x = a | map: (item, index) => item.foo.bar %}", + want="{% assign x = a | map: (item, index) => item.foo.bar %}", ), Case( name="range expression as filter argument", - source="{% assign x | foo: (1..4) %}", - want="{% assign x | foo: (1..4) %}", + source="{% assign x = a | foo: (1..4) %}", + want="{% assign x = a | foo: (1..4) %}", ), ] From 9195447056e7bde68a9217ab44f9c9826a59905d Mon Sep 17 00:00:00 2001 From: James Prior Date: Sun, 19 Jan 2025 11:50:14 +0000 Subject: [PATCH 05/14] Move arrow function context wrangling to the filter --- liquid2/builtin/expressions.py | 15 ---------- liquid2/builtin/filters/map_arrow.py | 29 +++++++++++++------ tests/liquid2-compliance-test-suite/cts.json | 24 +++++++++++++++ .../tests/filters/map.json | 18 ++++++++++++ 4 files changed, 62 insertions(+), 24 deletions(-) diff --git a/liquid2/builtin/expressions.py b/liquid2/builtin/expressions.py index f973820..093b244 100644 --- a/liquid2/builtin/expressions.py +++ b/liquid2/builtin/expressions.py @@ -6,12 +6,10 @@ import sys from decimal import Decimal from itertools import islice -from itertools import zip_longest from typing import TYPE_CHECKING from typing import Any from typing import Collection from typing import Generic -from typing import Iterable from typing import Iterator from typing import Mapping from typing import Sequence @@ -440,19 +438,6 @@ def children(self) -> list[Expression]: # tag. return [self.expression] - def getitem(self, context: RenderContext, args: Iterable[object]) -> object: - with context.extend(dict(zip_longest(self.params, args))): - # XXX: Ignoring potential `None` keys. - return self.expression.evaluate(context) - - async def getitem_async( - self, context: RenderContext, args: Iterable[object] - ) -> object: - # TODO: now we need async filters - with context.extend(dict(zip_longest(self.params, args))): - # XXX: Ignoring potential `None` keys. - return await self.expression.evaluate_async(context) - @staticmethod def parse(env: Environment, stream: TokenStream) -> ArrowFunction: """Parse an arrow function from tokens in _stream_.""" diff --git a/liquid2/builtin/filters/map_arrow.py b/liquid2/builtin/filters/map_arrow.py index 5f4c5cd..2573bda 100644 --- a/liquid2/builtin/filters/map_arrow.py +++ b/liquid2/builtin/filters/map_arrow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from itertools import zip_longest from operator import getitem from typing import TYPE_CHECKING from typing import Any @@ -89,24 +90,34 @@ def validate( def __call__( self, left: Iterable[object], - expr: str | ArrowFunction, + arrow: str | ArrowFunction, *, context: RenderContext, ) -> list[object]: """Apply the filter and return the result.""" left = sequence_arg(left) - if isinstance(expr, ArrowFunction): - if len(expr.params) == 1: - items = (expr.getitem(context, (item,)) for item in left) + + if isinstance(arrow, ArrowFunction): + items: list[object] = [] + scope: dict[str, object] = {} + + if len(arrow.params) == 1: + param = arrow.params[0] + with context.extend(scope): + for item in left: + scope[param] = item + items.append(arrow.expression.evaluate(context)) else: - items = ( - expr.getitem(context, (item, index)) - for index, item in enumerate(left) - ) + name_param, index_param = arrow.params[:2] + with context.extend(scope): + for index, item in enumerate(left): + scope[index_param] = index + scope[name_param] = item + items.append(arrow.expression.evaluate(context)) return [_NULL if is_undefined(item) else item for item in items] try: - return [_getitem(itm, str(expr), default=_NULL) for itm in left] + return [_getitem(itm, str(arrow), default=_NULL) for itm in left] except TypeError as err: raise LiquidTypeError("can't map sequence", token=None) from err diff --git a/tests/liquid2-compliance-test-suite/cts.json b/tests/liquid2-compliance-test-suite/cts.json index 5616758..adc083c 100644 --- a/tests/liquid2-compliance-test-suite/cts.json +++ b/tests/liquid2-compliance-test-suite/cts.json @@ -2250,6 +2250,30 @@ }, "result": "foo#bar#baz" }, + { + "name": "filters, map, array of objects, lambda expression, map to index", + "template": "{{ a | map: (i, j) => j | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "0#1#2" + }, { "name": "filters, minus, integer value and integer arg", "template": "{{ 10 | minus: 2 }}", diff --git a/tests/liquid2-compliance-test-suite/tests/filters/map.json b/tests/liquid2-compliance-test-suite/tests/filters/map.json index 8c77c6e..7e55d67 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/map.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/map.json @@ -160,6 +160,24 @@ ] }, "result": "foo#bar#baz" + }, + { + "name": "array of objects, lambda expression, map to index", + "template": "{{ a | map: (i, j) => j | join: '#' }}", + "data": { + "a": [ + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": "baz" } + } + ] + }, + "result": "0#1#2" } ] } From 15ae79924cf7144a8f944e07c3a37410a9c35634 Mon Sep 17 00:00:00 2001 From: James Prior Date: Sun, 19 Jan 2025 15:38:15 +0000 Subject: [PATCH 06/14] Experimental `where`, `sort` and `sort_natural` filters --- liquid2/builtin/__init__.py | 22 ++- liquid2/builtin/expressions.py | 14 +- liquid2/builtin/filters/map_arrow.py | 25 ++- liquid2/builtin/filters/sort_arrow.py | 168 ++++++++++++++++++ liquid2/builtin/filters/where_arrow.py | 104 +++++++++++ liquid2/filter.py | 2 +- tests/liquid2-compliance-test-suite/cts.json | 162 +++++++++++++++++ .../tests/filters/sort.json | 36 ++++ .../tests/filters/sort_natural.json | 57 ++++-- .../tests/filters/where.json | 54 ++++++ tests/test_undefined.py | 6 + 11 files changed, 602 insertions(+), 48 deletions(-) create mode 100644 liquid2/builtin/filters/sort_arrow.py create mode 100644 liquid2/builtin/filters/where_arrow.py diff --git a/liquid2/builtin/__init__.py b/liquid2/builtin/__init__.py index c61ac9d..ddbc079 100644 --- a/liquid2/builtin/__init__.py +++ b/liquid2/builtin/__init__.py @@ -7,7 +7,6 @@ from .comment import Comment from .content import Content -from .expressions import ArrowFunction from .expressions import Blank from .expressions import BooleanExpression from .expressions import Continue @@ -20,6 +19,7 @@ from .expressions import Identifier from .expressions import IntegerLiteral from .expressions import KeywordArgument +from .expressions import LambdaExpression from .expressions import Literal from .expressions import LogicalAndExpression from .expressions import LogicalNotExpression @@ -50,12 +50,12 @@ from .filters.array import last from .filters.array import map_ # noqa: F401 from .filters.array import reverse -from .filters.array import sort -from .filters.array import sort_natural +from .filters.array import sort # noqa: F401 +from .filters.array import sort_natural # noqa: F401 from .filters.array import sort_numeric from .filters.array import sum_ from .filters.array import uniq -from .filters.array import where +from .filters.array import where # noqa: F401 from .filters.babel import Currency from .filters.babel import DateTime from .filters.babel import Number @@ -76,6 +76,8 @@ from .filters.misc import date from .filters.misc import default from .filters.misc import size +from .filters.sort_arrow import SortFilter +from .filters.sort_arrow import SortNaturalFilter from .filters.string import append from .filters.string import capitalize from .filters.string import downcase @@ -108,6 +110,7 @@ from .filters.translate import NPGetText from .filters.translate import PGetText from .filters.translate import Translate +from .filters.where_arrow import WhereFilter from .loaders.caching_file_system_loader import CachingFileSystemLoader from .loaders.choice_loader import CachingChoiceLoader from .loaders.choice_loader import ChoiceLoader @@ -147,7 +150,7 @@ __all__ = ( "abs_", - "ArrowFunction", + "LambdaExpression", "AssignTag", "at_least", "at_most", @@ -255,11 +258,14 @@ def register_default_tags_and_filters(env: Environment) -> None: # noqa: PLR091 # env.filters["map"] = map_ env.filters["map"] = MapFilter() env.filters["reverse"] = reverse - env.filters["sort"] = sort - env.filters["sort_natural"] = sort_natural + # env.filters["sort"] = sort + env.filters["sort"] = SortFilter() + # env.filters["sort_natural"] = sort_natural + env.filters["sort_natural"] = SortNaturalFilter() env.filters["sort_numeric"] = sort_numeric env.filters["sum"] = sum_ - env.filters["where"] = where + # env.filters["where"] = where + env.filters["where"] = WhereFilter() env.filters["uniq"] = uniq env.filters["compact"] = compact diff --git a/liquid2/builtin/expressions.py b/liquid2/builtin/expressions.py index 093b244..3ae06c9 100644 --- a/liquid2/builtin/expressions.py +++ b/liquid2/builtin/expressions.py @@ -411,7 +411,7 @@ def children(self) -> list[Expression]: return self.template -class ArrowFunction(Expression): +class LambdaExpression(Expression): __slots__ = ("params", "expression") def __init__(self, token: TokenT, params: list[Identifier], expression: Expression): @@ -439,7 +439,7 @@ def children(self) -> list[Expression]: return [self.expression] @staticmethod - def parse(env: Environment, stream: TokenStream) -> ArrowFunction: + def parse(env: Environment, stream: TokenStream) -> LambdaExpression: """Parse an arrow function from tokens in _stream_.""" token = stream.next() @@ -449,7 +449,7 @@ def parse(env: Environment, stream: TokenStream) -> ArrowFunction: stream.next() expr = parse_boolean_primitive(env, stream) stream.backup() - return ArrowFunction( + return LambdaExpression( token, [parse_identifier(token)], expr, @@ -470,7 +470,7 @@ def parse(env: Environment, stream: TokenStream) -> ArrowFunction: expr = parse_boolean_primitive(env, stream) stream.backup() - return ArrowFunction( + return LambdaExpression( token, params, expr, @@ -907,7 +907,7 @@ def parse( # noqa: PLR0912 filter_arguments.append( KeywordArgument( token.value, - ArrowFunction.parse(env, stream), + LambdaExpression.parse(env, stream), ) ) else: @@ -921,7 +921,7 @@ def parse( # noqa: PLR0912 # A positional argument that is an arrow function with a # single parameter. filter_arguments.append( - PositionalArgument(ArrowFunction.parse(env, stream)) + PositionalArgument(LambdaExpression.parse(env, stream)) ) else: # A positional query that is a single word @@ -955,7 +955,7 @@ def parse( # noqa: PLR0912 # A positional argument that is an arrow function with # parameters surrounded by parentheses. filter_arguments.append( - PositionalArgument(ArrowFunction.parse(env, stream)) + PositionalArgument(LambdaExpression.parse(env, stream)) ) elif token.type_ == TokenType.COMMA: # Leading, trailing and duplicate commas are OK diff --git a/liquid2/builtin/filters/map_arrow.py b/liquid2/builtin/filters/map_arrow.py index 2573bda..d6a6c67 100644 --- a/liquid2/builtin/filters/map_arrow.py +++ b/liquid2/builtin/filters/map_arrow.py @@ -1,14 +1,13 @@ -"""An implementation of a `map` filter that accepts an arrow function.""" +"""An implementation of the `map` filter that accepts lambda expressions.""" from __future__ import annotations -from itertools import zip_longest from operator import getitem from typing import TYPE_CHECKING from typing import Any from typing import Iterable -from liquid2.builtin import ArrowFunction +from liquid2.builtin import LambdaExpression from liquid2.builtin import Null from liquid2.builtin import Path from liquid2.builtin import PositionalArgument @@ -54,7 +53,7 @@ def _getitem(sequence: Any, key: object, default: object = None) -> Any: class MapFilter: - """An implementation of a `map` filter that accepts an arrow function.""" + """An implementation of the `map` filter that accepts lambda expressions.""" with_context = True @@ -80,7 +79,7 @@ def validate( arg = args[0].value - if isinstance(arg, ArrowFunction) and not isinstance(arg.expression, Path): + if isinstance(arg, LambdaExpression) and not isinstance(arg.expression, Path): raise LiquidSyntaxError( f"{name!r} expects a path to a variable, " f"got {arg.expression.__class__.__name__}", @@ -90,34 +89,34 @@ def validate( def __call__( self, left: Iterable[object], - arrow: str | ArrowFunction, + first: str | LambdaExpression, *, context: RenderContext, ) -> list[object]: """Apply the filter and return the result.""" left = sequence_arg(left) - if isinstance(arrow, ArrowFunction): + if isinstance(first, LambdaExpression): items: list[object] = [] scope: dict[str, object] = {} - if len(arrow.params) == 1: - param = arrow.params[0] + if len(first.params) == 1: + param = first.params[0] with context.extend(scope): for item in left: scope[param] = item - items.append(arrow.expression.evaluate(context)) + items.append(first.expression.evaluate(context)) else: - name_param, index_param = arrow.params[:2] + name_param, index_param = first.params[:2] with context.extend(scope): for index, item in enumerate(left): scope[index_param] = index scope[name_param] = item - items.append(arrow.expression.evaluate(context)) + items.append(first.expression.evaluate(context)) return [_NULL if is_undefined(item) else item for item in items] try: - return [_getitem(itm, str(arrow), default=_NULL) for itm in left] + return [_getitem(itm, str(first), default=_NULL) for itm in left] except TypeError as err: raise LiquidTypeError("can't map sequence", token=None) from err diff --git a/liquid2/builtin/filters/sort_arrow.py b/liquid2/builtin/filters/sort_arrow.py new file mode 100644 index 0000000..623b0bf --- /dev/null +++ b/liquid2/builtin/filters/sort_arrow.py @@ -0,0 +1,168 @@ +"""Implementations of `sort` and `sort_natural` filters accepting lambda expressions.""" + +from __future__ import annotations + +from functools import partial +from operator import getitem +from operator import itemgetter +from typing import TYPE_CHECKING +from typing import Any + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import Path +from liquid2.builtin import PositionalArgument +from liquid2.exceptions import LiquidSyntaxError +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + +# Send objects with missing keys to the end when sorting a list. +MAX_CH = chr(0x10FFFF) + + +def _getitem(sequence: Any, key: object, default: object = None) -> Any: + """Helper for the sort filter. + + Same as sequence[key], but returns a default value if key does not exist + in sequence. + """ + try: + return getitem(sequence, key) + except (KeyError, IndexError): + return default + except TypeError: + if not hasattr(sequence, "__getitem__"): + raise + return default + + +def _lower(obj: Any) -> str: + """Helper for the sort filter.""" + try: + return str(obj).lower() + except AttributeError: + return "" + + +class SortFilter: + """An implementation of the `sort` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidSyntaxError` if _args_ are not valid.""" + if len(args) > 1: + raise LiquidSyntaxError( + f"{name!r} expects at most one argument, got {len(args)}", + token=token, + ) + + if len(args) == 1: + arg = args[0].value + if isinstance(arg, LambdaExpression) and not isinstance( + arg.expression, Path + ): + raise LiquidSyntaxError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + + def __call__( + self, + left: object, + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + items: list[tuple[object, object]] = [] + scope: dict[str, object] = {} + + if len(key.params) == 1: + param = key.params[0] + with context.extend(scope): + for item in left: + scope[param] = item + rv = key.expression.evaluate(context) + items.append((item, MAX_CH if is_undefined(rv) else rv)) + else: + name_param, index_param = key.params[:2] + with context.extend(scope): + for index, item in enumerate(left): + scope[index_param] = index + scope[name_param] = item + rv = key.expression.evaluate(context) + items.append((item, MAX_CH if is_undefined(rv) else rv)) + + return [item[0] for item in sorted(items, key=itemgetter(1))] + + if key: + key_func = partial(_getitem, key=str(key), default=MAX_CH) + return sorted(left, key=key_func) + + try: + return sorted(left) + except TypeError as err: + raise LiquidTypeError("can't sort sequence", token=None) from err + + +class SortNaturalFilter(SortFilter): + """An implementation of the `sort` filter that accepts a lambda expression.""" + + def __call__( + self, + left: object, + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + items: list[tuple[object, object]] = [] + scope: dict[str, object] = {} + + if len(key.params) == 1: + param = key.params[0] + with context.extend(scope): + for item in left: + scope[param] = item + rv = key.expression.evaluate(context) + items.append( + (item, MAX_CH if is_undefined(rv) else str(rv).lower()) + ) + else: + name_param, index_param = key.params[:2] + with context.extend(scope): + for index, item in enumerate(left): + scope[index_param] = index + scope[name_param] = item + rv = key.expression.evaluate(context) + items.append( + (item, MAX_CH if is_undefined(rv) else str(rv).lower()) + ) + + return [item[0] for item in sorted(items, key=itemgetter(1))] + + if key: + item_getter = partial(_getitem, key=str(key), default=MAX_CH) + return sorted(left, key=lambda obj: _lower(item_getter(obj))) + + return sorted(left, key=_lower) diff --git a/liquid2/builtin/filters/where_arrow.py b/liquid2/builtin/filters/where_arrow.py new file mode 100644 index 0000000..ef75b00 --- /dev/null +++ b/liquid2/builtin/filters/where_arrow.py @@ -0,0 +1,104 @@ +"""An implementation of the `where` filter that accepts lambda expressions.""" + +from __future__ import annotations + +from operator import getitem +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import PositionalArgument +from liquid2.builtin.expressions import is_truthy +from liquid2.exceptions import LiquidSyntaxError +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + + +def _getitem(sequence: Any, key: object, default: object = None) -> Any: + """Helper for the where filter. + + Same as sequence[key], but returns a default value if key does not exist + in sequence. + """ + try: + return getitem(sequence, key) + except (KeyError, IndexError): + return default + except TypeError: + if not hasattr(sequence, "__getitem__"): + raise + return default + + +class WhereFilter: + """An implementation of the `where` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidSyntaxError` if _args_ are not valid.""" + if len(args) not in (1, 2): + raise LiquidSyntaxError( + f"{name!r} expects one or two arguments, got {len(args)}", + token=token, + ) + + arg = args[0].value + + if isinstance(arg, LambdaExpression) and len(args) != 1: + raise LiquidSyntaxError( + f"{name!r} expects one argument when given a lambda expressions", + token=args[1].token, + ) + + def __call__( + self, + left: Iterable[object], + first: str | LambdaExpression, + value: object = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(first, LambdaExpression): + items: list[object] = [] + scope: dict[str, object] = {} + + if len(first.params) == 1: + param = first.params[0] + with context.extend(scope): + for item in left: + scope[param] = item + rv = first.expression.evaluate(context) + if not is_undefined(rv) and is_truthy(rv): + items.append(item) + else: + name_param, index_param = first.params[:2] + with context.extend(scope): + for index, item in enumerate(left): + scope[index_param] = index + scope[name_param] = item + rv = first.expression.evaluate(context) + if not is_undefined(rv) and is_truthy(rv): + items.append(item) + + return items + + if value is not None and not is_undefined(value): + return [itm for itm in left if _getitem(itm, first) == value] + return [itm for itm in left if _getitem(itm, first) not in (False, None)] diff --git a/liquid2/filter.py b/liquid2/filter.py index c305e22..f2ba828 100644 --- a/liquid2/filter.py +++ b/liquid2/filter.py @@ -137,7 +137,7 @@ def wrapper(val: object, *args: Any, **kwargs: Any) -> Any: return wrapper -def sequence_arg(val: object) -> Iterable[object]: +def sequence_arg(val: object) -> Iterable[Any]: """Return _val_ as an iterable.""" if is_undefined(val): val.poke() diff --git a/tests/liquid2-compliance-test-suite/cts.json b/tests/liquid2-compliance-test-suite/cts.json index adc083c..63853e9 100644 --- a/tests/liquid2-compliance-test-suite/cts.json +++ b/tests/liquid2-compliance-test-suite/cts.json @@ -3340,6 +3340,54 @@ }, "invalid": true }, + { + "name": "filters, sort, array of objects, lambda expression", + "template": "{% assign x = a | sort: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,Baz)(user.title,bar)(user.title,foo)" + }, + { + "name": "filters, sort, array of objects, lambda expression, all missing", + "template": "{% assign x = a | sort: i => i.user.foo %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,foo)(user.title,bar)(user.title,Baz)" + }, { "name": "filters, sort natural, array of strings", "template": "{{ a | sort_natural | join: '#' }}", @@ -3475,6 +3523,54 @@ }, "result": "14{}" }, + { + "name": "filters, sort natural, array of objects, lambda expression", + "template": "{% assign x = a | sort_natural: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,bar)(user.title,Baz)(user.title,foo)" + }, + { + "name": "filters, sort natural, array of objects, lambda expression, all missing", + "template": "{% assign x = a | sort_natural: i => i.user.foo %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,foo)(user.title,bar)(user.title,Baz)" + }, { "name": "filters, split, split string", "template": "{{ \"Hi, how are you today?\" | split: \" \" | join: \"#\" }}", @@ -4373,6 +4469,24 @@ }, "invalid": true }, + { + "name": "filters, where, arrow function, two arguments", + "template": "{{ a | i => i.foo.bar, x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "invalid": true + }, { "name": "filters, where, left value is undefined", "template": "{{ nosuchthing | where: 'title' }}", @@ -4469,6 +4583,54 @@ }, "result": "(b,bar)" }, + { + "name": "filters, where, array of hashes, lambda expression", + "template": "{% assign x = a | where: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": null + } + } + ] + }, + "result": "(user.title,foo)(user.title,bar)" + }, + { + "name": "filters, where, array of hashes, lambda expression, two arguments", + "template": "{% assign x = a | where: (item, index) => index > 0 %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": null + } + } + ] + }, + "result": "(user.title,bar)(user.title,)" + }, { "name": "tags, assign, string literal", "template": "{% assign a = 'b' %}{{ a }}", diff --git a/tests/liquid2-compliance-test-suite/tests/filters/sort.json b/tests/liquid2-compliance-test-suite/tests/filters/sort.json index ea63045..845bf1f 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/sort.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/sort.json @@ -103,6 +103,42 @@ "a": [[], {}, 1, "4"] }, "invalid": true + }, + { + "name": "array of objects, lambda expression", + "template": "{% assign x = a | sort: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": "Baz" } + } + ] + }, + "result": "(user.title,Baz)(user.title,bar)(user.title,foo)" + }, + { + "name": "array of objects, lambda expression, all missing", + "template": "{% assign x = a | sort: i => i.user.foo %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": "Baz" } + } + ] + }, + "result": "(user.title,foo)(user.title,bar)(user.title,Baz)" } ] } diff --git a/tests/liquid2-compliance-test-suite/tests/filters/sort_natural.json b/tests/liquid2-compliance-test-suite/tests/filters/sort_natural.json index 28ac892..21b2557 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/sort_natural.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/sort_natural.json @@ -4,13 +4,7 @@ "name": "array of strings", "template": "{{ a | sort_natural | join: '#' }}", "data": { - "a": [ - "b", - "a", - "C", - "B", - "A" - ] + "a": ["b", "a", "C", "B", "A"] }, "result": "a#A#b#B#C" }, @@ -18,14 +12,7 @@ "name": "array of strings with a nul", "template": "{% assign x = a | sort_natural %}{% for i in x %}{{ i }}{% unless forloop.last %}#{% endunless %}{% endfor %}", "data": { - "a": [ - "b", - "a", - null, - "C", - "B", - "A" - ] + "a": ["b", "a", null, "C", "B", "A"] }, "result": "a#A#b#B#C#" }, @@ -126,14 +113,46 @@ { "name": "incompatible types", "template": "{{ a | sort_natural }}", + "data": { + "a": [{}, 1, "4"] + }, + "result": "14{}" + }, + { + "name": "array of objects, lambda expression", + "template": "{% assign x = a | sort_natural: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": "Baz" } + } + ] + }, + "result": "(user.title,bar)(user.title,Baz)(user.title,foo)" + }, + { + "name": "array of objects, lambda expression, all missing", + "template": "{% assign x = a | sort_natural: i => i.user.foo %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", "data": { "a": [ - {}, - 1, - "4" + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": "Baz" } + } ] }, - "result": "14{}" + "result": "(user.title,foo)(user.title,bar)(user.title,Baz)" } ] } diff --git a/tests/liquid2-compliance-test-suite/tests/filters/where.json b/tests/liquid2-compliance-test-suite/tests/filters/where.json index 1f27931..097b5f8 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/where.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/where.json @@ -98,6 +98,24 @@ }, "invalid": true }, + { + "name": "arrow function, two arguments", + "template": "{{ a | i => i.foo.bar, x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "invalid": true + }, { "name": "left value is undefined", "template": "{{ nosuchthing | where: 'title' }}", @@ -193,6 +211,42 @@ ] }, "result": "(b,bar)" + }, + { + "name": "array of hashes, lambda expression", + "template": "{% assign x = a | where: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": null } + } + ] + }, + "result": "(user.title,foo)(user.title,bar)" + }, + { + "name": "array of hashes, lambda expression, two arguments", + "template": "{% assign x = a | where: (item, index) => index > 0 %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": null } + } + ] + }, + "result": "(user.title,bar)(user.title,)" } ] } diff --git a/tests/test_undefined.py b/tests/test_undefined.py index 3c9e2de..0bba687 100644 --- a/tests/test_undefined.py +++ b/tests/test_undefined.py @@ -235,6 +235,12 @@ def test_strict_undefined_with_default() -> None: assert template.render() == "hello" +def test_where_filter_lambda_with_strict_undefined() -> None: + env = Environment(undefined=StrictUndefined) + template = env.from_string("{{ (1..3) | where: i => i.nosuchthing | join: '#' }}") + assert template.render() == "" + + def test_strict_undefined_magic() -> None: undefined = StrictUndefined("test", token=None) From b9986eefea6d6e91d1958e13ab3a8f2731609ee0 Mon Sep 17 00:00:00 2001 From: James Prior Date: Mon, 20 Jan 2025 09:09:05 +0000 Subject: [PATCH 07/14] More filters accepting lambda expression arguments --- liquid2/builtin/__init__.py | 18 ++- liquid2/builtin/filters/compact_arrow.py | 95 ++++++++++++ liquid2/builtin/filters/map_arrow.py | 4 +- liquid2/builtin/filters/sort_arrow.py | 27 ++-- liquid2/builtin/filters/sum_arrow.py | 109 +++++++++++++ liquid2/builtin/filters/uniq_arrow.py | 121 +++++++++++++++ liquid2/builtin/filters/where_arrow.py | 4 +- liquid2/filter.py | 6 +- tests/liquid2-compliance-test-suite/cts.json | 144 ++++++++++++++++++ .../tests/filters/compact.json | 49 +++++- .../tests/filters/map.json | 18 +++ .../tests/filters/sum.json | 78 +++++----- .../tests/filters/uniq.json | 64 +++++--- 13 files changed, 650 insertions(+), 87 deletions(-) create mode 100644 liquid2/builtin/filters/compact_arrow.py create mode 100644 liquid2/builtin/filters/sum_arrow.py create mode 100644 liquid2/builtin/filters/uniq_arrow.py diff --git a/liquid2/builtin/__init__.py b/liquid2/builtin/__init__.py index ddbc079..8f7408f 100644 --- a/liquid2/builtin/__init__.py +++ b/liquid2/builtin/__init__.py @@ -43,7 +43,7 @@ from .expressions import parse_primitive from .expressions import parse_string_or_identifier from .expressions import parse_string_or_path -from .filters.array import compact +from .filters.array import compact # noqa: F401 from .filters.array import concat from .filters.array import first from .filters.array import join @@ -53,13 +53,14 @@ from .filters.array import sort # noqa: F401 from .filters.array import sort_natural # noqa: F401 from .filters.array import sort_numeric -from .filters.array import sum_ -from .filters.array import uniq +from .filters.array import sum_ # noqa: F401 +from .filters.array import uniq # noqa: F401 from .filters.array import where # noqa: F401 from .filters.babel import Currency from .filters.babel import DateTime from .filters.babel import Number from .filters.babel import Unit +from .filters.compact_arrow import CompactFilter from .filters.map_arrow import MapFilter from .filters.math import abs_ from .filters.math import at_least @@ -104,12 +105,14 @@ from .filters.string import upcase from .filters.string import url_decode from .filters.string import url_encode +from .filters.sum_arrow import SumFilter from .filters.translate import BaseTranslateFilter from .filters.translate import GetText from .filters.translate import NGetText from .filters.translate import NPGetText from .filters.translate import PGetText from .filters.translate import Translate +from .filters.uniq_arrow import UniqFilter from .filters.where_arrow import WhereFilter from .loaders.caching_file_system_loader import CachingFileSystemLoader from .loaders.choice_loader import CachingChoiceLoader @@ -263,11 +266,14 @@ def register_default_tags_and_filters(env: Environment) -> None: # noqa: PLR091 # env.filters["sort_natural"] = sort_natural env.filters["sort_natural"] = SortNaturalFilter() env.filters["sort_numeric"] = sort_numeric - env.filters["sum"] = sum_ + # env.filters["sum"] = sum_ + env.filters["sum"] = SumFilter() # env.filters["where"] = where env.filters["where"] = WhereFilter() - env.filters["uniq"] = uniq - env.filters["compact"] = compact + # env.filters["uniq"] = uniq + env.filters["uniq"] = UniqFilter() + # env.filters["compact"] = compact + env.filters["compact"] = CompactFilter() env.filters["abs"] = abs_ env.filters["at_least"] = at_least diff --git a/liquid2/builtin/filters/compact_arrow.py b/liquid2/builtin/filters/compact_arrow.py new file mode 100644 index 0000000..7d8a961 --- /dev/null +++ b/liquid2/builtin/filters/compact_arrow.py @@ -0,0 +1,95 @@ +"""An implementation of the `compact` filter that accepts lambda expressions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Iterable + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import Path +from liquid2.builtin import PositionalArgument +from liquid2.exceptions import LiquidSyntaxError +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + + +class CompactFilter: + """An implementation of the `compact` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidSyntaxError` if _args_ are not valid.""" + if len(args) > 1: + raise LiquidSyntaxError( + f"{name!r} expects at most one argument, got {len(args)}", + token=token, + ) + + if len(args) == 1: + arg = args[0].value + + if isinstance(arg, LambdaExpression) and not isinstance( + arg.expression, Path + ): + raise LiquidSyntaxError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + items: list[object] = [] + scope: dict[str, object] = {} + + if len(key.params) == 1: + param = key.params[0] + with context.extend(scope): + for item in left: + scope[param] = item + rv = key.expression.evaluate(context) + if not is_undefined(rv) and rv is not None: + items.append(item) + else: + name_param, index_param = key.params[:2] + with context.extend(scope): + for index, item in enumerate(left): + scope[index_param] = index + scope[name_param] = item + rv = key.expression.evaluate(context) + if not is_undefined(rv) and rv is not None: + items.append(item) + + return items + + if key is not None: + try: + return [itm for itm in left if itm[key] is not None] + except TypeError as err: + raise LiquidTypeError( + f"can't read property '{key}'", token=None + ) from err + return [itm for itm in left if itm is not None] diff --git a/liquid2/builtin/filters/map_arrow.py b/liquid2/builtin/filters/map_arrow.py index d6a6c67..0cf2f4a 100644 --- a/liquid2/builtin/filters/map_arrow.py +++ b/liquid2/builtin/filters/map_arrow.py @@ -39,8 +39,8 @@ def __str__(self) -> str: # pragma: no cover def _getitem(sequence: Any, key: object, default: object = None) -> Any: """Helper for the map filter. - Same as sequence[key], but returns a default value if key does not exist - in sequence. + Same as obj[key], but returns a default value if key does not exist + in obj. """ try: return getitem(sequence, key) diff --git a/liquid2/builtin/filters/sort_arrow.py b/liquid2/builtin/filters/sort_arrow.py index 623b0bf..1ef952b 100644 --- a/liquid2/builtin/filters/sort_arrow.py +++ b/liquid2/builtin/filters/sort_arrow.py @@ -23,21 +23,21 @@ from liquid2.builtin import KeywordArgument # Send objects with missing keys to the end when sorting a list. -MAX_CH = chr(0x10FFFF) +_MAX_CH = chr(0x10FFFF) -def _getitem(sequence: Any, key: object, default: object = None) -> Any: +def _getitem(obj: Any, key: object, default: object = None) -> Any: """Helper for the sort filter. - Same as sequence[key], but returns a default value if key does not exist - in sequence. + Same as obj[key], but returns a default value if key does not exist + in obj. """ try: - return getitem(sequence, key) + return getitem(obj, key) except (KeyError, IndexError): return default except TypeError: - if not hasattr(sequence, "__getitem__"): + if not hasattr(obj, "__getitem__"): raise return default @@ -100,7 +100,7 @@ def __call__( for item in left: scope[param] = item rv = key.expression.evaluate(context) - items.append((item, MAX_CH if is_undefined(rv) else rv)) + items.append((item, _MAX_CH if is_undefined(rv) else rv)) else: name_param, index_param = key.params[:2] with context.extend(scope): @@ -108,12 +108,12 @@ def __call__( scope[index_param] = index scope[name_param] = item rv = key.expression.evaluate(context) - items.append((item, MAX_CH if is_undefined(rv) else rv)) + items.append((item, _MAX_CH if is_undefined(rv) else rv)) return [item[0] for item in sorted(items, key=itemgetter(1))] if key: - key_func = partial(_getitem, key=str(key), default=MAX_CH) + key_func = partial(_getitem, key=str(key), default=_MAX_CH) return sorted(left, key=key_func) try: @@ -146,7 +146,7 @@ def __call__( scope[param] = item rv = key.expression.evaluate(context) items.append( - (item, MAX_CH if is_undefined(rv) else str(rv).lower()) + (item, _MAX_CH if is_undefined(rv) else str(rv).lower()) ) else: name_param, index_param = key.params[:2] @@ -156,13 +156,16 @@ def __call__( scope[name_param] = item rv = key.expression.evaluate(context) items.append( - (item, MAX_CH if is_undefined(rv) else str(rv).lower()) + (item, _MAX_CH if is_undefined(rv) else str(rv).lower()) ) return [item[0] for item in sorted(items, key=itemgetter(1))] if key: - item_getter = partial(_getitem, key=str(key), default=MAX_CH) + item_getter = partial(_getitem, key=str(key), default=_MAX_CH) return sorted(left, key=lambda obj: _lower(item_getter(obj))) return sorted(left, key=_lower) + + +# TODO: class SortNumericFilter(SortFilter): diff --git a/liquid2/builtin/filters/sum_arrow.py b/liquid2/builtin/filters/sum_arrow.py new file mode 100644 index 0000000..5386a6a --- /dev/null +++ b/liquid2/builtin/filters/sum_arrow.py @@ -0,0 +1,109 @@ +"""An implementation of the `sum` filter that accepts lambda expressions.""" + +from __future__ import annotations + +from decimal import Decimal +from operator import getitem +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import Path +from liquid2.builtin import PositionalArgument +from liquid2.exceptions import LiquidSyntaxError +from liquid2.filter import decimal_arg +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + + +def _getitem(sequence: Any, key: object, default: object = None) -> Any: + """Helper for the sum filter. + + Same as obj[key], but returns a default value if key does not exist + in obj. + """ + try: + return getitem(sequence, key) + except (KeyError, IndexError): + return default + except TypeError: + if not hasattr(sequence, "__getitem__"): + raise + return default + + +class SumFilter: + """An implementation of the `sum` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidSyntaxError` if _args_ are not valid.""" + if len(args) > 1: + raise LiquidSyntaxError( + f"{name!r} expects at most one argument, got {len(args)}", + token=token, + ) + + if len(args) == 1: + arg = args[0].value + + if isinstance(arg, LambdaExpression) and not isinstance( + arg.expression, Path + ): + raise LiquidSyntaxError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> float | int: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + items: list[object] = [] + scope: dict[str, object] = {} + + if len(key.params) == 1: + param = key.params[0] + with context.extend(scope): + for item in left: + scope[param] = item + items.append(key.expression.evaluate(context)) + else: + name_param, index_param = key.params[:2] + with context.extend(scope): + for index, item in enumerate(left): + scope[index_param] = index + scope[name_param] = item + items.append(key.expression.evaluate(context)) + + rv = sum(decimal_arg(item, 0) for item in items if not is_undefined(item)) + elif key is not None and not is_undefined(key): + rv = sum(decimal_arg(_getitem(elem, key, 0), 0) for elem in left) + else: + rv = sum(decimal_arg(elem, 0) for elem in left) + + if isinstance(rv, Decimal): + return float(rv) + return rv diff --git a/liquid2/builtin/filters/uniq_arrow.py b/liquid2/builtin/filters/uniq_arrow.py new file mode 100644 index 0000000..be25e8e --- /dev/null +++ b/liquid2/builtin/filters/uniq_arrow.py @@ -0,0 +1,121 @@ +"""An implementation of the `uniq` filter that accepts lambda expressions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Iterable + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import Path +from liquid2.builtin import PositionalArgument +from liquid2.exceptions import LiquidSyntaxError +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + +MISSING = object() + + +class UniqFilter: + """An implementation of the `uniq` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidSyntaxError` if _args_ are not valid.""" + if len(args) > 1: + raise LiquidSyntaxError( + f"{name!r} expects at most one argument, got {len(args)}", + token=token, + ) + + if len(args) == 1: + arg = args[0].value + + if isinstance(arg, LambdaExpression) and not isinstance( + arg.expression, Path + ): + raise LiquidSyntaxError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + # Note that we're not using a dict or set for deduplication because we need + # to handle sequences containing unhashable objects, like dictionaries and + # lists. + + # This is probably quite slow. + + if isinstance(key, LambdaExpression): + keys: list[object] = [] + items: list[object] = [] + scope: dict[str, object] = {} + + if len(key.params) == 1: + param = key.params[0] + with context.extend(scope): + for item in left: + scope[param] = item + rv = key.expression.evaluate(context) + current_key = MISSING if is_undefined(rv) else rv + if current_key not in keys: + keys.append(current_key) + items.append(item) + else: + name_param, index_param = key.params[:2] + with context.extend(scope): + for index, item in enumerate(left): + scope[index_param] = index + scope[name_param] = item + rv = key.expression.evaluate(context) + current_key = MISSING if is_undefined(rv) else rv + if current_key not in keys: + keys.append(current_key) + items.append(item) + + return items + + if key is not None: + keys = [] + result = [] + for obj in left: + try: + item = obj[key] + except KeyError: + item = MISSING + except TypeError as err: + raise LiquidTypeError( + f"can't read property '{key}' of {obj}", + token=None, + ) from err + + if item not in keys: + keys.append(item) + result.append(obj) + + return result + + return [obj for i, obj in enumerate(left) if left.index(obj) == i] diff --git a/liquid2/builtin/filters/where_arrow.py b/liquid2/builtin/filters/where_arrow.py index ef75b00..ad02112 100644 --- a/liquid2/builtin/filters/where_arrow.py +++ b/liquid2/builtin/filters/where_arrow.py @@ -24,8 +24,8 @@ def _getitem(sequence: Any, key: object, default: object = None) -> Any: """Helper for the where filter. - Same as sequence[key], but returns a default value if key does not exist - in sequence. + Same as obj[key], but returns a default value if key does not exist + in obj. """ try: return getitem(sequence, key) diff --git a/liquid2/filter.py b/liquid2/filter.py index f2ba828..4ede009 100644 --- a/liquid2/filter.py +++ b/liquid2/filter.py @@ -137,8 +137,8 @@ def wrapper(val: object, *args: Any, **kwargs: Any) -> Any: return wrapper -def sequence_arg(val: object) -> Iterable[Any]: - """Return _val_ as an iterable.""" +def sequence_arg(val: object) -> Sequence[Any]: + """Return _val_ as an Sequence.""" if is_undefined(val): val.poke() return [] @@ -149,7 +149,7 @@ def sequence_arg(val: object) -> Iterable[Any]: if isinstance(val, Mapping): return [val] if isinstance(val, Iterable): - return val + return list(val) return [val] diff --git a/tests/liquid2-compliance-test-suite/cts.json b/tests/liquid2-compliance-test-suite/cts.json index 63853e9..b320506 100644 --- a/tests/liquid2-compliance-test-suite/cts.json +++ b/tests/liquid2-compliance-test-suite/cts.json @@ -1207,6 +1207,48 @@ }, "result": "(title,foo)(name,a)(title,bar)(name,c)" }, + { + "name": "filters, compact, array of objects, lambda expression", + "template": "{% assign x = a | compact: i => i.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": null, + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "result": "(title,foo)(name,a)(title,bar)(name,c)" + }, + { + "name": "filters, compact, array of objects, lambda expression is not a path", + "template": "{% assign x = a | compact: i => i.title == 'foo' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": null, + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "invalid": true + }, { "name": "filters, concat, range literal concat filter left value", "template": "{{ (1..3) | concat: foo | join: '#' }}", @@ -2274,6 +2316,30 @@ }, "result": "0#1#2" }, + { + "name": "filters, map, array of objects, lambda expression is not a path", + "template": "{{ a | map: (i) => i.user.title == 'foo' | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "invalid": true + }, { "name": "filters, minus, integer value and integer arg", "template": "{{ 10 | minus: 2 }}", @@ -3971,6 +4037,42 @@ }, "invalid": true }, + { + "name": "filters, sum, hashes with lambda argument", + "template": "{{ a | sum: i => i.k }}", + "data": { + "a": [ + { + "k": 1 + }, + { + "k": 2 + }, + { + "k": 3 + } + ] + }, + "result": "6" + }, + { + "name": "filters, sum, hashes with lambda invalid argument", + "template": "{{ a | sum: i => i.k == 'foo' }}", + "data": { + "a": [ + { + "k": 1 + }, + { + "k": 2 + }, + { + "k": 3 + } + ] + }, + "invalid": true + }, { "name": "filters, times, int times int", "template": "{{ 5 | times: 2 }}", @@ -4299,6 +4401,48 @@ }, "result": "(title,foo)(name,a)(title,bar)(name,c)(heading,bar)(name,c)" }, + { + "name": "filters, uniq, array of objects, lambda expression", + "template": "{% assign x = a | uniq: i => i.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": "foo", + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "result": "(title,foo)(name,a)(title,bar)(name,c)" + }, + { + "name": "filters, uniq, array of objects, lambda expression is not a path", + "template": "{% assign x = a | uniq: i => i.title == 'foo' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": "foo", + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "invalid": true + }, { "name": "filters, upcase, make lower case", "template": "{{ \"hello\" | upcase }}", diff --git a/tests/liquid2-compliance-test-suite/tests/filters/compact.json b/tests/liquid2-compliance-test-suite/tests/filters/compact.json index a05fd6a..1263349 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/compact.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/compact.json @@ -4,12 +4,7 @@ "name": "array with a nil", "template": "{{ a | compact | join: '#' }}", "data": { - "a": [ - "b", - "a", - null, - "A" - ] + "a": ["b", "a", null, "A"] }, "result": "b#a#A" }, @@ -61,6 +56,48 @@ ] }, "result": "(title,foo)(name,a)(title,bar)(name,c)" + }, + { + "name": "array of objects, lambda expression", + "template": "{% assign x = a | compact: i => i.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": null, + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "result": "(title,foo)(name,a)(title,bar)(name,c)" + }, + { + "name": "array of objects, lambda expression is not a path", + "template": "{% assign x = a | compact: i => i.title == 'foo' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": null, + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "invalid": true } ] } diff --git a/tests/liquid2-compliance-test-suite/tests/filters/map.json b/tests/liquid2-compliance-test-suite/tests/filters/map.json index 7e55d67..9782323 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/map.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/map.json @@ -178,6 +178,24 @@ ] }, "result": "0#1#2" + }, + { + "name": "array of objects, lambda expression is not a path", + "template": "{{ a | map: (i) => i.user.title == 'foo' | join: '#' }}", + "data": { + "a": [ + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": "baz" } + } + ] + }, + "invalid": true } ] } diff --git a/tests/liquid2-compliance-test-suite/tests/filters/sum.json b/tests/liquid2-compliance-test-suite/tests/filters/sum.json index bda6e26..9918aa9 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/sum.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/sum.json @@ -12,11 +12,7 @@ "name": "only zeros", "template": "{{ a | sum }}", "data": { - "a": [ - 0, - 0, - 0 - ] + "a": [0, 0, 0] }, "result": "0" }, @@ -24,11 +20,7 @@ "name": "ints", "template": "{{ a | sum }}", "data": { - "a": [ - 1, - 2, - 3 - ] + "a": [1, 2, 3] }, "result": "6" }, @@ -36,11 +28,7 @@ "name": "negative ints", "template": "{{ a | sum }}", "data": { - "a": [ - -1, - -2, - -3 - ] + "a": [-1, -2, -3] }, "result": "-6" }, @@ -48,11 +36,7 @@ "name": "negative strings", "template": "{{ a | sum }}", "data": { - "a": [ - "-1", - "-2", - "-3" - ] + "a": ["-1", "-2", "-3"] }, "result": "-6" }, @@ -60,11 +44,7 @@ "name": "positive and negative ints", "template": "{{ a | sum }}", "data": { - "a": [ - -2, - -3, - 10 - ] + "a": [-2, -3, 10] }, "result": "5" }, @@ -72,15 +52,7 @@ "name": "nested ints", "template": "{{ a | sum }}", "data": { - "a": [ - 1, - [ - 2, - [ - 3 - ] - ] - ] + "a": [1, [2, [3]]] }, "result": "6" }, @@ -159,11 +131,43 @@ { "name": "properties arguments with non-hash items", "template": "{{ a | sum: 'k' }}", + "data": { + "a": [1, 2, 3] + }, + "invalid": true + }, + { + "name": "hashes with lambda argument", + "template": "{{ a | sum: i => i.k }}", + "data": { + "a": [ + { + "k": 1 + }, + { + "k": 2 + }, + { + "k": 3 + } + ] + }, + "result": "6" + }, + { + "name": "hashes with lambda invalid argument", + "template": "{{ a | sum: i => i.k == 'foo' }}", "data": { "a": [ - 1, - 2, - 3 + { + "k": 1 + }, + { + "k": 2 + }, + { + "k": 3 + } ] }, "invalid": true diff --git a/tests/liquid2-compliance-test-suite/tests/filters/uniq.json b/tests/liquid2-compliance-test-suite/tests/filters/uniq.json index 358b72c..ef326a9 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/uniq.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/uniq.json @@ -4,12 +4,7 @@ "name": "array of strings", "template": "{{ a | uniq | join: '#' }}", "data": { - "a": [ - "a", - "b", - "b", - "a" - ] + "a": ["a", "b", "b", "a"] }, "result": "a#b" }, @@ -17,12 +12,7 @@ "name": "array of things", "template": "{{ a | uniq | join: '#' }}", "data": { - "a": [ - "a", - "b", - 1, - 1 - ] + "a": ["a", "b", 1, 1] }, "result": "a#b#1" }, @@ -38,13 +28,7 @@ "name": "unhashable items", "template": "{{ a | uniq | join: '#' }}", "data": { - "a": [ - "a", - "b", - [], - {}, - {} - ] + "a": ["a", "b", [], {}, {}] }, "result": "a#b#{}" }, @@ -117,6 +101,48 @@ ] }, "result": "(title,foo)(name,a)(title,bar)(name,c)(heading,bar)(name,c)" + }, + { + "name": "array of objects, lambda expression", + "template": "{% assign x = a | uniq: i => i.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": "foo", + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "result": "(title,foo)(name,a)(title,bar)(name,c)" + }, + { + "name": "array of objects, lambda expression is not a path", + "template": "{% assign x = a | uniq: i => i.title == 'foo' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": "foo", + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "invalid": true } ] } From 6e4f8aacb2c41619859d8ece299c4dbec93293c7 Mon Sep 17 00:00:00 2001 From: James Prior Date: Mon, 20 Jan 2025 13:08:58 +0000 Subject: [PATCH 08/14] Factor out lambda application --- liquid2/builtin/__init__.py | 6 +- liquid2/builtin/expressions.py | 20 ++++ liquid2/builtin/filters/compact_arrow.py | 23 +---- liquid2/builtin/filters/map_arrow.py | 22 +---- liquid2/builtin/filters/sort_arrow.py | 98 +++++++++++-------- liquid2/builtin/filters/sum_arrow.py | 23 +---- liquid2/builtin/filters/uniq_arrow.py | 33 ++----- liquid2/builtin/filters/where_arrow.py | 31 ++---- tests/liquid2-compliance-test-suite/cts.json | 38 +++++++ .../tests/filters/sort_numeric.json | 20 ++++ tests/test_sort_numeric_filter.py | 2 + 11 files changed, 167 insertions(+), 149 deletions(-) create mode 100644 tests/liquid2-compliance-test-suite/tests/filters/sort_numeric.json diff --git a/liquid2/builtin/__init__.py b/liquid2/builtin/__init__.py index 8f7408f..191ab0d 100644 --- a/liquid2/builtin/__init__.py +++ b/liquid2/builtin/__init__.py @@ -52,7 +52,7 @@ from .filters.array import reverse from .filters.array import sort # noqa: F401 from .filters.array import sort_natural # noqa: F401 -from .filters.array import sort_numeric +from .filters.array import sort_numeric # noqa: F401 from .filters.array import sum_ # noqa: F401 from .filters.array import uniq # noqa: F401 from .filters.array import where # noqa: F401 @@ -79,6 +79,7 @@ from .filters.misc import size from .filters.sort_arrow import SortFilter from .filters.sort_arrow import SortNaturalFilter +from .filters.sort_arrow import SortNumericFilter from .filters.string import append from .filters.string import capitalize from .filters.string import downcase @@ -265,7 +266,8 @@ def register_default_tags_and_filters(env: Environment) -> None: # noqa: PLR091 env.filters["sort"] = SortFilter() # env.filters["sort_natural"] = sort_natural env.filters["sort_natural"] = SortNaturalFilter() - env.filters["sort_numeric"] = sort_numeric + # env.filters["sort_numeric"] = sort_numeric + env.filters["sort_numeric"] = SortNumericFilter() # env.filters["sum"] = sum_ env.filters["sum"] = SumFilter() # env.filters["where"] = where diff --git a/liquid2/builtin/expressions.py b/liquid2/builtin/expressions.py index 3ae06c9..f2880ea 100644 --- a/liquid2/builtin/expressions.py +++ b/liquid2/builtin/expressions.py @@ -10,6 +10,7 @@ from typing import Any from typing import Collection from typing import Generic +from typing import Iterable from typing import Iterator from typing import Mapping from typing import Sequence @@ -438,6 +439,25 @@ def children(self) -> list[Expression]: # tag. return [self.expression] + def map(self, context: RenderContext, it: Iterable[object]) -> Iterator[object]: + """Return an iterator mapping this expression to items in _it_.""" + scope: dict[str, object] = {} + + if len(self.params) == 1: + param = self.params[0] + with context.extend(scope): + for item in it: + scope[param] = item + yield self.expression.evaluate(context) + + else: + name_param, index_param = self.params[:2] + with context.extend(scope): + for index, item in enumerate(it): + scope[index_param] = index + scope[name_param] = item + yield self.expression.evaluate(context) + @staticmethod def parse(env: Environment, stream: TokenStream) -> LambdaExpression: """Parse an arrow function from tokens in _stream_.""" diff --git a/liquid2/builtin/filters/compact_arrow.py b/liquid2/builtin/filters/compact_arrow.py index 7d8a961..47c8aeb 100644 --- a/liquid2/builtin/filters/compact_arrow.py +++ b/liquid2/builtin/filters/compact_arrow.py @@ -63,26 +63,9 @@ def __call__( if isinstance(key, LambdaExpression): items: list[object] = [] - scope: dict[str, object] = {} - - if len(key.params) == 1: - param = key.params[0] - with context.extend(scope): - for item in left: - scope[param] = item - rv = key.expression.evaluate(context) - if not is_undefined(rv) and rv is not None: - items.append(item) - else: - name_param, index_param = key.params[:2] - with context.extend(scope): - for index, item in enumerate(left): - scope[index_param] = index - scope[name_param] = item - rv = key.expression.evaluate(context) - if not is_undefined(rv) and rv is not None: - items.append(item) - + for item, rv in zip(left, key.map(context, left), strict=True): + if not is_undefined(rv) and rv is not None: + items.append(item) return items if key is not None: diff --git a/liquid2/builtin/filters/map_arrow.py b/liquid2/builtin/filters/map_arrow.py index 0cf2f4a..0d81900 100644 --- a/liquid2/builtin/filters/map_arrow.py +++ b/liquid2/builtin/filters/map_arrow.py @@ -97,24 +97,10 @@ def __call__( left = sequence_arg(left) if isinstance(first, LambdaExpression): - items: list[object] = [] - scope: dict[str, object] = {} - - if len(first.params) == 1: - param = first.params[0] - with context.extend(scope): - for item in left: - scope[param] = item - items.append(first.expression.evaluate(context)) - else: - name_param, index_param = first.params[:2] - with context.extend(scope): - for index, item in enumerate(left): - scope[index_param] = index - scope[name_param] = item - items.append(first.expression.evaluate(context)) - - return [_NULL if is_undefined(item) else item for item in items] + return [ + _NULL if is_undefined(item) else item + for item in first.map(context, left) + ] try: return [_getitem(itm, str(first), default=_NULL) for itm in left] diff --git a/liquid2/builtin/filters/sort_arrow.py b/liquid2/builtin/filters/sort_arrow.py index 1ef952b..9b97b6b 100644 --- a/liquid2/builtin/filters/sort_arrow.py +++ b/liquid2/builtin/filters/sort_arrow.py @@ -2,6 +2,9 @@ from __future__ import annotations +import math +import re +from decimal import Decimal from functools import partial from operator import getitem from operator import itemgetter @@ -14,6 +17,7 @@ from liquid2.exceptions import LiquidSyntaxError from liquid2.exceptions import LiquidTypeError from liquid2.filter import sequence_arg +from liquid2.limits import to_int from liquid2.undefined import is_undefined if TYPE_CHECKING: @@ -92,24 +96,8 @@ def __call__( if isinstance(key, LambdaExpression): items: list[tuple[object, object]] = [] - scope: dict[str, object] = {} - - if len(key.params) == 1: - param = key.params[0] - with context.extend(scope): - for item in left: - scope[param] = item - rv = key.expression.evaluate(context) - items.append((item, _MAX_CH if is_undefined(rv) else rv)) - else: - name_param, index_param = key.params[:2] - with context.extend(scope): - for index, item in enumerate(left): - scope[index_param] = index - scope[name_param] = item - rv = key.expression.evaluate(context) - items.append((item, _MAX_CH if is_undefined(rv) else rv)) - + for item, rv in zip(left, key.map(context, left), strict=True): + items.append((item, _MAX_CH if is_undefined(rv) else rv)) return [item[0] for item in sorted(items, key=itemgetter(1))] if key: @@ -137,28 +125,8 @@ def __call__( if isinstance(key, LambdaExpression): items: list[tuple[object, object]] = [] - scope: dict[str, object] = {} - - if len(key.params) == 1: - param = key.params[0] - with context.extend(scope): - for item in left: - scope[param] = item - rv = key.expression.evaluate(context) - items.append( - (item, _MAX_CH if is_undefined(rv) else str(rv).lower()) - ) - else: - name_param, index_param = key.params[:2] - with context.extend(scope): - for index, item in enumerate(left): - scope[index_param] = index - scope[name_param] = item - rv = key.expression.evaluate(context) - items.append( - (item, _MAX_CH if is_undefined(rv) else str(rv).lower()) - ) - + for item, rv in zip(left, key.map(context, left), strict=True): + items.append((item, _MAX_CH if is_undefined(rv) else str(rv).lower())) return [item[0] for item in sorted(items, key=itemgetter(1))] if key: @@ -168,4 +136,52 @@ def __call__( return sorted(left, key=_lower) -# TODO: class SortNumericFilter(SortFilter): +RE_NUMERIC = re.compile(r"-?\d+") + + +class SortNumericFilter(SortFilter): + """An implementation `sort_numeric` that accepts a lambda expression.""" + + def __call__( + self, + left: object, + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + items: list[tuple[object, object]] = [] + for item, rv in zip(left, key.map(context, left), strict=True): + items.append((item, _MAX_CH if is_undefined(rv) else rv)) + return [item[0] for item in sorted(items, key=lambda i: _ints(i[1]))] + + if key: + _key = str(key) + return sorted(left, key=lambda item: _ints(_get_numeric_item(item, _key))) + return sorted(left, key=_ints) + + +def _get_numeric_item(sequence: Any, key: object, default: object = None) -> Any: + """Item getter for the `sort_numeric` filter.""" + try: + return getitem(sequence, key) + except (KeyError, IndexError, TypeError): + return default + + +def _ints(obj: object) -> tuple[int | float | Decimal, ...]: + """Key function for the `sort_numeric` filter.""" + if isinstance(obj, bool): + # isinstance(False, int) == True + return (math.inf,) + if isinstance(obj, (int, float, Decimal)): + return (obj,) + + ints = tuple(to_int(n) for n in RE_NUMERIC.findall(str(obj))) + + if not ints: + return (math.inf,) + return ints diff --git a/liquid2/builtin/filters/sum_arrow.py b/liquid2/builtin/filters/sum_arrow.py index 5386a6a..c61d706 100644 --- a/liquid2/builtin/filters/sum_arrow.py +++ b/liquid2/builtin/filters/sum_arrow.py @@ -81,24 +81,11 @@ def __call__( left = sequence_arg(left) if isinstance(key, LambdaExpression): - items: list[object] = [] - scope: dict[str, object] = {} - - if len(key.params) == 1: - param = key.params[0] - with context.extend(scope): - for item in left: - scope[param] = item - items.append(key.expression.evaluate(context)) - else: - name_param, index_param = key.params[:2] - with context.extend(scope): - for index, item in enumerate(left): - scope[index_param] = index - scope[name_param] = item - items.append(key.expression.evaluate(context)) - - rv = sum(decimal_arg(item, 0) for item in items if not is_undefined(item)) + rv = sum( + decimal_arg(item, 0) + for item in key.map(context, left) + if not is_undefined(item) + ) elif key is not None and not is_undefined(key): rv = sum(decimal_arg(_getitem(elem, key, 0), 0) for elem in left) else: diff --git a/liquid2/builtin/filters/uniq_arrow.py b/liquid2/builtin/filters/uniq_arrow.py index be25e8e..87fb9fb 100644 --- a/liquid2/builtin/filters/uniq_arrow.py +++ b/liquid2/builtin/filters/uniq_arrow.py @@ -65,36 +65,17 @@ def __call__( # Note that we're not using a dict or set for deduplication because we need # to handle sequences containing unhashable objects, like dictionaries and - # lists. - - # This is probably quite slow. + # lists. This is probably quite slow. if isinstance(key, LambdaExpression): keys: list[object] = [] items: list[object] = [] - scope: dict[str, object] = {} - - if len(key.params) == 1: - param = key.params[0] - with context.extend(scope): - for item in left: - scope[param] = item - rv = key.expression.evaluate(context) - current_key = MISSING if is_undefined(rv) else rv - if current_key not in keys: - keys.append(current_key) - items.append(item) - else: - name_param, index_param = key.params[:2] - with context.extend(scope): - for index, item in enumerate(left): - scope[index_param] = index - scope[name_param] = item - rv = key.expression.evaluate(context) - current_key = MISSING if is_undefined(rv) else rv - if current_key not in keys: - keys.append(current_key) - items.append(item) + + for item, rv in zip(left, key.map(context, left), strict=True): + current_key = MISSING if is_undefined(rv) else rv + if current_key not in keys: + keys.append(current_key) + items.append(item) return items diff --git a/liquid2/builtin/filters/where_arrow.py b/liquid2/builtin/filters/where_arrow.py index ad02112..f55a7af 100644 --- a/liquid2/builtin/filters/where_arrow.py +++ b/liquid2/builtin/filters/where_arrow.py @@ -67,7 +67,7 @@ def validate( def __call__( self, left: Iterable[object], - first: str | LambdaExpression, + key: str | LambdaExpression, value: object = None, *, context: RenderContext, @@ -75,30 +75,13 @@ def __call__( """Apply the filter and return the result.""" left = sequence_arg(left) - if isinstance(first, LambdaExpression): + if isinstance(key, LambdaExpression): items: list[object] = [] - scope: dict[str, object] = {} - - if len(first.params) == 1: - param = first.params[0] - with context.extend(scope): - for item in left: - scope[param] = item - rv = first.expression.evaluate(context) - if not is_undefined(rv) and is_truthy(rv): - items.append(item) - else: - name_param, index_param = first.params[:2] - with context.extend(scope): - for index, item in enumerate(left): - scope[index_param] = index - scope[name_param] = item - rv = first.expression.evaluate(context) - if not is_undefined(rv) and is_truthy(rv): - items.append(item) - + for item, rv in zip(left, key.map(context, left), strict=True): + if not is_undefined(rv) and is_truthy(rv): + items.append(item) return items if value is not None and not is_undefined(value): - return [itm for itm in left if _getitem(itm, first) == value] - return [itm for itm in left if _getitem(itm, first) not in (False, None)] + return [itm for itm in left if _getitem(itm, key) == value] + return [itm for itm in left if _getitem(itm, key) not in (False, None)] diff --git a/tests/liquid2-compliance-test-suite/cts.json b/tests/liquid2-compliance-test-suite/cts.json index b320506..17ca78d 100644 --- a/tests/liquid2-compliance-test-suite/cts.json +++ b/tests/liquid2-compliance-test-suite/cts.json @@ -3637,6 +3637,44 @@ }, "result": "(user.title,foo)(user.title,bar)(user.title,Baz)" }, + { + "name": "filters, sort numeric, array of objects, lambda expression argument", + "template": "{% assign x = a | sort_numeric: i => i.x %}{% for item in x %}{% for pair in item %}{{ '(${pair[0]},${pair[1]})' }}{% endfor %}{% endfor %}", + "data": { + "a": [ + { + "y": "-1", + "x": "10" + }, + { + "x": "2" + }, + { + "x": "3" + } + ] + }, + "result": "(x,2)(x,3)(y,-1)(x,10)" + }, + { + "name": "filters, sort numeric, array of objects, lambda expression is not a path", + "template": "{% assign x = a | sort_numeric: i => i.x == y %}{% for item in x %}{% for pair in item %}{{ '(${pair[0]},${pair[1]})' }}{% endfor %}{% endfor %}", + "data": { + "a": [ + { + "y": "-1", + "x": "10" + }, + { + "x": "2" + }, + { + "x": "3" + } + ] + }, + "invalid": true + }, { "name": "filters, split, split string", "template": "{{ \"Hi, how are you today?\" | split: \" \" | join: \"#\" }}", diff --git a/tests/liquid2-compliance-test-suite/tests/filters/sort_numeric.json b/tests/liquid2-compliance-test-suite/tests/filters/sort_numeric.json new file mode 100644 index 0000000..e34769a --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/filters/sort_numeric.json @@ -0,0 +1,20 @@ +{ + "tests": [ + { + "name": "array of objects, lambda expression argument", + "template": "{% assign x = a | sort_numeric: i => i.x %}{% for item in x %}{% for pair in item %}{{ '(${pair[0]},${pair[1]})' }}{% endfor %}{% endfor %}", + "data": { + "a": [{ "y": "-1", "x": "10" }, { "x": "2" }, { "x": "3" }] + }, + "result": "(x,2)(x,3)(y,-1)(x,10)" + }, + { + "name": "array of objects, lambda expression is not a path", + "template": "{% assign x = a | sort_numeric: i => i.x == y %}{% for item in x %}{% for pair in item %}{{ '(${pair[0]},${pair[1]})' }}{% endfor %}{% endfor %}", + "data": { + "a": [{ "y": "-1", "x": "10" }, { "x": "2" }, { "x": "3" }] + }, + "invalid": true + } + ] +} diff --git a/tests/test_sort_numeric_filter.py b/tests/test_sort_numeric_filter.py index f460bcf..dd930c3 100644 --- a/tests/test_sort_numeric_filter.py +++ b/tests/test_sort_numeric_filter.py @@ -16,6 +16,8 @@ class Case(NamedTuple): expect: Any +# TODO: move these to the compliance test suite + TEST_CASES = [ Case( description="list of string ints", From f354112c37b9e05e4d6052d90ac8fa0837dee01e Mon Sep 17 00:00:00 2001 From: James Prior Date: Mon, 20 Jan 2025 18:41:40 +0000 Subject: [PATCH 09/14] Filters `find`, `find_index`, `has` and `reject` --- liquid2/builtin/__init__.py | 34 +--- liquid2/builtin/filters/compact_arrow.py | 78 -------- liquid2/builtin/filters/filtering_filters.py | 179 ++++++++++++++++++ liquid2/builtin/filters/find_filters.py | 151 +++++++++++++++ .../filters/{map_arrow.py => map_filter.py} | 15 +- .../{sort_arrow.py => sorting_filters.py} | 7 +- .../filters/{sum_arrow.py => sum_filter.py} | 14 +- .../filters/{uniq_arrow.py => uniq_filter.py} | 7 +- liquid2/builtin/filters/where_arrow.py | 87 --------- tests/liquid2-compliance-test-suite/cts.json | 90 +++++++++ .../tests/filters/reject.json | 94 +++++++++ 11 files changed, 544 insertions(+), 212 deletions(-) delete mode 100644 liquid2/builtin/filters/compact_arrow.py create mode 100644 liquid2/builtin/filters/filtering_filters.py create mode 100644 liquid2/builtin/filters/find_filters.py rename liquid2/builtin/filters/{map_arrow.py => map_filter.py} (87%) rename liquid2/builtin/filters/{sort_arrow.py => sorting_filters.py} (96%) rename liquid2/builtin/filters/{sum_arrow.py => sum_filter.py} (87%) rename liquid2/builtin/filters/{uniq_arrow.py => uniq_filter.py} (93%) delete mode 100644 liquid2/builtin/filters/where_arrow.py create mode 100644 tests/liquid2-compliance-test-suite/tests/filters/reject.json diff --git a/liquid2/builtin/__init__.py b/liquid2/builtin/__init__.py index 191ab0d..5637084 100644 --- a/liquid2/builtin/__init__.py +++ b/liquid2/builtin/__init__.py @@ -43,25 +43,19 @@ from .expressions import parse_primitive from .expressions import parse_string_or_identifier from .expressions import parse_string_or_path -from .filters.array import compact # noqa: F401 from .filters.array import concat from .filters.array import first from .filters.array import join from .filters.array import last -from .filters.array import map_ # noqa: F401 from .filters.array import reverse -from .filters.array import sort # noqa: F401 -from .filters.array import sort_natural # noqa: F401 -from .filters.array import sort_numeric # noqa: F401 -from .filters.array import sum_ # noqa: F401 -from .filters.array import uniq # noqa: F401 -from .filters.array import where # noqa: F401 from .filters.babel import Currency from .filters.babel import DateTime from .filters.babel import Number from .filters.babel import Unit -from .filters.compact_arrow import CompactFilter -from .filters.map_arrow import MapFilter +from .filters.filtering_filters import CompactFilter +from .filters.filtering_filters import RejectFilter +from .filters.filtering_filters import WhereFilter +from .filters.map_filter import MapFilter from .filters.math import abs_ from .filters.math import at_least from .filters.math import at_most @@ -77,9 +71,9 @@ from .filters.misc import date from .filters.misc import default from .filters.misc import size -from .filters.sort_arrow import SortFilter -from .filters.sort_arrow import SortNaturalFilter -from .filters.sort_arrow import SortNumericFilter +from .filters.sorting_filters import SortFilter +from .filters.sorting_filters import SortNaturalFilter +from .filters.sorting_filters import SortNumericFilter from .filters.string import append from .filters.string import capitalize from .filters.string import downcase @@ -106,15 +100,14 @@ from .filters.string import upcase from .filters.string import url_decode from .filters.string import url_encode -from .filters.sum_arrow import SumFilter +from .filters.sum_filter import SumFilter from .filters.translate import BaseTranslateFilter from .filters.translate import GetText from .filters.translate import NGetText from .filters.translate import NPGetText from .filters.translate import PGetText from .filters.translate import Translate -from .filters.uniq_arrow import UniqFilter -from .filters.where_arrow import WhereFilter +from .filters.uniq_filter import UniqFilter from .loaders.caching_file_system_loader import CachingFileSystemLoader from .loaders.choice_loader import CachingChoiceLoader from .loaders.choice_loader import ChoiceLoader @@ -259,22 +252,15 @@ def register_default_tags_and_filters(env: Environment) -> None: # noqa: PLR091 env.filters["first"] = first env.filters["last"] = last env.filters["concat"] = concat - # env.filters["map"] = map_ env.filters["map"] = MapFilter() env.filters["reverse"] = reverse - # env.filters["sort"] = sort env.filters["sort"] = SortFilter() - # env.filters["sort_natural"] = sort_natural env.filters["sort_natural"] = SortNaturalFilter() - # env.filters["sort_numeric"] = sort_numeric env.filters["sort_numeric"] = SortNumericFilter() - # env.filters["sum"] = sum_ env.filters["sum"] = SumFilter() - # env.filters["where"] = where env.filters["where"] = WhereFilter() - # env.filters["uniq"] = uniq + env.filters["reject"] = RejectFilter() env.filters["uniq"] = UniqFilter() - # env.filters["compact"] = compact env.filters["compact"] = CompactFilter() env.filters["abs"] = abs_ diff --git a/liquid2/builtin/filters/compact_arrow.py b/liquid2/builtin/filters/compact_arrow.py deleted file mode 100644 index 47c8aeb..0000000 --- a/liquid2/builtin/filters/compact_arrow.py +++ /dev/null @@ -1,78 +0,0 @@ -"""An implementation of the `compact` filter that accepts lambda expressions.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from typing import Iterable - -from liquid2.builtin import LambdaExpression -from liquid2.builtin import Path -from liquid2.builtin import PositionalArgument -from liquid2.exceptions import LiquidSyntaxError -from liquid2.exceptions import LiquidTypeError -from liquid2.filter import sequence_arg -from liquid2.undefined import is_undefined - -if TYPE_CHECKING: - from liquid2 import Environment - from liquid2 import RenderContext - from liquid2 import TokenT - from liquid2.builtin import KeywordArgument - - -class CompactFilter: - """An implementation of the `compact` filter that accepts lambda expressions.""" - - with_context = True - - def validate( - self, - _env: Environment, - token: TokenT, - name: str, - args: list[KeywordArgument | PositionalArgument], - ) -> None: - """Raise a `LiquidSyntaxError` if _args_ are not valid.""" - if len(args) > 1: - raise LiquidSyntaxError( - f"{name!r} expects at most one argument, got {len(args)}", - token=token, - ) - - if len(args) == 1: - arg = args[0].value - - if isinstance(arg, LambdaExpression) and not isinstance( - arg.expression, Path - ): - raise LiquidSyntaxError( - f"{name!r} expects a path to a variable, " - f"got {arg.expression.__class__.__name__}", - token=arg.expression.token, - ) - - def __call__( - self, - left: Iterable[object], - key: str | LambdaExpression | None = None, - *, - context: RenderContext, - ) -> list[object]: - """Apply the filter and return the result.""" - left = sequence_arg(left) - - if isinstance(key, LambdaExpression): - items: list[object] = [] - for item, rv in zip(left, key.map(context, left), strict=True): - if not is_undefined(rv) and rv is not None: - items.append(item) - return items - - if key is not None: - try: - return [itm for itm in left if itm[key] is not None] - except TypeError as err: - raise LiquidTypeError( - f"can't read property '{key}'", token=None - ) from err - return [itm for itm in left if itm is not None] diff --git a/liquid2/builtin/filters/filtering_filters.py b/liquid2/builtin/filters/filtering_filters.py new file mode 100644 index 0000000..6d6054a --- /dev/null +++ b/liquid2/builtin/filters/filtering_filters.py @@ -0,0 +1,179 @@ +"""Implementations of `where`, `reject` and `compact` that accept lambda expressions.""" + +from __future__ import annotations + +from operator import getitem +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import Path +from liquid2.builtin import PositionalArgument +from liquid2.builtin.expressions import is_truthy +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + + +def _getitem(obj: Any, key: object, default: object = None) -> Any: + """Helper for the where filter. + + Same as obj[key], but returns a default value if key does not exist + in obj. + """ + try: + return getitem(obj, key) + except (KeyError, IndexError): + return default + except TypeError: + if not hasattr(obj, "__getitem__"): + raise + return default + + +class _FilterFilter: + """Base class for filters that filter array-like objects.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) not in (1, 2): + raise LiquidTypeError( + f"{name!r} expects one or two arguments, got {len(args)}", + token=token, + ) + + arg = args[0].value + + if isinstance(arg, LambdaExpression) and len(args) != 1: + raise LiquidTypeError( + f"{name!r} expects one argument when given a lambda expressions", + token=args[1].token, + ) + + +class WhereFilter(_FilterFilter): + """An implementation of the `where` filter that accepts lambda expressions.""" + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression, + value: object = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + return [ + i + for i, r in zip(left, key.map(context, left), strict=True) + if not is_undefined(r) and is_truthy(r) + ] + + if value is not None and not is_undefined(value): + return [itm for itm in left if _getitem(itm, key) == value] + + return [itm for itm in left if _getitem(itm, key) not in (False, None)] + + +class RejectFilter(_FilterFilter): + """An implementation of the `reject` filter that accepts lambda expressions.""" + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression, + value: object = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + return [ + i + for i, r in zip(left, key.map(context, left), strict=True) + if is_undefined(r) or not is_truthy(r) + ] + + if value is not None and not is_undefined(value): + return [itm for itm in left if _getitem(itm, key) != value] + + return [itm for itm in left if _getitem(itm, key) not in (False, None)] + + +class CompactFilter: + """An implementation of the `compact` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) > 1: + raise LiquidTypeError( + f"{name!r} expects at most one argument, got {len(args)}", + token=token, + ) + + if len(args) == 1: + arg = args[0].value + + if isinstance(arg, LambdaExpression) and not isinstance( + arg.expression, Path + ): + raise LiquidTypeError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + return [ + i + for i, r in zip(left, key.map(context, left), strict=True) + if not is_undefined(r) and r is not None + ] + + if key is not None: + try: + return [itm for itm in left if itm[key] is not None] + except TypeError as err: + raise LiquidTypeError( + f"can't read property '{key}'", token=None + ) from err + + return [itm for itm in left if itm is not None] diff --git a/liquid2/builtin/filters/find_filters.py b/liquid2/builtin/filters/find_filters.py new file mode 100644 index 0000000..c6a8628 --- /dev/null +++ b/liquid2/builtin/filters/find_filters.py @@ -0,0 +1,151 @@ +"""Implementations of `find`, `find_index` and `has` accepting lambda expressions.""" + +from __future__ import annotations + +from operator import getitem +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import PositionalArgument +from liquid2.builtin.expressions import is_truthy +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + + +def _getitem(obj: Any, key: object, default: object = None) -> Any: + """Helper for the `find` filter. + + Same as obj[key], but returns a default value if key does not exist + in obj. + """ + try: + return getitem(obj, key) + except (KeyError, IndexError): + return default + except TypeError: + if not hasattr(obj, "__getitem__"): + raise + return default + + +class FindFilter: + """An implementation of the `find` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) not in (1, 2): + raise LiquidTypeError( + f"{name!r} expects one or two arguments, got {len(args)}", + token=token, + ) + + arg = args[0].value + + if isinstance(arg, LambdaExpression) and len(args) != 1: + raise LiquidTypeError( + f"{name!r} expects one argument when given a lambda expressions", + token=args[1].token, + ) + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression, + value: object = None, + *, + context: RenderContext, + ) -> object: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + for item, rv in zip(left, key.map(context, left), strict=True): + if not is_undefined(rv) and is_truthy(rv): + return item + + if value is not None and not is_undefined(value): + for item in left: + if _getitem(item, key) == value: + return item + + for item in left: + if item not in (False, None): + return item + + return None + + +class FindIndexFilter(FindFilter): + """An implementation of the `find_index` filter that accepts lambda expressions.""" + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression, + value: object = None, + *, + context: RenderContext, + ) -> object: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + for i, pair in enumerate(zip(left, key.map(context, left), strict=True)): + item, rv = pair + if not is_undefined(rv) and is_truthy(rv): + return i + + if value is not None and not is_undefined(value): + for i, item in enumerate(left): + if _getitem(item, key) == value: + return i + + for i, item in enumerate(left): + if item not in (False, None): + return i + + return None + + +class HasFilter(FindFilter): + """An implementation of the `has` filter that accepts lambda expressions.""" + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression, + value: object = None, + *, + context: RenderContext, + ) -> bool: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + for rv in key.map(context, left): + if not is_undefined(rv) and is_truthy(rv): + return True + + if value is not None and not is_undefined(value): + for item in left: + if _getitem(item, key) == value: + return True + + return any(item not in (False, None) for item in left) diff --git a/liquid2/builtin/filters/map_arrow.py b/liquid2/builtin/filters/map_filter.py similarity index 87% rename from liquid2/builtin/filters/map_arrow.py rename to liquid2/builtin/filters/map_filter.py index 0d81900..de5d2b2 100644 --- a/liquid2/builtin/filters/map_arrow.py +++ b/liquid2/builtin/filters/map_filter.py @@ -11,7 +11,6 @@ from liquid2.builtin import Null from liquid2.builtin import Path from liquid2.builtin import PositionalArgument -from liquid2.exceptions import LiquidSyntaxError from liquid2.exceptions import LiquidTypeError from liquid2.filter import sequence_arg from liquid2.undefined import is_undefined @@ -36,18 +35,18 @@ def __str__(self) -> str: # pragma: no cover _NULL = _Null() -def _getitem(sequence: Any, key: object, default: object = None) -> Any: +def _getitem(obj: Any, key: object, default: object = None) -> Any: """Helper for the map filter. Same as obj[key], but returns a default value if key does not exist in obj. """ try: - return getitem(sequence, key) + return getitem(obj, key) except (KeyError, IndexError): return default except TypeError: - if not hasattr(sequence, "__getitem__"): + if not hasattr(obj, "__getitem__"): raise return default @@ -64,15 +63,15 @@ def validate( name: str, args: list[KeywordArgument | PositionalArgument], ) -> None: - """Raise a `LiquidSyntaxError` if _args_ are not valid.""" + """Raise a `LiquidTypeError` if _args_ are not valid.""" if len(args) != 1: - raise LiquidSyntaxError( + raise LiquidTypeError( f"{name!r} expects exactly one argument, got {len(args)}", token=token, ) if not isinstance(args[0], PositionalArgument): - raise LiquidSyntaxError( + raise LiquidTypeError( f"{name!r} takes no keyword arguments", token=token, ) @@ -80,7 +79,7 @@ def validate( arg = args[0].value if isinstance(arg, LambdaExpression) and not isinstance(arg.expression, Path): - raise LiquidSyntaxError( + raise LiquidTypeError( f"{name!r} expects a path to a variable, " f"got {arg.expression.__class__.__name__}", token=arg.expression.token, diff --git a/liquid2/builtin/filters/sort_arrow.py b/liquid2/builtin/filters/sorting_filters.py similarity index 96% rename from liquid2/builtin/filters/sort_arrow.py rename to liquid2/builtin/filters/sorting_filters.py index 9b97b6b..1d22015 100644 --- a/liquid2/builtin/filters/sort_arrow.py +++ b/liquid2/builtin/filters/sorting_filters.py @@ -14,7 +14,6 @@ from liquid2.builtin import LambdaExpression from liquid2.builtin import Path from liquid2.builtin import PositionalArgument -from liquid2.exceptions import LiquidSyntaxError from liquid2.exceptions import LiquidTypeError from liquid2.filter import sequence_arg from liquid2.limits import to_int @@ -66,9 +65,9 @@ def validate( name: str, args: list[KeywordArgument | PositionalArgument], ) -> None: - """Raise a `LiquidSyntaxError` if _args_ are not valid.""" + """Raise a `LiquidTypeError` if _args_ are not valid.""" if len(args) > 1: - raise LiquidSyntaxError( + raise LiquidTypeError( f"{name!r} expects at most one argument, got {len(args)}", token=token, ) @@ -78,7 +77,7 @@ def validate( if isinstance(arg, LambdaExpression) and not isinstance( arg.expression, Path ): - raise LiquidSyntaxError( + raise LiquidTypeError( f"{name!r} expects a path to a variable, " f"got {arg.expression.__class__.__name__}", token=arg.expression.token, diff --git a/liquid2/builtin/filters/sum_arrow.py b/liquid2/builtin/filters/sum_filter.py similarity index 87% rename from liquid2/builtin/filters/sum_arrow.py rename to liquid2/builtin/filters/sum_filter.py index c61d706..0c13888 100644 --- a/liquid2/builtin/filters/sum_arrow.py +++ b/liquid2/builtin/filters/sum_filter.py @@ -11,7 +11,7 @@ from liquid2.builtin import LambdaExpression from liquid2.builtin import Path from liquid2.builtin import PositionalArgument -from liquid2.exceptions import LiquidSyntaxError +from liquid2.exceptions import LiquidTypeError from liquid2.filter import decimal_arg from liquid2.filter import sequence_arg from liquid2.undefined import is_undefined @@ -23,18 +23,18 @@ from liquid2.builtin import KeywordArgument -def _getitem(sequence: Any, key: object, default: object = None) -> Any: +def _getitem(obj: Any, key: object, default: object = None) -> Any: """Helper for the sum filter. Same as obj[key], but returns a default value if key does not exist in obj. """ try: - return getitem(sequence, key) + return getitem(obj, key) except (KeyError, IndexError): return default except TypeError: - if not hasattr(sequence, "__getitem__"): + if not hasattr(obj, "__getitem__"): raise return default @@ -51,9 +51,9 @@ def validate( name: str, args: list[KeywordArgument | PositionalArgument], ) -> None: - """Raise a `LiquidSyntaxError` if _args_ are not valid.""" + """Raise a `LiquidTypeError` if _args_ are not valid.""" if len(args) > 1: - raise LiquidSyntaxError( + raise LiquidTypeError( f"{name!r} expects at most one argument, got {len(args)}", token=token, ) @@ -64,7 +64,7 @@ def validate( if isinstance(arg, LambdaExpression) and not isinstance( arg.expression, Path ): - raise LiquidSyntaxError( + raise LiquidTypeError( f"{name!r} expects a path to a variable, " f"got {arg.expression.__class__.__name__}", token=arg.expression.token, diff --git a/liquid2/builtin/filters/uniq_arrow.py b/liquid2/builtin/filters/uniq_filter.py similarity index 93% rename from liquid2/builtin/filters/uniq_arrow.py rename to liquid2/builtin/filters/uniq_filter.py index 87fb9fb..a77376f 100644 --- a/liquid2/builtin/filters/uniq_arrow.py +++ b/liquid2/builtin/filters/uniq_filter.py @@ -8,7 +8,6 @@ from liquid2.builtin import LambdaExpression from liquid2.builtin import Path from liquid2.builtin import PositionalArgument -from liquid2.exceptions import LiquidSyntaxError from liquid2.exceptions import LiquidTypeError from liquid2.filter import sequence_arg from liquid2.undefined import is_undefined @@ -34,9 +33,9 @@ def validate( name: str, args: list[KeywordArgument | PositionalArgument], ) -> None: - """Raise a `LiquidSyntaxError` if _args_ are not valid.""" + """Raise a `LiquidTypeError` if _args_ are not valid.""" if len(args) > 1: - raise LiquidSyntaxError( + raise LiquidTypeError( f"{name!r} expects at most one argument, got {len(args)}", token=token, ) @@ -47,7 +46,7 @@ def validate( if isinstance(arg, LambdaExpression) and not isinstance( arg.expression, Path ): - raise LiquidSyntaxError( + raise LiquidTypeError( f"{name!r} expects a path to a variable, " f"got {arg.expression.__class__.__name__}", token=arg.expression.token, diff --git a/liquid2/builtin/filters/where_arrow.py b/liquid2/builtin/filters/where_arrow.py deleted file mode 100644 index f55a7af..0000000 --- a/liquid2/builtin/filters/where_arrow.py +++ /dev/null @@ -1,87 +0,0 @@ -"""An implementation of the `where` filter that accepts lambda expressions.""" - -from __future__ import annotations - -from operator import getitem -from typing import TYPE_CHECKING -from typing import Any -from typing import Iterable - -from liquid2.builtin import LambdaExpression -from liquid2.builtin import PositionalArgument -from liquid2.builtin.expressions import is_truthy -from liquid2.exceptions import LiquidSyntaxError -from liquid2.filter import sequence_arg -from liquid2.undefined import is_undefined - -if TYPE_CHECKING: - from liquid2 import Environment - from liquid2 import RenderContext - from liquid2 import TokenT - from liquid2.builtin import KeywordArgument - - -def _getitem(sequence: Any, key: object, default: object = None) -> Any: - """Helper for the where filter. - - Same as obj[key], but returns a default value if key does not exist - in obj. - """ - try: - return getitem(sequence, key) - except (KeyError, IndexError): - return default - except TypeError: - if not hasattr(sequence, "__getitem__"): - raise - return default - - -class WhereFilter: - """An implementation of the `where` filter that accepts lambda expressions.""" - - with_context = True - - def validate( - self, - _env: Environment, - token: TokenT, - name: str, - args: list[KeywordArgument | PositionalArgument], - ) -> None: - """Raise a `LiquidSyntaxError` if _args_ are not valid.""" - if len(args) not in (1, 2): - raise LiquidSyntaxError( - f"{name!r} expects one or two arguments, got {len(args)}", - token=token, - ) - - arg = args[0].value - - if isinstance(arg, LambdaExpression) and len(args) != 1: - raise LiquidSyntaxError( - f"{name!r} expects one argument when given a lambda expressions", - token=args[1].token, - ) - - def __call__( - self, - left: Iterable[object], - key: str | LambdaExpression, - value: object = None, - *, - context: RenderContext, - ) -> list[object]: - """Apply the filter and return the result.""" - left = sequence_arg(left) - - if isinstance(key, LambdaExpression): - items: list[object] = [] - for item, rv in zip(left, key.map(context, left), strict=True): - if not is_undefined(rv) and is_truthy(rv): - items.append(item) - return items - - if value is not None and not is_undefined(value): - return [itm for itm in left if _getitem(itm, key) == value] - return [itm for itm in left if _getitem(itm, key) not in (False, None)] diff --git a/tests/liquid2-compliance-test-suite/cts.json b/tests/liquid2-compliance-test-suite/cts.json index 17ca78d..f943664 100644 --- a/tests/liquid2-compliance-test-suite/cts.json +++ b/tests/liquid2-compliance-test-suite/cts.json @@ -2610,6 +2610,96 @@ "data": {}, "result": "hi" }, + { + "name": "filters, reject, array of objects, explicit null", + "template": "{% assign x = a | reject: 'title', null %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "result": "(title,foo)(title,bar)" + }, + { + "name": "filters, reject, array of objects, implicit null", + "template": "{% assign x = a | reject: 'title' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "result": "(title,foo)(title,bar)" + }, + { + "name": "filters, reject, array of objects, string match", + "template": "{% assign x = a | reject: 'title', 'baz' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "(title,foo)(title,bar)" + }, + { + "name": "filters, reject, array of objects, missing key", + "template": "{% assign x = a | reject: 'title', 'baz' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "heading": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "(heading,foo)(title,bar)" + }, + { + "name": "filters, reject, array of objects, lambda expression", + "template": "{% assign x = a | reject: i => i.title == 'bar' or i.title == 'baz' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "heading": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "(heading,foo)" + }, { "name": "filters, remove, remove substrings", "template": "{{ \"I strained to see the train through the rain\" | remove: \"rain\" }}", diff --git a/tests/liquid2-compliance-test-suite/tests/filters/reject.json b/tests/liquid2-compliance-test-suite/tests/filters/reject.json new file mode 100644 index 0000000..84fb13f --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/filters/reject.json @@ -0,0 +1,94 @@ +{ + "tests": [ + { + "name": "array of objects, explicit null", + "template": "{% assign x = a | reject: 'title', null %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "result": "(title,foo)(title,bar)" + }, + { + "name": "array of objects, implicit null", + "template": "{% assign x = a | reject: 'title' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "result": "(title,foo)(title,bar)" + }, + { + "name": "array of objects, string match", + "template": "{% assign x = a | reject: 'title', 'baz' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "(title,foo)(title,bar)" + }, + { + "name": "array of objects, missing key", + "template": "{% assign x = a | reject: 'title', 'baz' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "heading": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "(heading,foo)(title,bar)" + }, + { + "name": "array of objects, lambda expression", + "template": "{% assign x = a | reject: i => i.title == 'bar' or i.title == 'baz' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "heading": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "(heading,foo)" + } + ] +} From 23314a6ce3844367742b84d32ecf6fbbd789b032 Mon Sep 17 00:00:00 2001 From: James Prior Date: Tue, 21 Jan 2025 08:17:12 +0000 Subject: [PATCH 10/14] More tests --- liquid2/builtin/__init__.py | 6 + liquid2/builtin/filters/find_filters.py | 25 +- tests/liquid2-compliance-test-suite/cts.json | 213 ++++++++++++++++++ .../tests/filters/find.json | 73 ++++++ .../tests/filters/find_index.json | 76 +++++++ .../tests/filters/has.json | 76 +++++++ tests/test_template_str.py | 22 +- 7 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 tests/liquid2-compliance-test-suite/tests/filters/find.json create mode 100644 tests/liquid2-compliance-test-suite/tests/filters/find_index.json create mode 100644 tests/liquid2-compliance-test-suite/tests/filters/has.json diff --git a/liquid2/builtin/__init__.py b/liquid2/builtin/__init__.py index 5637084..fffb913 100644 --- a/liquid2/builtin/__init__.py +++ b/liquid2/builtin/__init__.py @@ -55,6 +55,9 @@ from .filters.filtering_filters import CompactFilter from .filters.filtering_filters import RejectFilter from .filters.filtering_filters import WhereFilter +from .filters.find_filters import FindFilter +from .filters.find_filters import FindIndexFilter +from .filters.find_filters import HasFilter from .filters.map_filter import MapFilter from .filters.math import abs_ from .filters.math import at_least @@ -262,6 +265,9 @@ def register_default_tags_and_filters(env: Environment) -> None: # noqa: PLR091 env.filters["reject"] = RejectFilter() env.filters["uniq"] = UniqFilter() env.filters["compact"] = CompactFilter() + env.filters["find"] = FindFilter() + env.filters["find_index"] = FindIndexFilter() + env.filters["has"] = HasFilter() env.filters["abs"] = abs_ env.filters["at_least"] = at_least diff --git a/liquid2/builtin/filters/find_filters.py b/liquid2/builtin/filters/find_filters.py index c6a8628..d813689 100644 --- a/liquid2/builtin/filters/find_filters.py +++ b/liquid2/builtin/filters/find_filters.py @@ -80,14 +80,15 @@ def __call__( if not is_undefined(rv) and is_truthy(rv): return item - if value is not None and not is_undefined(value): + elif value is not None and not is_undefined(value): for item in left: if _getitem(item, key) == value: return item - for item in left: - if item not in (False, None): - return item + else: + for item in left: + if item not in (False, None): + return item return None @@ -112,14 +113,15 @@ def __call__( if not is_undefined(rv) and is_truthy(rv): return i - if value is not None and not is_undefined(value): + elif value is not None and not is_undefined(value): for i, item in enumerate(left): if _getitem(item, key) == value: return i - for i, item in enumerate(left): - if item not in (False, None): - return i + else: + for i, item in enumerate(left): + if item not in (False, None): + return i return None @@ -143,9 +145,12 @@ def __call__( if not is_undefined(rv) and is_truthy(rv): return True - if value is not None and not is_undefined(value): + elif value is not None and not is_undefined(value): for item in left: if _getitem(item, key) == value: return True - return any(item not in (False, None) for item in left) + else: + return any(item not in (False, None) for item in left) + + return False diff --git a/tests/liquid2-compliance-test-suite/cts.json b/tests/liquid2-compliance-test-suite/cts.json index f943664..35b5a6b 100644 --- a/tests/liquid2-compliance-test-suite/cts.json +++ b/tests/liquid2-compliance-test-suite/cts.json @@ -1767,6 +1767,147 @@ "data": {}, "result": "" }, + { + "name": "filters, find, array of objects", + "template": "{% assign x = a | find: 'title', 'bar' %}{{ x.title }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "bar" + }, + { + "name": "filters, find, array of objects, lambda expression", + "template": "{% assign x = a | find: i => i.title == 'bar' %}{{ x.title }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "bar" + }, + { + "name": "filters, find, array of objects, lambda expression, not found", + "template": "{% assign x = a | find: i => i.title == '42' %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, + { + "name": "filters, find, array of objects, not found", + "template": "{% assign x = a | find: 'title', 'bar' %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, + { + "name": "filters, find index, array of objects", + "template": "{% assign x = a | find_index: 'title', 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "1" + }, + { + "name": "filters, find index, array of objects, lambda expression", + "template": "{% assign x = a | find_index: i => i.title == 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "1" + }, + { + "name": "filters, find index, array of objects, lambda expression, not found", + "template": "{% assign x = a | find_index: i => i.title == 42 %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, + { + "name": "filters, find index, array of objects, not found", + "template": "{% assign x = a | find_index: 'title', 42 %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, { "name": "filters, first, range literal first filter left value", "template": "{{ (1..3) | first }}", @@ -1905,6 +2046,78 @@ "data": {}, "result": "0" }, + { + "name": "filters, has, array of objects", + "template": "{% assign x = a | has: 'title', 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "true" + }, + { + "name": "filters, has, array of objects, lambda expression", + "template": "{% assign x = a | has: i => i.title == 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "true" + }, + { + "name": "filters, has, array of objects, lambda expression, not found", + "template": "{% assign x = a | has: i => i.title == '42' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "false" + }, + { + "name": "filters, has, array of objects, not found", + "template": "{% assign x = a | has: i => i.title == 42 %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "false" + }, { "name": "filters, join, range literal join filter left value", "template": "{{ (1..3) | join: '#' }}", diff --git a/tests/liquid2-compliance-test-suite/tests/filters/find.json b/tests/liquid2-compliance-test-suite/tests/filters/find.json new file mode 100644 index 0000000..8f1609d --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/filters/find.json @@ -0,0 +1,73 @@ +{ + "tests": [ + { + "name": "array of objects", + "template": "{% assign x = a | find: 'title', 'bar' %}{{ x.title }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "bar" + }, + { + "name": "array of objects, lambda expression", + "template": "{% assign x = a | find: i => i.title == 'bar' %}{{ x.title }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "bar" + }, + { + "name": "array of objects, lambda expression, not found", + "template": "{% assign x = a | find: i => i.title == '42' %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, + { + "name": "array of objects, not found", + "template": "{% assign x = a | find: 'title', 'bar' %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + } + ] +} diff --git a/tests/liquid2-compliance-test-suite/tests/filters/find_index.json b/tests/liquid2-compliance-test-suite/tests/filters/find_index.json new file mode 100644 index 0000000..5408b76 --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/filters/find_index.json @@ -0,0 +1,76 @@ +{ + "tests": [ + { + "name": "array of objects", + "template": "{% assign x = a | find_index: 'title', 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "1" + }, + { + "name": "array of objects, lambda expression", + "template": "{% assign x = a | find_index: i => i.title == 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "1" + }, + { + "name": "array of objects, lambda expression, not found", + "template": "{% assign x = a | find_index: i => i.title == 42 %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, + { + "name": "array of objects, not found", + "template": "{% assign x = a | find_index: 'title', 42 %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + } + ] +} diff --git a/tests/liquid2-compliance-test-suite/tests/filters/has.json b/tests/liquid2-compliance-test-suite/tests/filters/has.json new file mode 100644 index 0000000..4ba01f2 --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/filters/has.json @@ -0,0 +1,76 @@ +{ + "tests": [ + { + "name": "array of objects", + "template": "{% assign x = a | has: 'title', 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "true" + }, + { + "name": "array of objects, lambda expression", + "template": "{% assign x = a | has: i => i.title == 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "true" + }, + { + "name": "array of objects, lambda expression, not found", + "template": "{% assign x = a | has: i => i.title == '42' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "false" + }, + { + "name": "array of objects, not found", + "template": "{% assign x = a | has: i => i.title == 42 %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "false" + } + ] +} diff --git a/tests/test_template_str.py b/tests/test_template_str.py index a6ce881..d0dca54 100644 --- a/tests/test_template_str.py +++ b/tests/test_template_str.py @@ -356,10 +356,7 @@ def test_translate_str() -> None: source = "\n".join( [ "{% translate x:'foo', y:'bar' %}", - " Hello, {{ you }}" - "{% plural %}" - " Hello, {{ you }}s" - "{% endtranslate %}", + " Hello, {{ you }}{% plural %} Hello, {{ you }}s{% endtranslate %}", ] ) template = parse(source) @@ -370,10 +367,7 @@ def test_translate_str_wc() -> None: source = "\n".join( [ "{%- translate x:'foo', y:'bar' ~%}", - " Hello, {{ you ~}}" - "{%~ plural -%}" - " Hello, {{ you }}s" - "{%~ endtranslate +%}", + " Hello, {{ you ~}}{%~ plural -%} Hello, {{ you }}s{%~ endtranslate +%}", ] ) template = parse(source) @@ -442,3 +436,15 @@ def test_array_literal_str() -> None: source = "{% assign my_array = 1, 2, 3 %}" template = parse(source) assert str(template) == source + + +def test_lambda_expression_str() -> None: + source = "{% assign x = a | map: i => i.foo.bar %}" + template = parse(source) + assert str(template) == source + + +def test_two_argument_lambda_expression_str() -> None: + source = "{% assign x = a | where: (i, j) => i.foo.bar == j %}" + template = parse(source) + assert str(template) == source From c3917b58908c0f7e576e3c3da1316159a4a144b2 Mon Sep 17 00:00:00 2001 From: James Prior Date: Tue, 21 Jan 2025 08:55:26 +0000 Subject: [PATCH 11/14] Fix lambda expression scope during static analysis --- liquid2/builtin/expressions.py | 5 ++-- liquid2/expression.py | 5 ++++ liquid2/static_analysis.py | 43 +++++++++++++++++++------------ tests/test_static_analysis.py | 47 ++++++++++++++++++++++++++++++++-- 4 files changed, 80 insertions(+), 20 deletions(-) diff --git a/liquid2/builtin/expressions.py b/liquid2/builtin/expressions.py index f2880ea..3ece928 100644 --- a/liquid2/builtin/expressions.py +++ b/liquid2/builtin/expressions.py @@ -435,10 +435,11 @@ def evaluate(self, _context: RenderContext) -> object: return self def children(self) -> list[Expression]: - # XXX: This expression has its own scope, a scope that is not controlled by a - # tag. return [self.expression] + def scope(self) -> Iterable[Identifier]: + return self.params + def map(self, context: RenderContext, it: Iterable[object]) -> Iterator[object]: """Return an iterator mapping this expression to items in _it_.""" scope: dict[str, object] = {} diff --git a/liquid2/expression.py b/liquid2/expression.py index 64b4c86..c7d74d6 100644 --- a/liquid2/expression.py +++ b/liquid2/expression.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from liquid2 import TokenT + from .builtin import Identifier from .context import RenderContext @@ -32,3 +33,7 @@ async def evaluate_async(self, context: RenderContext) -> object: @abstractmethod def children(self) -> Iterable[Expression]: """Return this expression's child expressions.""" + + def scope(self) -> Iterable[Identifier]: + """Return variables this expression adds the scope of any child expressions.""" + return [] diff --git a/liquid2/static_analysis.py b/liquid2/static_analysis.py index 24d5f1e..c50cccd 100644 --- a/liquid2/static_analysis.py +++ b/liquid2/static_analysis.py @@ -172,11 +172,7 @@ def _visit(node: Node, template_name: str, scope: _StaticScope) -> None: # Update variables from node.expressions() for expr in node.expressions(): - for var in _extract_variables(expr, template_name): - variables.add(var) - root = str(var.segments[0]) - if root not in scope: - globals.add(var) + _analyze_variables(expr, template_name, scope, globals, variables) # Update filters from expr for name, span in _extract_filters(expr, template_name): @@ -262,11 +258,7 @@ async def _visit(node: Node, template_name: str, scope: _StaticScope) -> None: # Update variables from node.expressions() for expr in node.expressions(): - for var in _extract_variables(expr, template_name): - variables.add(var) - root = str(var.segments[0]) - if root not in scope: - globals.add(var) + _analyze_variables(expr, template_name, scope, globals, variables) # Update filters from expr for name, span in _extract_filters(expr, template_name): @@ -343,17 +335,36 @@ def _extract_filters( yield from _extract_filters(expr, template_name) -def _extract_variables( - expression: Expression, template_name: str -) -> Iterable[Variable]: +def _analyze_variables( + expression: Expression, + template_name: str, + scope: _StaticScope, + globals: _VariableMap, + variables: _VariableMap, +) -> None: if isinstance(expression, Path): - yield Variable( + var = Variable( segments=_segments(expression, template_name), span=Span(template_name, expression.token.start, expression.token.stop), ) - for expr in expression.children(): - yield from _extract_variables(expr, template_name=template_name) + # NOTE: We're updating globals and variables here so we can manage scope while + # traversing the expression. This is for the benefit of lambda expressions that + # add names to the scope of their children. + variables.add(var) + + root = str(var.segments[0]) + if root not in scope: + globals.add(var) + + if child_scope := expression.scope(): + scope.push(set(child_scope)) + for expr in expression.children(): + _analyze_variables(expr, template_name, scope, globals, variables) + scope.pop() + else: + for expr in expression.children(): + _analyze_variables(expr, template_name, scope, globals, variables) def _segments(path: Path, template_name: str) -> Segments: diff --git a/tests/test_static_analysis.py b/tests/test_static_analysis.py index f707ee8..ccb1378 100644 --- a/tests/test_static_analysis.py +++ b/tests/test_static_analysis.py @@ -744,8 +744,7 @@ def test_analyze_inheritance_chain() -> None: "{% block foo %}{% assign z = 7 %}{% endblock %}" ), "some": ( - "{% extends 'other' %}{{ y | append: x }}" - "{% block foo %}{% endblock %}" + "{% extends 'other' %}{{ y | append: x }}{% block foo %}{% endblock %}" ), } ) @@ -933,3 +932,47 @@ def test_analyze_array_literals(env: Environment) -> None: }, tags={"assign": [Span("", 0, 26)]}, ) + + +def test_analyze_lambda_expression(env: Environment) -> None: + source = "{% assign y = 42 %}{% assign x = a | where: i => i.foo.bar == y %}" + + _assert( + env.from_string(source), + locals={ + "y": [Variable(["y"], Span("", 10, 11))], + "x": [Variable(["x"], Span("", 29, 30))], + }, + globals={ + "a": [Variable(["a"], Span("", 33, 34))], + }, + variables={ + "a": [Variable(["a"], Span("", 33, 34))], + "i": [Variable(["i", "foo", "bar"], Span("", 49, 58))], + "y": [Variable(["y"], Span("", 62, 63))], + }, + tags={"assign": [Span("", 0, 19), Span("", 19, 66)]}, + filters={"where": [Span("", 37, 42)]}, + ) + + +def test_analyze_two_argument_lambda_expression(env: Environment) -> None: + source = "{% assign y = 42 %}{% assign x = a | where: (i, j) => i.foo.bar == j %}" + + _assert( + env.from_string(source), + locals={ + "y": [Variable(["y"], Span("", 10, 11))], + "x": [Variable(["x"], Span("", 29, 30))], + }, + globals={ + "a": [Variable(["a"], Span("", 33, 34))], + }, + variables={ + "a": [Variable(["a"], Span("", 33, 34))], + "i": [Variable(["i", "foo", "bar"], Span("", 54, 63))], + "j": [Variable(["j"], Span("", 67, 68))], + }, + tags={"assign": [Span("", 0, 19), Span("", 19, 71)]}, + filters={"where": [Span("", 37, 42)]}, + ) From a9a80a96abc254c55df56799f3e79e889b9e113e Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 22 Jan 2025 08:56:34 +0000 Subject: [PATCH 12/14] Docs for `map`, `where` and `reject` --- docs/api/expression.md | 1 + docs/custom_filters.md | 48 ++++++ docs/filter_reference.md | 154 +++++++++++++++++- liquid2/builtin/filters/filtering_filters.py | 2 +- liquid2/expression.py | 5 +- mkdocs.yml | 1 + tests/liquid2-compliance-test-suite/cts.json | 4 +- .../tests/filters/reject.json | 4 +- 8 files changed, 209 insertions(+), 10 deletions(-) create mode 100644 docs/api/expression.md diff --git a/docs/api/expression.md b/docs/api/expression.md new file mode 100644 index 0000000..d7e966f --- /dev/null +++ b/docs/api/expression.md @@ -0,0 +1 @@ +::: liquid2.expression.Expression diff --git a/docs/custom_filters.md b/docs/custom_filters.md index 2e2cd2c..269f43f 100644 --- a/docs/custom_filters.md +++ b/docs/custom_filters.md @@ -137,3 +137,51 @@ del env.filters["safe"] !!! tip You can add, remove and replace filters on `liquid2.DEFAULT_ENVIRONMENT` too. Convenience functions [`parse()`](api/convenience.md#liquid2.parse) and [`render()`](api/convenience.md#liquid2.render) use `DEFAULT_ENVIRONMENT` + +## Filter argument validation + +When implementing a filter as a class, you have the option of implementing a `validate()` method. If present, `validate` will be called when parsing the template, giving you the opportunity to raise an exception if the filter's arguments are not acceptable. + +Here's an example of the built-in [`map`](filter_reference.md#map) filter. It uses `validate` to check that if a lambda expression is passed as an argument, that expression is a path to a variable, not a logical expression. + +!!! tip + + See [map_filter.py](https://github.com/jg-rp/python-liquid2/tree/main/liquid2/builtin/filters/map_filter.py) for the full example. + +```python +class MapFilter: + """An implementation of the `map` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) != 1: + raise LiquidTypeError( + f"{name!r} expects exactly one argument, got {len(args)}", + token=token, + ) + + if not isinstance(args[0], PositionalArgument): + raise LiquidTypeError( + f"{name!r} takes no keyword arguments", + token=token, + ) + + arg = args[0].value + + if isinstance(arg, LambdaExpression) and not isinstance(arg.expression, Path): + raise LiquidTypeError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + +# ... +``` diff --git a/docs/filter_reference.md b/docs/filter_reference.md index e28d23f..d773527 100644 --- a/docs/filter_reference.md +++ b/docs/filter_reference.md @@ -898,10 +898,10 @@ So much room for activities ! ## map - + ``` - | map: + | map: ``` Extract properties from an array of objects into a new array. @@ -936,12 +936,55 @@ For example, if `pages` is an array of objects with a `category` property: - technology ``` +### Lambda expressions + + + + +You can use a lambda expression to select arbitrary nested properties and array items from a sequence of objects. + +For example, if `pages` is an array of objects with a `tags` property, which is an array of strings: + +```json title="data" +{ + "pages": [ + { + "id": 1, + "title": "Introduction to Cooking", + "category": "Cooking", + "tags": ["recipes", "beginner", "cooking techniques"] + }, + { + "id": 2, + "title": "Top 10 Travel Destinations in Europe", + "category": "Travel", + "tags": ["Europe", "destinations", "travel tips"] + }, + { + "id": 3, + "title": "Mastering JavaScript", + "category": "Programming", + "tags": ["JavaScript", "web development", "coding"] + } + ] +} +``` + +```liquid2 +{% assign first_tags = pages | map: page => page.tags[0] -%} +{{ first_tags | json }} +``` + +```plain title="output" +["recipes", "Europe", "JavaScript"] +``` + ## minus -```` +``` | minus: ``` @@ -952,7 +995,7 @@ Return the result of subtracting one number from another. If either the input or {{ "16" | minus: 4 }} {{ 183.357 | minus: 12.2 }} {{ "hello" | minus: 10 }} -```` +``` ```plain title="output" 2 @@ -1190,6 +1233,62 @@ concatenation. World! ``` +## reject + + + + +``` + | reject: [, ] +``` + +Return a copy of the input array including only those objects that have a property, named with the first argument, **that is not equal to** a value, given as the second argument. If a second argument is not given, only elements with the named property that are falsy will be included. + +```json title="data" +{ + "products": [ + { "title": "Vacuum", "type": "house", "available": true }, + { "title": "Spatula", "type": "kitchen", "available": false }, + { "title": "Television", "type": "lounge", "available": true }, + { "title": "Garlic press", "type": "kitchen", "available": true } + ] +} +``` + +```liquid2 +All products: +{% for product in products -%} +- {{ product.title }} +{% endfor %} + +{%- assign kitchen_products = products | reject: "type", "kitchen" -%} + +Non kitchen products: +{% for product in kitchen_products -%} +- {{ product.title }} +{% endfor %} + +{%- assign unavailable_products = products | reject: "available" -%} + +Unavailable products: +{% for product in unavailable_products -%} +- {{ product.title }} +{% endfor %} +``` + +```plain title="output" +All products: +- Vacuum +- Spatula +- Television +- Garlic press +Non kitchen products: +- Vacuum +- Television +Unavailable products: +- Spatula +``` + ## remove @@ -2101,3 +2200,50 @@ Available product: - Television - Garlic press ``` + +### Lambda expressions + + + + +``` + | where: +``` + +Use a lambda expression to select array items according to an arbitrary Boolean expression (one that evaluates to true or false). + +In this example we select pages that have a "coding" tag. + +```json title="data" +{ + "pages": [ + { + "id": 1, + "title": "Introduction to Cooking", + "category": "Cooking", + "tags": ["recipes", "beginner", "cooking techniques"] + }, + { + "id": 2, + "title": "Top 10 Travel Destinations in Europe", + "category": "Travel", + "tags": ["Europe", "destinations", "travel tips"] + }, + { + "id": 3, + "title": "Mastering JavaScript", + "category": "Programming", + "tags": ["JavaScript", "web development", "coding"] + } + ] +} +``` + +```liquid2 +{% assign coding_pages = pages | where: page => page.tags contains 'coding' %} +{{ coding_pages | map: page => page.title | json }} +``` + +```plain title="output" +["Mastering JavaScript"] +``` diff --git a/liquid2/builtin/filters/filtering_filters.py b/liquid2/builtin/filters/filtering_filters.py index 6d6054a..817ed10 100644 --- a/liquid2/builtin/filters/filtering_filters.py +++ b/liquid2/builtin/filters/filtering_filters.py @@ -117,7 +117,7 @@ def __call__( if value is not None and not is_undefined(value): return [itm for itm in left if _getitem(itm, key) != value] - return [itm for itm in left if _getitem(itm, key) not in (False, None)] + return [itm for itm in left if _getitem(itm, key) in (False, None)] class CompactFilter: diff --git a/liquid2/expression.py b/liquid2/expression.py index c7d74d6..20bc8bb 100644 --- a/liquid2/expression.py +++ b/liquid2/expression.py @@ -35,5 +35,8 @@ def children(self) -> Iterable[Expression]: """Return this expression's child expressions.""" def scope(self) -> Iterable[Identifier]: - """Return variables this expression adds the scope of any child expressions.""" + """Return variables this expression adds the scope of any child expressions. + + Used by lambda expressions only. + """ return [] diff --git a/mkdocs.yml b/mkdocs.yml index c1a82f3..3d0a628 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,7 @@ nav: - Exceptions: "api/exceptions.md" - Builtin: "api/builtin.md" - AST: "api/ast.md" + - Expression: "api/expression.md" - Render context: "api/render_context.md" - Filter helpers: "api/filter.md" - Tag: "api/tag.md" diff --git a/tests/liquid2-compliance-test-suite/cts.json b/tests/liquid2-compliance-test-suite/cts.json index 35b5a6b..34af6de 100644 --- a/tests/liquid2-compliance-test-suite/cts.json +++ b/tests/liquid2-compliance-test-suite/cts.json @@ -2839,7 +2839,7 @@ } ] }, - "result": "(title,foo)(title,bar)" + "result": "(title,)" }, { "name": "filters, reject, array of objects, implicit null", @@ -2857,7 +2857,7 @@ } ] }, - "result": "(title,foo)(title,bar)" + "result": "(title,)" }, { "name": "filters, reject, array of objects, string match", diff --git a/tests/liquid2-compliance-test-suite/tests/filters/reject.json b/tests/liquid2-compliance-test-suite/tests/filters/reject.json index 84fb13f..2399a77 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/reject.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/reject.json @@ -16,7 +16,7 @@ } ] }, - "result": "(title,foo)(title,bar)" + "result": "(title,)" }, { "name": "array of objects, implicit null", @@ -34,7 +34,7 @@ } ] }, - "result": "(title,foo)(title,bar)" + "result": "(title,)" }, { "name": "array of objects, string match", From cd04b27617e6691ac36c76b570f0e499bcf69d24 Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 22 Jan 2025 10:55:46 +0000 Subject: [PATCH 13/14] More docs --- docs/filter_reference.md | 196 +++++++++++++++++++++++++++++++++++++++ docs/migration.md | 10 ++ docs/tag_reference.md | 2 +- 3 files changed, 207 insertions(+), 1 deletion(-) diff --git a/docs/filter_reference.md b/docs/filter_reference.md index d773527..18db17f 100644 --- a/docs/filter_reference.md +++ b/docs/filter_reference.md @@ -712,6 +712,133 @@ Return the input string with characters `&`, `<` and `>` converted to HTML-safe Have you read 'James & the Giant Peach'? ``` +## find + + + + +``` + | find: [, ] +``` + +Return the first item in the input array that contains a property, given as the first argument, equal to the value given as the second argument. If no such item exists, `null` is returned. + +In this example we select the first page in the "Programming" category. + +```json title="data" +{ + "pages": [ + { + "id": 1, + "title": "Introduction to Cooking", + "category": "Cooking", + "tags": ["recipes", "beginner", "cooking techniques"] + }, + { + "id": 2, + "title": "Top 10 Travel Destinations in Europe", + "category": "Travel", + "tags": ["Europe", "destinations", "travel tips"] + }, + { + "id": 3, + "title": "Mastering JavaScript", + "category": "Programming", + "tags": ["JavaScript", "web development", "coding"] + } + ] +} +``` + +```liquid2 +{% assign page = pages | find: 'category', 'Programming' %} +{{ page.title }} +``` + +```plain title="output" +Mastering JavaScript +``` + +### Lambda expressions + + + + +``` + | find: +``` + +We can pass a lambda expression as an argument to `find` to select the first item matching an arbitrary Boolean expression (one that evaluates to true or false). Using the same data as above, this example finds the first page with a "web development" tag. + +```liquid2 +{% assign page = pages | find: item => 'web development' in item.tags %} +{{ page.title }} +``` + +```plain title="output" +Mastering JavaScript +``` + +## find_index + +Return the index of the first item in the input array that contains a property, given as the first argument, equal to the value given as the second argument. If no such item exists, `null` is returned. + +In this example we find the index for the first page in the "Programming" category. + +```json title="data" +{ + "pages": [ + { + "id": 1, + "title": "Introduction to Cooking", + "category": "Cooking", + "tags": ["recipes", "beginner", "cooking techniques"] + }, + { + "id": 2, + "title": "Top 10 Travel Destinations in Europe", + "category": "Travel", + "tags": ["Europe", "destinations", "travel tips"] + }, + { + "id": 3, + "title": "Mastering JavaScript", + "category": "Programming", + "tags": ["JavaScript", "web development", "coding"] + } + ] +} +``` + +```liquid2 +{% assign index = pages | find_index: 'category', 'Programming' %} +{{ pages[index].title }} +``` + +```plain title="output" +Mastering JavaScript +``` + +### Lambda expressions + + + + +``` + | find_index: +``` + +We can pass a lambda expression as an argument to `find_index` to get the index for the first item matching an arbitrary Boolean expression (one that evaluates to true or false). Using the same data as above, this example finds the first page with a "web development" tag. + +```liquid2 +{% assign index = pages | find_index: item => 'web development' in item.tags %} +{{ page[index].title }} +``` + +```plain title="output" +Mastering JavaScript +``` + ## first @@ -762,6 +889,75 @@ Return the input down to the nearest whole number. Liquid tries to convert the i If the input can't be converted to a number, `0` is returned. +## has + + + + +``` + | has: [, ] +``` + +Return `true` if the input array contains an object with a property identified by the first argument that is equal to the object given as the second argument. `false` is returned if none of the items in the input array contain such a property/value. + +In this example we test to see if any pages are in the "Programming" category. + +```json title="data" +{ + "pages": [ + { + "id": 1, + "title": "Introduction to Cooking", + "category": "Cooking", + "tags": ["recipes", "beginner", "cooking techniques"] + }, + { + "id": 2, + "title": "Top 10 Travel Destinations in Europe", + "category": "Travel", + "tags": ["Europe", "destinations", "travel tips"] + }, + { + "id": 3, + "title": "Mastering JavaScript", + "category": "Programming", + "tags": ["JavaScript", "web development", "coding"] + } + ] +} +``` + +```liquid2 +{% assign has_programming_page = pages | has: 'category', 'Programming' %} +{{ has_programming_page }} +``` + +```plain title="output" +true +``` + +### Lambda expressions + + + + +``` + | has: +``` + +Use the same data as above, we can pass a lambda expression to `has` to test against an arbitrary Boolean expression (one that evaluates to true or false). + +This example test for a page with a category equal to "programming" or "Programming". + +```liquid2 +{% assign has_programming_page = pages | has: p => p.category == 'programming' or p.category == 'Programming' %} +{{ has_programming_page }} +``` + +```plain title="output" +true +``` + ## gettext diff --git a/docs/migration.md b/docs/migration.md index 722c182..97c262a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -92,6 +92,16 @@ Hello, {{ you | capitalize }}! {% assign greeting = 'Hello, ${you | capitalize}!' %} ``` +### Lambda expression as filter arguments + + + +Many built-in filters that operate on arrays now support lambda expressions. For example, we can use the [`where`](filter_reference.md#where) filter to select values according to an arbitrary Boolean expression. + +```liquid2 +{% assign coding_pages = pages | where: page => page.tags contains 'coding' %} +``` + ### Logical `not` Logical expressions now support negation with the `not` operator and grouping terms with parentheses by default. Previously this was an opt-in feature. diff --git a/docs/tag_reference.md b/docs/tag_reference.md index 32fab3a..19eeeb9 100644 --- a/docs/tag_reference.md +++ b/docs/tag_reference.md @@ -115,7 +115,7 @@ Hello, {{ you }}! Values can be modified prior to output using filters. Filters are applied to an expression using the pipe symbol (`|`), followed by the filter's name and, possibly, some filter arguments. Filter arguments appear after a colon (`:`) and are separated by commas (`,`). -Multiple filters can be chained together, effectively piping the output of one filter into the input of another. +Multiple filters can be chained together, effectively piping the output of one filter into the input of another. See the [filter reference](filter_reference.md) for details of all built in filters. ```liquid2 {{ user_name | upcase }} From 54ce52f7e57693262d265c141c0912f3551331f7 Mon Sep 17 00:00:00 2001 From: James Prior Date: Wed, 22 Jan 2025 11:06:10 +0000 Subject: [PATCH 14/14] Bump version number and update change log --- CHANGELOG.md | 13 ++++++++++++- liquid2/__about__.py | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4726701..c2d9c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,22 @@ # Python Liquid2 Change Log -## Version 0.2.1 (unreleased) +## Version 0.3.0 (unreleased) + +**Breaking changes** + +- Most built-in expression parsing functions/methods now expect the current `Environment` instance to be passed as the first argument. **Fixes** - Fixed `{% for %}` tag expressions with a comma between the iterable and `limit`, `offset` or `reversed`. Previously we were assuming a comma immediately following the iterable would mean we are iterating an array literal. We're also explicitly disallowing `limit`, `offset` and `reversed` arguments after an array literal. +**Features** + +- Added optional filter argument validation at template parse time. ([docs](https://jg-rp.github.io/python-liquid2/custom_filters/#filter-argument-validation)) +- Added lambda expressions as filter arguments. Both custom and built-in filters can accept arguments of the form ` => ` or `(, ) => `. +- Updated filters `map`, `where`, `sort`, `sort_natural`, `uniq`, `compact` and `sum` to accept lambda expression or string arguments. +- Added filters `reject`, `has`, `find` and `find_index`. + ## Version 0.2.0 **Features** diff --git a/liquid2/__about__.py b/liquid2/__about__.py index 3ced358..493f741 100644 --- a/liquid2/__about__.py +++ b/liquid2/__about__.py @@ -1 +1 @@ -__version__ = "0.2.1" +__version__ = "0.3.0"