mirror of
https://github.com/django-components/django-components.git
synced 2025-07-07 17:34:59 +00:00

* refactor: deprecate template caching, get_template_name, get_template, assoc template with Comp cls * refactor: change implementation * refactor: handle cached template loader * refactor: fix tests * refactor: fix test on windows * refactor: try to fix type errors * refactor: Re-cast `context` to fix type errors * refactor: fix linter error * refactor: fix typing * refactor: more linter fixes * refactor: more linter errors * refactor: revert extra node metadata
1199 lines
38 KiB
Python
1199 lines
38 KiB
Python
import inspect
|
|
import re
|
|
import pytest
|
|
from django.template import Context, Template
|
|
from django.template.base import TextNode, VariableNode
|
|
from django.template.defaulttags import IfNode, LoremNode
|
|
from django.template.exceptions import TemplateSyntaxError
|
|
|
|
from django_components import types
|
|
from django_components.node import BaseNode, template_tag
|
|
from django_components.templatetags import component_tags
|
|
from django_components.util.tag_parser import TagAttr
|
|
|
|
from django_components.testing import djc_test
|
|
from .testutils import setup_test_config
|
|
|
|
setup_test_config({"autodiscover": False})
|
|
|
|
|
|
@djc_test
|
|
class TestNode:
|
|
def test_node_class_requires_tag(self):
|
|
with pytest.raises(ValueError):
|
|
|
|
class CaptureNode(BaseNode):
|
|
pass
|
|
|
|
# Test that the template tag can be used within the template under the registered tag
|
|
def test_node_class_tags(self):
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
end_tag = "endmytag"
|
|
|
|
def render(self, context: Context, name: str, **kwargs) -> str:
|
|
return f"Hello, {name}!"
|
|
|
|
TestNode.register(component_tags.register)
|
|
|
|
# Works with end tag and self-closing
|
|
template_str: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
{% endmytag %}
|
|
Shorthand: {% mytag 'Mary' / %}
|
|
"""
|
|
template = Template(template_str)
|
|
rendered = template.render(Context({}))
|
|
|
|
assert rendered.strip() == "Hello, John!\n Shorthand: Hello, Mary!"
|
|
|
|
# But raises if missing end tag
|
|
template_str2: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
"""
|
|
with pytest.raises(TemplateSyntaxError, match=re.escape("Unclosed tag on line 3: 'mytag'")):
|
|
Template(template_str2)
|
|
|
|
TestNode.unregister(component_tags.register)
|
|
|
|
def test_node_class_no_end_tag(self):
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
|
|
def render(self, context: Context, name: str, **kwargs) -> str:
|
|
return f"Hello, {name}!"
|
|
|
|
TestNode.register(component_tags.register)
|
|
|
|
# Raises with end tag or self-closing
|
|
template_str: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
{% endmytag %}
|
|
Shorthand: {% mytag 'Mary' / %}
|
|
"""
|
|
with pytest.raises(TemplateSyntaxError, match=re.escape("Invalid block tag on line 4: 'endmytag'")):
|
|
Template(template_str)
|
|
|
|
# Works when missing end tag
|
|
template_str2: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
"""
|
|
template2 = Template(template_str2)
|
|
rendered2 = template2.render(Context({}))
|
|
assert rendered2.strip() == "Hello, John!"
|
|
|
|
TestNode.unregister(component_tags.register)
|
|
|
|
def test_node_class_flags(self):
|
|
captured = None
|
|
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
end_tag = "endmytag"
|
|
allowed_flags = ["required", "default"]
|
|
|
|
def render(self, context: Context, name: str, **kwargs) -> str:
|
|
nonlocal captured
|
|
captured = self.allowed_flags, self.flags, self.active_flags
|
|
|
|
return f"Hello, {name}!"
|
|
|
|
TestNode.register(component_tags.register)
|
|
|
|
template_str = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' required / %}
|
|
"""
|
|
template = Template(template_str)
|
|
template.render(Context({}))
|
|
|
|
allowed_flags, flags, active_flags = captured # type: ignore
|
|
assert allowed_flags == ["required", "default"]
|
|
assert flags == {"required": True, "default": False}
|
|
assert active_flags == ["required"]
|
|
|
|
TestNode.unregister(component_tags.register)
|
|
|
|
def test_node_render(self):
|
|
# Check that the render function is called with the context
|
|
captured = None
|
|
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
|
|
def render(self, context: Context) -> str:
|
|
nonlocal captured
|
|
captured = context.flatten()
|
|
|
|
return f"Hello, {context['name']}!"
|
|
|
|
TestNode.register(component_tags.register)
|
|
|
|
template_str = """
|
|
{% load component_tags %}
|
|
{% mytag / %}
|
|
"""
|
|
template = Template(template_str)
|
|
rendered = template.render(Context({"name": "John"}))
|
|
|
|
assert captured == {"False": False, "None": None, "True": True, "name": "John"}
|
|
assert rendered.strip() == "Hello, John!"
|
|
|
|
TestNode.unregister(component_tags.register)
|
|
|
|
def test_node_render_raises_if_no_context_arg(self):
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("`render()` method of TestNode must have at least two parameters"),
|
|
):
|
|
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
|
|
def render(self) -> str: # type: ignore
|
|
return ""
|
|
|
|
def test_node_render_accepted_params_set_by_render_signature(self):
|
|
captured = None
|
|
|
|
class TestNode1(BaseNode):
|
|
tag = "mytag"
|
|
allowed_flags = ["required", "default"]
|
|
|
|
def render(self, context: Context, name: str, count: int = 1, *, msg: str, mode: str = "default") -> str:
|
|
nonlocal captured
|
|
captured = name, count, msg, mode
|
|
return ""
|
|
|
|
TestNode1.register(component_tags.register)
|
|
|
|
# Set only required params
|
|
template1 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John' msg='Hello' required %}
|
|
"""
|
|
)
|
|
template1.render(Context({}))
|
|
assert captured == ("John", 1, "Hello", "default")
|
|
|
|
# Set all params
|
|
template2 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John2' count=2 msg='Hello' mode='custom' required %}
|
|
"""
|
|
)
|
|
template2.render(Context({}))
|
|
assert captured == ("John2", 2, "Hello", "custom")
|
|
|
|
# Set no params
|
|
template3 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': missing a required argument: 'name'"),
|
|
):
|
|
template3.render(Context({}))
|
|
|
|
# Omit required arg
|
|
template4 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag msg='Hello' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': missing a required argument: 'name'"),
|
|
):
|
|
template4.render(Context({}))
|
|
|
|
# Omit required kwarg
|
|
template5 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag name='John' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': missing a required argument: 'msg'"),
|
|
):
|
|
template5.render(Context({}))
|
|
|
|
# Extra args
|
|
template6 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 123 count=1 name='John' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': got multiple values for argument 'name'"),
|
|
):
|
|
template6.render(Context({}))
|
|
|
|
# Extra args after kwargs
|
|
template6 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag count=1 name='John' 123 %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("positional argument follows keyword argument"),
|
|
):
|
|
template6.render(Context({}))
|
|
|
|
# Extra kwargs
|
|
template7 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John' msg='Hello' mode='custom' var=123 %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': got an unexpected keyword argument 'var'"),
|
|
):
|
|
template7.render(Context({}))
|
|
|
|
# Extra kwargs - non-identifier or kwargs
|
|
template8 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John' msg='Hello' mode='custom' data-id=123 class="pa-4" @click.once="myVar" %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': got an unexpected keyword argument 'data-id'"),
|
|
):
|
|
template8.render(Context({}))
|
|
|
|
# Extra arg after special kwargs
|
|
template9 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag data-id=123 'John' msg='Hello' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
SyntaxError,
|
|
match=re.escape("positional argument follows keyword argument"),
|
|
):
|
|
template9.render(Context({}))
|
|
|
|
TestNode1.unregister(component_tags.register)
|
|
|
|
def test_node_render_extra_args_and_kwargs(self):
|
|
captured = None
|
|
|
|
class TestNode1(BaseNode):
|
|
tag = "mytag"
|
|
allowed_flags = ["required", "default"]
|
|
|
|
def render(self, context: Context, name: str, *args, msg: str, **kwargs) -> str:
|
|
nonlocal captured
|
|
captured = name, args, msg, kwargs
|
|
return ""
|
|
|
|
TestNode1.register(component_tags.register)
|
|
|
|
template1 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John'
|
|
123 456 789 msg='Hello' a=1 b=2 c=3 required
|
|
data-id=123 class="pa-4" @click.once="myVar"
|
|
%}
|
|
"""
|
|
)
|
|
template1.render(Context({}))
|
|
assert captured == (
|
|
"John",
|
|
(123, 456, 789),
|
|
"Hello",
|
|
{"a": 1, "b": 2, "c": 3, "data-id": 123, "class": "pa-4", "@click.once": "myVar"},
|
|
)
|
|
|
|
TestNode1.unregister(component_tags.register)
|
|
|
|
def test_node_render_kwargs_only(self):
|
|
captured = None
|
|
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
|
|
def render(self, context: Context, **kwargs) -> str:
|
|
nonlocal captured
|
|
captured = kwargs
|
|
return ""
|
|
|
|
TestNode.register(component_tags.register)
|
|
|
|
# Test with various kwargs including non-identifier keys
|
|
template = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag
|
|
name='John'
|
|
age=25
|
|
data-id=123
|
|
class="header"
|
|
@click="handleClick"
|
|
v-if="isVisible"
|
|
%}
|
|
"""
|
|
)
|
|
template.render(Context({}))
|
|
|
|
# All kwargs should be accepted since the function accepts **kwargs
|
|
assert captured == {
|
|
"name": "John",
|
|
"age": 25,
|
|
"data-id": 123,
|
|
"class": "header",
|
|
"@click": "handleClick",
|
|
"v-if": "isVisible",
|
|
}
|
|
|
|
# Test with positional args (should fail since function only accepts kwargs)
|
|
template2 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag "John" name="Mary" %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': takes 0 positional arguments but 1 was given"),
|
|
):
|
|
template2.render(Context({}))
|
|
|
|
TestNode.unregister(component_tags.register)
|
|
|
|
|
|
@djc_test
|
|
class TestDecorator:
|
|
def test_decorator_requires_tag(self):
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("template_tag() missing 1 required positional argument: 'tag'"),
|
|
):
|
|
|
|
@template_tag(component_tags.register) # type: ignore
|
|
def mytag(node: BaseNode, context: Context) -> str:
|
|
return ""
|
|
|
|
# Test that the template tag can be used within the template under the registered tag
|
|
def test_decorator_tags(self):
|
|
@template_tag(component_tags.register, tag="mytag", end_tag="endmytag")
|
|
def render(node: BaseNode, context: Context, name: str, **kwargs) -> str:
|
|
return f"Hello, {name}!"
|
|
|
|
# Works with end tag and self-closing
|
|
template_str: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
{% endmytag %}
|
|
Shorthand: {% mytag 'Mary' / %}
|
|
"""
|
|
template = Template(template_str)
|
|
rendered = template.render(Context({}))
|
|
|
|
assert rendered.strip() == "Hello, John!\n Shorthand: Hello, Mary!"
|
|
|
|
# But raises if missing end tag
|
|
template_str2: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
"""
|
|
with pytest.raises(
|
|
TemplateSyntaxError,
|
|
match=re.escape("Unclosed tag on line 3: 'mytag'"),
|
|
):
|
|
Template(template_str2)
|
|
|
|
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
|
|
|
def test_decorator_no_end_tag(self):
|
|
@template_tag(component_tags.register, tag="mytag") # type: ignore
|
|
def render(node: BaseNode, context: Context, name: str, **kwargs) -> str:
|
|
return f"Hello, {name}!"
|
|
|
|
# Raises with end tag or self-closing
|
|
template_str: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
{% endmytag %}
|
|
Shorthand: {% mytag 'Mary' / %}
|
|
"""
|
|
with pytest.raises(
|
|
TemplateSyntaxError,
|
|
match=re.escape("Invalid block tag on line 4: 'endmytag'"),
|
|
):
|
|
Template(template_str)
|
|
|
|
# Works when missing end tag
|
|
template_str2: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
"""
|
|
template2 = Template(template_str2)
|
|
rendered2 = template2.render(Context({}))
|
|
assert rendered2.strip() == "Hello, John!"
|
|
|
|
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
|
|
|
def test_decorator_flags(self):
|
|
@template_tag(component_tags.register, tag="mytag", end_tag="endmytag", allowed_flags=["required", "default"])
|
|
def render(node: BaseNode, context: Context, name: str, **kwargs) -> str:
|
|
return ""
|
|
|
|
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
|
|
|
def test_decorator_render(self):
|
|
# Check that the render function is called with the context
|
|
captured = None
|
|
|
|
@template_tag(component_tags.register, tag="mytag") # type: ignore
|
|
def render(node: BaseNode, context: Context) -> str:
|
|
nonlocal captured
|
|
captured = context.flatten()
|
|
return f"Hello, {context['name']}!"
|
|
|
|
template_str = """
|
|
{% load component_tags %}
|
|
{% mytag / %}
|
|
"""
|
|
template = Template(template_str)
|
|
rendered = template.render(Context({"name": "John"}))
|
|
|
|
assert captured == {"False": False, "None": None, "True": True, "name": "John"}
|
|
assert rendered.strip() == "Hello, John!"
|
|
|
|
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
|
|
|
def test_decorator_render_raises_if_no_context_arg(self):
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Failed to create node class in 'template_tag()' for 'render'"),
|
|
):
|
|
|
|
@template_tag(component_tags.register, tag="mytag") # type: ignore
|
|
def render(node: BaseNode) -> str: # type: ignore
|
|
return ""
|
|
|
|
def test_decorator_render_accepted_params_set_by_render_signature(self):
|
|
captured = None
|
|
|
|
@template_tag(component_tags.register, tag="mytag", allowed_flags=["required", "default"]) # type: ignore
|
|
def render(
|
|
node: BaseNode, context: Context, name: str, count: int = 1, *, msg: str, mode: str = "default"
|
|
) -> str:
|
|
nonlocal captured
|
|
captured = name, count, msg, mode
|
|
return ""
|
|
|
|
# Set only required params
|
|
template1 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John' msg='Hello' required %}
|
|
"""
|
|
)
|
|
template1.render(Context({}))
|
|
assert captured == ("John", 1, "Hello", "default")
|
|
|
|
# Set all params
|
|
template2 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John2' count=2 msg='Hello' mode='custom' required %}
|
|
"""
|
|
)
|
|
template2.render(Context({}))
|
|
assert captured == ("John2", 2, "Hello", "custom")
|
|
|
|
# Set no params
|
|
template3 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': missing a required argument: 'name'"),
|
|
):
|
|
template3.render(Context({}))
|
|
|
|
# Omit required arg
|
|
template4 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag msg='Hello' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': missing a required argument: 'name'"),
|
|
):
|
|
template4.render(Context({}))
|
|
|
|
# Omit required kwarg
|
|
template5 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag name='John' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': missing a required argument: 'msg'"),
|
|
):
|
|
template5.render(Context({}))
|
|
|
|
# Extra args
|
|
template6 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 123 count=1 name='John' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': got multiple values for argument 'name'"),
|
|
):
|
|
template6.render(Context({}))
|
|
|
|
# Extra args after kwargs
|
|
template6 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag count=1 name='John' 123 %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("positional argument follows keyword argument"),
|
|
):
|
|
template6.render(Context({}))
|
|
|
|
# Extra kwargs
|
|
template7 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John' msg='Hello' mode='custom' var=123 %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': got an unexpected keyword argument 'var'"),
|
|
):
|
|
template7.render(Context({}))
|
|
|
|
# Extra kwargs - non-identifier or kwargs
|
|
template8 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John' msg='Hello' mode='custom' data-id=123 class="pa-4" @click.once="myVar" %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': got an unexpected keyword argument 'data-id'"),
|
|
):
|
|
template8.render(Context({}))
|
|
|
|
# Extra arg after special kwargs
|
|
template9 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag data-id=123 'John' msg='Hello' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
SyntaxError,
|
|
match=re.escape("positional argument follows keyword argument"),
|
|
):
|
|
template9.render(Context({}))
|
|
|
|
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
|
|
|
def test_decorator_render_extra_args_and_kwargs(self):
|
|
captured = None
|
|
|
|
@template_tag(component_tags.register, tag="mytag", allowed_flags=["required", "default"]) # type: ignore
|
|
def render(node: BaseNode, context: Context, name: str, *args, msg: str, **kwargs) -> str:
|
|
nonlocal captured
|
|
captured = name, args, msg, kwargs
|
|
return ""
|
|
|
|
template1 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John'
|
|
123 456 789 msg='Hello' a=1 b=2 c=3 required
|
|
data-id=123 class="pa-4" @click.once="myVar"
|
|
%}
|
|
"""
|
|
)
|
|
template1.render(Context({}))
|
|
assert captured == (
|
|
"John",
|
|
(123, 456, 789),
|
|
"Hello",
|
|
{"a": 1, "b": 2, "c": 3, "data-id": 123, "class": "pa-4", "@click.once": "myVar"},
|
|
)
|
|
|
|
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
|
|
|
def test_decorator_render_kwargs_only(self):
|
|
captured = None
|
|
|
|
@template_tag(component_tags.register, tag="mytag") # type: ignore
|
|
def render(node: BaseNode, context: Context, **kwargs) -> str:
|
|
nonlocal captured
|
|
captured = kwargs
|
|
return ""
|
|
|
|
# Test with various kwargs including non-identifier keys
|
|
template = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag
|
|
name='John'
|
|
age=25
|
|
data-id=123
|
|
class="header"
|
|
@click="handleClick"
|
|
v-if="isVisible"
|
|
%}
|
|
"""
|
|
)
|
|
template.render(Context({}))
|
|
|
|
# All kwargs should be accepted since the function accepts **kwargs
|
|
assert captured == {
|
|
"name": "John",
|
|
"age": 25,
|
|
"data-id": 123,
|
|
"class": "header",
|
|
"@click": "handleClick",
|
|
"v-if": "isVisible",
|
|
}
|
|
|
|
# Test with positional args (should fail since function only accepts kwargs)
|
|
template2 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag "John" name="Mary" %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': takes 0 positional arguments but 1 was given"),
|
|
):
|
|
template2.render(Context({}))
|
|
|
|
render._node.unregister(component_tags.register) # type: ignore[attr-defined]
|
|
|
|
|
|
def force_signature_validation(fn):
|
|
"""
|
|
Creates a proxy around a function that makes __code__ inaccessible,
|
|
forcing the use of signature-based validation.
|
|
"""
|
|
|
|
class SignatureOnlyFunction:
|
|
def __init__(self, fn):
|
|
self.__wrapped__ = fn
|
|
self.__signature__ = inspect.signature(fn)
|
|
|
|
def __call__(self, *args, **kwargs):
|
|
return self.__wrapped__(*args, **kwargs)
|
|
|
|
def __getattr__(self, name):
|
|
# Return None for __code__ to force signature-based validation
|
|
if name == "__code__":
|
|
return None
|
|
# For all other attributes, delegate to the wrapped function
|
|
return getattr(self.__wrapped__, name)
|
|
|
|
return SignatureOnlyFunction(fn)
|
|
|
|
|
|
@djc_test
|
|
class TestSignatureBasedValidation:
|
|
# Test that the template tag can be used within the template under the registered tag
|
|
def test_node_class_tags(self):
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
end_tag = "endmytag"
|
|
|
|
@force_signature_validation
|
|
def render(self, context: Context, name: str, **kwargs) -> str:
|
|
return f"Hello, {name}!"
|
|
|
|
TestNode.register(component_tags.register)
|
|
|
|
# Works with end tag and self-closing
|
|
template_str: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
{% endmytag %}
|
|
Shorthand: {% mytag 'Mary' / %}
|
|
"""
|
|
template = Template(template_str)
|
|
rendered = template.render(Context({}))
|
|
|
|
assert rendered.strip() == "Hello, John!\n Shorthand: Hello, Mary!"
|
|
|
|
# But raises if missing end tag
|
|
template_str2: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
"""
|
|
with pytest.raises(
|
|
TemplateSyntaxError,
|
|
match=re.escape("Unclosed tag on line 3: 'mytag'"),
|
|
):
|
|
Template(template_str2)
|
|
|
|
TestNode.unregister(component_tags.register)
|
|
|
|
def test_node_class_no_end_tag(self):
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
|
|
@force_signature_validation
|
|
def render(self, context: Context, name: str, **kwargs) -> str:
|
|
return f"Hello, {name}!"
|
|
|
|
TestNode.register(component_tags.register)
|
|
|
|
# Raises with end tag or self-closing
|
|
template_str: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
{% endmytag %}
|
|
Shorthand: {% mytag 'Mary' / %}
|
|
"""
|
|
with pytest.raises(
|
|
TemplateSyntaxError,
|
|
match=re.escape("Invalid block tag on line 4: 'endmytag'"),
|
|
):
|
|
Template(template_str)
|
|
|
|
# Works when missing end tag
|
|
template_str2: types.django_html = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
"""
|
|
template2 = Template(template_str2)
|
|
rendered2 = template2.render(Context({}))
|
|
assert rendered2.strip() == "Hello, John!"
|
|
|
|
TestNode.unregister(component_tags.register)
|
|
|
|
def test_node_class_flags(self):
|
|
captured = None
|
|
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
end_tag = "endmytag"
|
|
allowed_flags = ["required", "default"]
|
|
|
|
@force_signature_validation
|
|
def render(self, context: Context, name: str, **kwargs) -> str:
|
|
nonlocal captured
|
|
captured = self.allowed_flags, self.flags, self.active_flags
|
|
return f"Hello, {name}!"
|
|
|
|
TestNode.register(component_tags.register)
|
|
|
|
template_str = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' required / %}
|
|
"""
|
|
template = Template(template_str)
|
|
template.render(Context({}))
|
|
|
|
allowed_flags, flags, active_flags = captured # type: ignore
|
|
assert allowed_flags == ["required", "default"]
|
|
assert flags == {"required": True, "default": False}
|
|
assert active_flags == ["required"]
|
|
|
|
TestNode.unregister(component_tags.register)
|
|
|
|
def test_node_class_attributes(self):
|
|
captured = None
|
|
|
|
class TestNodeWithEndTag(BaseNode):
|
|
tag = "mytag"
|
|
end_tag = "endmytag"
|
|
|
|
@force_signature_validation
|
|
def render(self, context: Context, name: str, **kwargs) -> str:
|
|
nonlocal captured
|
|
captured = self.params, self.nodelist, self.node_id, self.contents
|
|
return f"Hello, {name}!"
|
|
|
|
# Case 1 - Node with end tag and NOT self-closing
|
|
TestNodeWithEndTag.register(component_tags.register)
|
|
|
|
template_str1 = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' %}
|
|
INSIDE TAG {{ my_var }} {# comment #} {% lorem 1 w %} {% if True %} henlo {% endif %}
|
|
{% endmytag %}
|
|
"""
|
|
template1 = Template(template_str1)
|
|
template1.render(Context({}))
|
|
|
|
params1, nodelist1, node_id1, contents1 = captured # type: ignore
|
|
assert len(params1) == 1
|
|
assert isinstance(params1[0], TagAttr)
|
|
# NOTE: The comment node is not included in the nodelist
|
|
assert len(nodelist1) == 8
|
|
assert isinstance(nodelist1[0], TextNode)
|
|
assert isinstance(nodelist1[1], VariableNode)
|
|
assert isinstance(nodelist1[2], TextNode)
|
|
assert isinstance(nodelist1[3], TextNode)
|
|
assert isinstance(nodelist1[4], LoremNode)
|
|
assert isinstance(nodelist1[5], TextNode)
|
|
assert isinstance(nodelist1[6], IfNode)
|
|
assert isinstance(nodelist1[7], TextNode)
|
|
assert contents1 == "\n INSIDE TAG {{ my_var }} {# comment #} {% lorem 1 w %} {% if True %} henlo {% endif %}\n " # noqa: E501
|
|
assert node_id1 == "a1bc3e"
|
|
|
|
captured = None # Reset captured
|
|
|
|
# Case 2 - Node with end tag and NOT self-closing
|
|
template_str2 = """
|
|
{% load component_tags %}
|
|
{% mytag 'John' / %}
|
|
"""
|
|
template2 = Template(template_str2)
|
|
template2.render(Context({}))
|
|
|
|
params2, nodelist2, node_id2, contents2 = captured # type: ignore
|
|
assert len(params2) == 1 # type: ignore
|
|
assert isinstance(params2[0], TagAttr) # type: ignore
|
|
assert len(nodelist2) == 0 # type: ignore
|
|
assert contents2 is None # type: ignore
|
|
assert node_id2 == "a1bc3f" # type: ignore
|
|
|
|
captured = None # Reset captured
|
|
|
|
# Case 3 - Node without end tag
|
|
class TestNodeWithoutEndTag(BaseNode):
|
|
tag = "mytag2"
|
|
|
|
@force_signature_validation
|
|
def render(self, context: Context, name: str, **kwargs) -> str:
|
|
nonlocal captured
|
|
captured = self.params, self.nodelist, self.node_id, self.contents
|
|
return f"Hello, {name}!"
|
|
|
|
TestNodeWithoutEndTag.register(component_tags.register)
|
|
|
|
template_str3 = """
|
|
{% load component_tags %}
|
|
{% mytag2 'John' %}
|
|
"""
|
|
template3 = Template(template_str3)
|
|
template3.render(Context({}))
|
|
|
|
params3, nodelist3, node_id3, contents3 = captured # type: ignore
|
|
assert len(params3) == 1 # type: ignore
|
|
assert isinstance(params3[0], TagAttr) # type: ignore
|
|
assert len(nodelist3) == 0 # type: ignore
|
|
assert contents3 is None # type: ignore
|
|
assert node_id3 == "a1bc40" # type: ignore
|
|
|
|
# Cleanup
|
|
TestNodeWithEndTag.unregister(component_tags.register)
|
|
TestNodeWithoutEndTag.unregister(component_tags.register)
|
|
|
|
def test_node_render(self):
|
|
# Check that the render function is called with the context
|
|
captured = None
|
|
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
|
|
@force_signature_validation
|
|
def render(self, context: Context) -> str:
|
|
nonlocal captured
|
|
captured = context.flatten()
|
|
return f"Hello, {context['name']}!"
|
|
|
|
TestNode.register(component_tags.register)
|
|
|
|
template_str = """
|
|
{% load component_tags %}
|
|
{% mytag / %}
|
|
"""
|
|
template = Template(template_str)
|
|
rendered = template.render(Context({"name": "John"}))
|
|
|
|
assert captured == {"False": False, "None": None, "True": True, "name": "John"}
|
|
assert rendered.strip() == "Hello, John!"
|
|
|
|
TestNode.unregister(component_tags.register)
|
|
|
|
def test_node_render_raises_if_no_context_arg(self):
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("`render()` method of TestNode must have at least two parameters"),
|
|
):
|
|
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
|
|
def render(self) -> str: # type: ignore
|
|
return ""
|
|
|
|
def test_node_render_accepted_params_set_by_render_signature(self):
|
|
captured = None
|
|
|
|
class TestNode1(BaseNode):
|
|
tag = "mytag"
|
|
allowed_flags = ["required", "default"]
|
|
|
|
@force_signature_validation
|
|
def render(self, context: Context, name: str, count: int = 1, *, msg: str, mode: str = "default") -> str:
|
|
nonlocal captured
|
|
captured = name, count, msg, mode
|
|
return ""
|
|
|
|
TestNode1.register(component_tags.register)
|
|
|
|
# Set only required params
|
|
template1 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John' msg='Hello' required %}
|
|
"""
|
|
)
|
|
template1.render(Context({}))
|
|
assert captured == ("John", 1, "Hello", "default")
|
|
|
|
# Set all params
|
|
template2 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John2' count=2 msg='Hello' mode='custom' required %}
|
|
"""
|
|
)
|
|
template2.render(Context({}))
|
|
assert captured == ("John2", 2, "Hello", "custom")
|
|
|
|
# Set no params
|
|
template3 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': missing a required argument: 'name'"),
|
|
):
|
|
template3.render(Context({}))
|
|
|
|
# Omit required arg
|
|
template4 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag msg='Hello' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': missing a required argument: 'name'"),
|
|
):
|
|
template4.render(Context({}))
|
|
|
|
# Omit required kwarg
|
|
template5 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag name='John' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': missing a required argument: 'msg'"),
|
|
):
|
|
template5.render(Context({}))
|
|
|
|
# Extra args
|
|
template6 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 123 count=1 name='John' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': got multiple values for argument 'name'"),
|
|
):
|
|
template6.render(Context({}))
|
|
|
|
# Extra args after kwargs
|
|
template6 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag count=1 name='John' 123 %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("positional argument follows keyword argument"),
|
|
):
|
|
template6.render(Context({}))
|
|
|
|
# Extra kwargs
|
|
template7 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John' msg='Hello' mode='custom' var=123 %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': got an unexpected keyword argument 'var'"),
|
|
):
|
|
template7.render(Context({}))
|
|
|
|
# Extra kwargs - non-identifier or kwargs
|
|
template8 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John' msg='Hello' mode='custom' data-id=123 class="pa-4" @click.once="myVar" %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': got an unexpected keyword argument 'data-id'"),
|
|
):
|
|
template8.render(Context({}))
|
|
|
|
# Extra arg after special kwargs
|
|
template9 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag data-id=123 'John' msg='Hello' %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
SyntaxError,
|
|
match=re.escape("positional argument follows keyword argument"),
|
|
):
|
|
template9.render(Context({}))
|
|
|
|
TestNode1.unregister(component_tags.register)
|
|
|
|
def test_node_render_extra_args_and_kwargs(self):
|
|
captured = None
|
|
|
|
class TestNode1(BaseNode):
|
|
tag = "mytag"
|
|
allowed_flags = ["required", "default"]
|
|
|
|
@force_signature_validation
|
|
def render(self, context: Context, name: str, *args, msg: str, **kwargs) -> str:
|
|
nonlocal captured
|
|
captured = name, args, msg, kwargs
|
|
return ""
|
|
|
|
TestNode1.register(component_tags.register)
|
|
|
|
template1 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag 'John'
|
|
123 456 789 msg='Hello' a=1 b=2 c=3 required
|
|
data-id=123 class="pa-4" @click.once="myVar"
|
|
%}
|
|
"""
|
|
)
|
|
template1.render(Context({}))
|
|
assert captured == (
|
|
"John",
|
|
(123, 456, 789),
|
|
"Hello",
|
|
{"a": 1, "b": 2, "c": 3, "data-id": 123, "class": "pa-4", "@click.once": "myVar"},
|
|
)
|
|
|
|
TestNode1.unregister(component_tags.register)
|
|
|
|
def test_node_render_kwargs_only(self):
|
|
captured = None
|
|
|
|
class TestNode(BaseNode):
|
|
tag = "mytag"
|
|
|
|
@force_signature_validation
|
|
def render(self, context: Context, **kwargs) -> str:
|
|
nonlocal captured
|
|
captured = kwargs
|
|
return ""
|
|
|
|
TestNode.register(component_tags.register)
|
|
|
|
# Test with various kwargs including non-identifier keys
|
|
template = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag
|
|
name='John'
|
|
age=25
|
|
data-id=123
|
|
class="header"
|
|
@click="handleClick"
|
|
v-if="isVisible"
|
|
%}
|
|
"""
|
|
)
|
|
template.render(Context({}))
|
|
|
|
# All kwargs should be accepted since the function accepts **kwargs
|
|
assert captured == {
|
|
"name": "John",
|
|
"age": 25,
|
|
"data-id": 123,
|
|
"class": "header",
|
|
"@click": "handleClick",
|
|
"v-if": "isVisible",
|
|
}
|
|
|
|
# Test with positional args (should fail since function only accepts kwargs)
|
|
template2 = Template(
|
|
"""
|
|
{% load component_tags %}
|
|
{% mytag "John" name="Mary" %}
|
|
"""
|
|
)
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=re.escape("Invalid parameters for tag 'mytag': takes 0 positional arguments but 1 was given"),
|
|
):
|
|
template2.render(Context({}))
|
|
|
|
TestNode.unregister(component_tags.register)
|