diff --git a/src/django_bird/_typing.py b/src/django_bird/_typing.py index fd48e27..d299483 100644 --- a/src/django_bird/_typing.py +++ b/src/django_bird/_typing.py @@ -2,6 +2,8 @@ import sys +from django.template.base import FilterExpression + if sys.version_info >= (3, 12): from typing import override as typing_override else: @@ -9,4 +11,5 @@ override = typing_override -TagBits = list[str] +RawTagBits = list[str] +TagBits = dict[str, FilterExpression] diff --git a/src/django_bird/components.py b/src/django_bird/components.py index b8903af..156d23d 100644 --- a/src/django_bird/components.py +++ b/src/django_bird/components.py @@ -22,7 +22,6 @@ from .conf import app_settings from .params import Param from .params import Params -from .params import Value from .plugins import pm from .staticfiles import Asset from .staticfiles import AssetType @@ -143,9 +142,9 @@ def render(self, context: Context): data_attrs = [ Param( f"data-bird-{self.component.data_attribute_name}", - Value(True), + True, ), - Param("data-bird-id", Value(f'"{self.component.id}-{self.id}"')), + Param("data-bird-id", f"{self.component.id}-{self.id}"), ] self.params.attrs.extend(data_attrs) diff --git a/src/django_bird/params.py b/src/django_bird/params.py index 3711aa1..31d270d 100644 --- a/src/django_bird/params.py +++ b/src/django_bird/params.py @@ -5,7 +5,8 @@ from typing import TYPE_CHECKING from typing import Any -from django import template +from django.template.base import FilterExpression +from django.template.base import VariableDoesNotExist from django.template.context import Context from django.utils.safestring import SafeString from django.utils.safestring import mark_safe @@ -55,7 +56,7 @@ def render_attrs(self, context: Context) -> SafeString: @classmethod def from_node(cls, node: BirdNode) -> Params: return cls( - attrs=[Param.from_bit(bit) for bit in node.attrs], + attrs=[Param(key, Value(value)) for key, value in node.attrs.items()], props=[], ) @@ -63,10 +64,13 @@ def from_node(cls, node: BirdNode) -> Params: @dataclass class Param: name: str - value: Value + value: Value | str | bool def render_attr(self, context: Context) -> str: - value = self.value.resolve(context) + if isinstance(self.value, Value): + value = self.value.resolve(context, is_attr=True) + else: + value = self.value if value is None: return "" name = self.name.replace("_", "-") @@ -75,47 +79,21 @@ def render_attr(self, context: Context) -> str: return f'{name}="{value}"' def render_prop(self, context: Context) -> str | bool | None: - return self.value.resolve(context) - - @classmethod - def from_bit(cls, bit: str) -> Param: - if "=" in bit: - name, raw_value = bit.split("=", 1) - value = Value(raw_value.strip()) - else: - name, value = bit, Value(True) - return cls(name, value) + return ( + self.value.resolve(context) if isinstance(self.value, Value) else self.value + ) @dataclass class Value: - raw: str | bool | None - - def resolve(self, context: Context | dict[str, Any]) -> Any: - match (self.raw, self.is_quoted): - case (None, _): - return None - - case (str(raw_str), False) if raw_str == "False": - return None - case (str(raw_str), False) if raw_str == "True": - return True - - case (bool(b), _): - return b if b else None - - case (str(raw_str), False): - try: - return template.Variable(raw_str).resolve(context) - except template.VariableDoesNotExist: - return raw_str - - case (_, True): - return str(self.raw)[1:-1] - - @property - def is_quoted(self) -> bool: - if self.raw is None or isinstance(self.raw, bool): - return False - - return self.raw.startswith(("'", '"')) and self.raw.endswith(self.raw[0]) + raw: FilterExpression + + def resolve(self, context: Context | dict[str, Any], is_attr=False) -> Any: + if is_attr and self.raw.token == "False": + return None + if self.raw.is_var: + try: + self.raw.var.resolve(context) + except VariableDoesNotExist: + return self.raw.token + return self.raw.resolve(context) diff --git a/src/django_bird/templatetags/tags/bird.py b/src/django_bird/templatetags/tags/bird.py index f910225..b161f72 100644 --- a/src/django_bird/templatetags/tags/bird.py +++ b/src/django_bird/templatetags/tags/bird.py @@ -9,6 +9,7 @@ from django.template.base import Token from django.template.context import Context +from django_bird._typing import RawTagBits from django_bird._typing import TagBits from django_bird._typing import override @@ -23,7 +24,7 @@ def do_bird(parser: Parser, token: Token) -> BirdNode: raise template.TemplateSyntaxError(msg) name = bits.pop(0) - attrs: TagBits = [] + attrs: TagBits = {} isolated_context = False for bit in bits: @@ -33,13 +34,18 @@ def do_bird(parser: Parser, token: Token) -> BirdNode: case "/": continue case _: - attrs.append(bit) + if "=" in bit: + key, value = bit.split("=") + else: + key = bit + value = "True" + attrs[key] = parser.compile_filter(value) nodelist = parse_nodelist(bits, parser) return BirdNode(name, attrs, nodelist, isolated_context) -def parse_nodelist(bits: TagBits, parser: Parser) -> NodeList | None: +def parse_nodelist(bits: RawTagBits, parser: Parser) -> NodeList | None: # self-closing tag # {% bird name / %} if len(bits) > 0 and bits[-1] == "/": @@ -55,7 +61,7 @@ class BirdNode(template.Node): def __init__( self, name: str, - attrs: TagBits, + attrs: dict[str, str], nodelist: NodeList | None, isolated_context: bool = False, ) -> None: diff --git a/src/django_bird/templatetags/tags/prop.py b/src/django_bird/templatetags/tags/prop.py index ccdcd0d..a23815e 100644 --- a/src/django_bird/templatetags/tags/prop.py +++ b/src/django_bird/templatetags/tags/prop.py @@ -4,6 +4,7 @@ from typing import final from django import template +from django.template.base import FilterExpression from django.template.base import Parser from django.template.base import Token from django.template.context import Context @@ -14,7 +15,7 @@ TAG = "bird:prop" -def do_prop(_parser: Parser, token: Token) -> PropNode: +def do_prop(parser: Parser, token: Token) -> PropNode: _tag, *bits = token.split_contents() if not bits: msg = f"{TAG} tag requires at least one argument" @@ -26,14 +27,14 @@ def do_prop(_parser: Parser, token: Token) -> PropNode: name, default = prop.split("=") except ValueError: name = prop - default = None + default = "None" - return PropNode(name, default, bits) + return PropNode(name, parser.compile_filter(default), bits) @final class PropNode(template.Node): - def __init__(self, name: str, default: str | None, attrs: TagBits): + def __init__(self, name: str, default: FilterExpression, attrs: TagBits): self.name = name self.default = default self.attrs = attrs diff --git a/tests/templatetags/test_bird.py b/tests/templatetags/test_bird.py index 87c8410..2c60515 100644 --- a/tests/templatetags/test_bird.py +++ b/tests/templatetags/test_bird.py @@ -53,12 +53,12 @@ def test_missing_name_do_bird(self): @pytest.mark.parametrize( "params,expected_attrs", [ - ('class="btn"', ['class="btn"']), - ("class='btn'", ["class='btn'"]), - ('class="btn" id="my-btn"', ['class="btn"', 'id="my-btn"']), - ("disabled", ["disabled"]), - ("class=dynamic", ["class=dynamic"]), - ("class=item.name id=user.id", ["class=item.name", "id=user.id"]), + ('class="btn"', {"class": '"btn"'}), + ("class='btn'", {"class": "'btn'"}), + ('class="btn" id="my-btn"', {"class": '"btn"', "id": '"my-btn"'}), + ("disabled", {"disabled": "True"}), + ("class=dynamic", {"class": "dynamic"}), + ("class=item.name id=user.id", {"class": "item.name", "id": "user.id"}), ], ) def test_attrs_do_bird(self, params, expected_attrs): @@ -69,7 +69,9 @@ def test_attrs_do_bird(self, params, expected_attrs): node = do_bird(parser, start_token) - assert node.attrs == expected_attrs + for key, value in node.attrs.items(): + assert key in expected_attrs + assert expected_attrs[key] == value.token @pytest.mark.parametrize( "test_case", diff --git a/tests/templatetags/test_prop.py b/tests/templatetags/test_prop.py index 060bc7a..31cd5e3 100644 --- a/tests/templatetags/test_prop.py +++ b/tests/templatetags/test_prop.py @@ -24,8 +24,9 @@ def test_do_prop(contents, expected): node = do_prop(Parser([]), start_token) assert node.name == expected.name - assert node.default == expected.default - assert node.attrs == expected.attrs + assert (node.default.token if node.default else node.default) == expected.default + for node_attr, expected_attr in zip(node.attrs, expected.attrs, strict=False): + assert node_attr.token == expected_attr def test_do_prop_no_args(): diff --git a/tests/test_params.py b/tests/test_params.py index 9ac9214..1e5d4c8 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -121,26 +121,6 @@ def test_render_attr(self, param, context, expected): def test_render_prop(self, param, context, expected): assert param.render_prop(context) == expected - @pytest.mark.parametrize( - "bit,expected", - [ - ("class='btn'", Param(name="class", value=Value("'btn'"))), - ('class="btn"', Param(name="class", value=Value('"btn"'))), - ("class=btn", Param(name="class", value=Value("btn"))), - ("disabled", Param(name="disabled", value=Value(True))), - ( - "class=item.name", - Param(name="class", value=Value("item.name")), - ), - ( - 'class="item.name"', - Param(name="class", value=Value('"item.name"')), - ), - ], - ) - def test_from_bit(self, bit, expected): - assert Param.from_bit(bit) == expected - class TestParams: @pytest.mark.parametrize( @@ -269,11 +249,11 @@ def test_render_attrs(self, params, context, expected): "attrs,expected", [ ( - ['class="btn"'], + {"class": '"btn"'}, Params(attrs=[Param(name="class", value=Value('"btn"'))]), ), ( - ['class="btn"', 'id="my-btn"'], + {"class": '"btn"', "id": '"my-btn"'}, Params( attrs=[ Param(name="class", value=Value('"btn"')), @@ -282,15 +262,15 @@ def test_render_attrs(self, params, context, expected): ), ), ( - ["disabled"], + {"disabled": True}, Params(attrs=[Param(name="disabled", value=Value(True))]), ), ( - ["class=dynamic"], + {"class": "dynamic"}, Params(attrs=[Param(name="class", value=Value("dynamic"))]), ), ( - ["class=item.name", "id=user.id"], + {"class": "item.name", "id": "user.id"}, Params( attrs=[ Param(name="class", value=Value("item.name")),