diff --git a/CHANGES.rst b/CHANGES.rst index a494a1f0..ce7bc836 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,15 @@ Python Liquid Change Log ======================== +Version 1.4.5 +------------- + +**Hot fix** + +- Fixed a bug where boolean expressions and the default filter would treat ``0.0`` and + ``decimal.Decimal("0")`` as ``False``. Python considers these values to be falsy, + Liquid does not. See `#74 `_. + Version 1.4.4 ------------- diff --git a/liquid/__init__.py b/liquid/__init__.py index 1c44c0bd..f7a2d001 100644 --- a/liquid/__init__.py +++ b/liquid/__init__.py @@ -1,7 +1,7 @@ # flake8: noqa # pylint: disable=useless-import-alias,missing-module-docstring -__version__ = "1.4.4" +__version__ = "1.4.5" try: from markupsafe import escape as escape diff --git a/liquid/builtin/filters/misc.py b/liquid/builtin/filters/misc.py index f5122b3d..65e4153c 100644 --- a/liquid/builtin/filters/misc.py +++ b/liquid/builtin/filters/misc.py @@ -2,6 +2,7 @@ from __future__ import annotations import datetime +import decimal import functools from typing import Any @@ -49,8 +50,8 @@ def default(obj: Any, default_: object = "", *, allow_false: bool = False) -> An if hasattr(obj, "__liquid__"): _obj = obj.__liquid__() - # Liquid zero is not falsy. - if isinstance(_obj, int) and not isinstance(_obj, bool): + # Liquid 0, 0.0, 0b0, 0X0, 0o0 and Decimal("0") are not falsy. + if not isinstance(obj, bool) and isinstance(obj, (int, float, decimal.Decimal)): return obj if allow_false is True and _obj is False: diff --git a/liquid/expression.py b/liquid/expression.py index 62ffb55e..1f9a0063 100644 --- a/liquid/expression.py +++ b/liquid/expression.py @@ -8,6 +8,7 @@ from abc import abstractmethod from collections import abc +from decimal import Decimal from itertools import islice from typing import Dict @@ -902,35 +903,23 @@ def eval_number_expression(left: Number, operator: str, right: Number) -> bool: raise LiquidTypeError(f"unknown operator {left} {operator} {right}") +def _is_py_falsy_number(obj: object) -> bool: + # Liquid 0, 0.0, 0b0, 0X0, 0o0 and Decimal("0") are not falsy. + return not isinstance(obj, bool) and isinstance(obj, (int, float, Decimal)) + + def is_truthy(obj: Any) -> bool: """Return True if the given object is Liquid truthy.""" if hasattr(obj, "__liquid__"): obj = obj.__liquid__() - - # Liquid zero is not falsy. - if isinstance(obj, int) and not isinstance(obj, bool): - return True - - if obj in (False, None): - return False - return True + return _is_py_falsy_number(obj) or obj not in (False, None) # pylint: disable=too-many-return-statements def compare_bool(left: Any, operator: str, right: Any) -> bool: """Compare an object to a boolean value.""" - if isinstance(left, bool) and ( - isinstance(right, int) and not isinstance(right, bool) - ): - if operator in ("==", "<", ">", "<=", ">="): - return False - if operator in ("!=", "<>"): - return True - raise LiquidTypeError( - f"unknown operator: {type(left)} {operator} {type(right)}" - ) - if isinstance(right, bool) and ( - isinstance(left, int) and not isinstance(left, bool) + if (isinstance(left, bool) and _is_py_falsy_number(right)) or ( + isinstance(right, bool) and _is_py_falsy_number(left) ): if operator in ("==", "<", ">", "<=", ">="): return False diff --git a/liquid/golden/default_filter.py b/liquid/golden/default_filter.py index 217958ae..9184f5e0 100644 --- a/liquid/golden/default_filter.py +++ b/liquid/golden/default_filter.py @@ -104,4 +104,9 @@ template=r'{{ 0 | default: "bar", allow_false: true }}', expect="0", ), + Case( + description="0.0 is not falsy", + template=r'{{ 0.0 | default: "bar" }}', + expect="0.0", + ), ] diff --git a/liquid/golden/if_tag.py b/liquid/golden/if_tag.py index c8140728..c569bd8d 100644 --- a/liquid/golden/if_tag.py +++ b/liquid/golden/if_tag.py @@ -166,6 +166,11 @@ template=(r"{% if 0 %}Hello{% else %}Goodbye{% endif %}"), expect="Hello", ), + Case( + description=("0.0 is truthy"), + template=(r"{% if 0.0 %}Hello{% else %}Goodbye{% endif %}"), + expect="Hello", + ), Case( description=("one is not equal to true"), template=(r"{% if 1 == true %}Hello{% else %}Goodbye{% endif %}"), diff --git a/tests/filters/test_misc.py b/tests/filters/test_misc.py index 8e1fd95d..ad739fa3 100644 --- a/tests/filters/test_misc.py +++ b/tests/filters/test_misc.py @@ -1,6 +1,7 @@ """Test miscellaneous filter functions.""" # pylint: disable=too-many-public-methods,too-many-lines,missing-class-docstring import datetime +import decimal import platform import unittest @@ -277,6 +278,20 @@ def test_default(self): kwargs={}, expect=0, ), + Case( + description="0.0 is not false", + val=0.0, + args=["bar"], + kwargs={}, + expect=0.0, + ), + Case( + description="Decimal('0') is not false", + val=decimal.Decimal("0"), + args=["bar"], + kwargs={}, + expect=decimal.Decimal("0"), + ), Case( description="one is not false or true", val=1, diff --git a/tests/test_evaluate_expression.py b/tests/test_evaluate_expression.py index d4accea8..7423efc1 100644 --- a/tests/test_evaluate_expression.py +++ b/tests/test_evaluate_expression.py @@ -1,23 +1,26 @@ """Liquid expression evaluator test cases.""" import unittest -from typing import NamedTuple, Any, Mapping + +from decimal import Decimal + +from typing import Any +from typing import Mapping +from typing import NamedTuple from liquid.environment import Environment from liquid.context import Context from liquid.stream import TokenStream -from liquid.lex import ( - tokenize_filtered_expression, - tokenize_boolean_expression, - tokenize_loop_expression, - tokenize_assignment_expression, -) -from liquid.parse import ( - parse_filtered_expression, - parse_boolean_expression, - parse_assignment_expression, - parse_loop_expression, -) + +from liquid.lex import tokenize_filtered_expression +from liquid.lex import tokenize_boolean_expression +from liquid.lex import tokenize_loop_expression +from liquid.lex import tokenize_assignment_expression + +from liquid.parse import parse_filtered_expression +from liquid.parse import parse_boolean_expression +from liquid.parse import parse_assignment_expression +from liquid.parse import parse_loop_expression class Case(NamedTuple): @@ -495,6 +498,12 @@ def test_eval_boolean_expression(self): expression="0", expect=True, ), + Case( + description="0.0", + context={}, + expression="0.0", + expect=True, + ), Case( description="one", context={}, @@ -507,6 +516,24 @@ def test_eval_boolean_expression(self): expression="0 == false", expect=False, ), + Case( + description="zero equals true", + context={}, + expression="0 == true", + expect=False, + ), + Case( + description="0.0 equals false", + context={}, + expression="0.0 == false", + expect=False, + ), + Case( + description="0.0 equals true", + context={}, + expression="0.0 == true", + expect=False, + ), Case( description="one equals true", context={}, @@ -531,6 +558,12 @@ def test_eval_boolean_expression(self): expression="0 < true", expect=False, ), + Case( + description="0.0 is less than true", + context={}, + expression="0.0 < true", + expect=False, + ), Case( description="one is not equal true", context={}, @@ -543,18 +576,54 @@ def test_eval_boolean_expression(self): expression="0 != false", expect=True, ), + Case( + description="0.0 is not equal false", + context={}, + expression="0.0 != false", + expect=True, + ), Case( description="false is not equal zero", context={}, expression="false != 0", expect=True, ), + Case( + description="false is not equal 0.0", + context={}, + expression="false != 0.0", + expect=True, + ), Case( description="false is less than string", context={}, expression="false < 'false'", expect=False, ), + Case( + description="decimal zero", + context={"n": Decimal("0")}, + expression="n", + expect=True, + ), + Case( + description="decimal non-zero", + context={"n": Decimal("1")}, + expression="n", + expect=True, + ), + Case( + description="decimal zero equals false", + context={"n": Decimal("0")}, + expression="n == false", + expect=False, + ), + Case( + description="decimal zero equals true", + context={"n": Decimal("0")}, + expression="n == true", + expect=False, + ), ] self._test(test_cases, tokenize_boolean_expression, parse_boolean_expression)