diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d67f18c..bf41131f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # Release notes +## v0.125 + +#### Feat + +- `@template_tag` and `BaseNode` - A decorator and a class that allow you to define + custom template tags that will behave similarly to django-components' own template tags. + + Read more on [Template tags](https://EmilStenstrom.github.io/django-components/0.125/concepts/advanced/template_tags/). + + Template tags defined with `@template_tag` and `BaseNode` will have the following features: + + - Accepting args, kwargs, and flags. + + - Allowing literal lists and dicts as inputs as: + + `key=[1, 2, 3]` or `key={"a": 1, "b": 2}` + - Using template tags tag inputs as: + + `{% my_tag key="{% lorem 3 w %}" / %}` + - Supporting the flat dictionary definition: + + `attr:key=value` + - Spreading args and kwargs with `...`: + + `{% my_tag ...args ...kwargs / %}` + - Being able to call the template tag as: + + `{% my_tag %} ... {% endmy_tag %}` or `{% my_tag / %}` + + +#### Refactor + +- Refactored template tag input validation. When you now call template tags like + `{% slot %}`, `{% fill %}`, `{% html_attrs %}`, and others, their inputs are now + validated the same way as Python function inputs are. + + So, for example + + ```django + {% slot "my_slot" name="content" / %} + ``` + + will raise an error, because the positional argument `name` is given twice. + + NOTE: Special kwargs whose keys are not valid Python variable names are not affected by this change. + So when you define: + + ```django + {% component data-id=123 / %} + ``` + + The `data-id` will still be accepted as a valid kwarg, assuming that your `get_context_data()` + accepts `**kwargs`: + + ```py + def get_context_data(self, **kwargs): + return { + "data_id": kwargs["data-id"], + } + ``` + ## v0.124 #### Feat diff --git a/docs/concepts/advanced/authoring_component_libraries.md b/docs/concepts/advanced/authoring_component_libraries.md index fdf3493b..242bbbb5 100644 --- a/docs/concepts/advanced/authoring_component_libraries.md +++ b/docs/concepts/advanced/authoring_component_libraries.md @@ -1,6 +1,6 @@ --- title: Authoring component libraries -weight: 8 +weight: 9 --- You can publish and share your components for others to use. Below you will find the steps to do so. diff --git a/docs/concepts/advanced/tag_formatter.md b/docs/concepts/advanced/tag_formatter.md index a5af24d6..ae441956 100644 --- a/docs/concepts/advanced/tag_formatter.md +++ b/docs/concepts/advanced/tag_formatter.md @@ -1,6 +1,6 @@ --- title: Tag formatters -weight: 7 +weight: 8 --- ## Customizing component tags with TagFormatter diff --git a/docs/concepts/advanced/template_tags.md b/docs/concepts/advanced/template_tags.md new file mode 100644 index 00000000..578db78f --- /dev/null +++ b/docs/concepts/advanced/template_tags.md @@ -0,0 +1,197 @@ +--- +title: Custom template tags +weight: 7 +--- + +Template tags introduced by django-components, such as `{% component %}` and `{% slot %}`, +offer additional features over the default Django template tags: + + +- [Self-closing tags `{% mytag / %}`](../../fundamentals/template_tag_syntax#self-closing-tags) +- [Allowing the use of `:`, `-` (and more) in keys](../../fundamentals/template_tag_syntax#special-characters) +- [Spread operator `...`](../../fundamentals/template_tag_syntax#spread-operator) +- [Using template tags as inputs to other template tags](../../fundamentals/template_tag_syntax#use-template-tags-inside-component-inputs) +- [Flat definition of dictionaries `attr:key=val`](../../fundamentals/template_tag_syntax#pass-dictonary-by-its-key-value-pairs) +- Function-like input validation + +You too can easily create custom template tags that use the above features. + +## Defining template tags with `@template_tag` + +The simplest way to create a custom template tag is using +the [`template_tag`](../../../reference/api#django_components.template_tag) decorator. +This decorator allows you to define a template tag by just writing a function that returns the rendered content. + +```python +from django.template import Context, Library +from django_components import BaseNode, template_tag + +library = Library() + +@template_tag( + library, + tag="mytag", + end_tag="endmytag", + allowed_flags=["required"] +) +def mytag(node: BaseNode, context: Context, name: str, **kwargs) -> str: + return f"Hello, {name}!" +``` + +This will allow you to use the tag in your templates like this: + +```django +{% mytag name="John" %} +{% endmytag %} + +{# or with self-closing syntax #} +{% mytag name="John" / %} + +{# or with flags #} +{% mytag name="John" required %} +{% endmytag %} +``` + +### Parameters + +The `@template_tag` decorator accepts the following parameters: + +- `library`: The Django template library to register the tag with +- `tag`: The name of the template tag (e.g. `"mytag"` for `{% mytag %}`) +- `end_tag`: Optional. The name of the end tag (e.g. `"endmytag"` for `{% endmytag %}`) +- `allowed_flags`: Optional. List of flags that can be used with the tag (e.g. `["required"]` for `{% mytag required %}`) + +### Function signature + +The function decorated with `@template_tag` must accept at least two arguments: + +1. `node`: The node instance (we'll explain this in detail in the next section) +2. `context`: The Django template context + +Any additional parameters in your function's signature define what inputs your template tag accepts. For example: + +```python +@template_tag(library, tag="greet") +def greet( + node: BaseNode, + context: Context, + name: str, # required positional argument + count: int = 1, # optional positional argument + *, # keyword-only arguments marker + msg: str, # required keyword argument + mode: str = "default", # optional keyword argument +) -> str: + return f"{msg}, {name}!" * count +``` + +This allows the tag to be used like: + +```django +{# All parameters #} +{% greet "John" count=2 msg="Hello" mode="custom" %} + +{# Only required parameters #} +{% greet "John" msg="Hello" %} + +{# Missing required parameter - will raise error #} +{% greet "John" %} {# Error: missing 'msg' #} +``` + +When you pass input to a template tag, it behaves the same way as if you passed the input to a function: + +- If required parameters are missing, an error is raised +- If unexpected parameters are passed, an error is raised + +To accept keys that are not valid Python identifiers (e.g. `data-id`), or would conflict with Python keywords (e.g. `is`), you can use the `**kwargs` syntax: + +```python +@template_tag(library, tag="greet") +def greet( + node: BaseNode, + context: Context, + **kwargs, +) -> str: + attrs = kwargs.copy() + is_var = attrs.pop("is", None) + attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs.items()) + + return mark_safe(f""" +
+ Hello, {is_var}! +
+ """) +``` + +This allows you to use the tag like this: + +```django +{% greet is="John" data-id="123" %} +``` + +## Defining template tags with `BaseNode` + +For more control over your template tag, you can subclass [`BaseNode`](../../../reference/api#django_components.BaseNode) directly instead of using the decorator. This gives you access to additional features like the node's internal state and parsing details. + +```python +from django_components import BaseNode + +class GreetNode(BaseNode): + tag = "greet" + end_tag = "endgreet" + allowed_flags = ["required"] + + def render(self, context: Context, name: str, **kwargs) -> str: + # Access node properties + if self.flags["required"]: + return f"Required greeting: Hello, {name}!" + return f"Hello, {name}!" + +# Register the node +GreetNode.register(library) +``` + +### Node properties + +When using `BaseNode`, you have access to several useful properties: + +- `node_id`: A unique identifier for this node instance +- `flags`: Dictionary of flag values (e.g. `{"required": True}`) +- `params`: List of raw parameters passed to the tag +- `nodelist`: The template nodes between the start and end tags +- `active_flags`: List of flags that are currently set to True + +This is what the `node` parameter in the `@template_tag` decorator gives you access to - it's the instance of the node class that was automatically created for your template tag. + +### Rendering content between tags + +When your tag has an end tag, you can access and render the content between the tags using `nodelist`: + +```python +class WrapNode(BaseNode): + tag = "wrap" + end_tag = "endwrap" + + def render(self, context: Context, tag: str = "div", **attrs) -> str: + # Render the content between tags + inner = self.nodelist.render(context) + attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs.items()) + return f"<{tag} {attrs_str}>{inner}" + +# Usage: +{% wrap tag="section" class="content" %} + Hello, world! +{% endwrap %} +``` + +### Unregistering nodes + +You can unregister a node from a library using the `unregister` method: + +```python +GreetNode.unregister(library) +``` + +This is particularly useful in testing when you want to clean up after registering temporary tags. diff --git a/docs/scripts/reference.py b/docs/scripts/reference.py index 967df5d9..c15de89f 100644 --- a/docs/scripts/reference.py +++ b/docs/scripts/reference.py @@ -49,8 +49,8 @@ from django_components import ComponentVars, TagFormatterABC from django_components.component import Component +from django_components.node import BaseNode from django_components.util.misc import get_import_path -from django_components.util.template_tag import TagSpec # NOTE: This file is an entrypoint for the `gen-files` plugin in `mkdocs.yml`. # However, `gen-files` plugin runs this file as a script, NOT as a module. @@ -504,17 +504,18 @@ def gen_reference_templatetags(): f"Import as\n```django\n{{% load {mod_name} %}}\n```\n\n" ) - for name, obj in inspect.getmembers(tags_module): + for _, obj in inspect.getmembers(tags_module): if not _is_template_tag(obj): continue - tag_spec: TagSpec = obj._tag_spec - tag_signature = _format_tag_signature(tag_spec) - obj_lineno = inspect.findsource(obj)[1] + node_cls: BaseNode = obj + name = node_cls.tag + tag_signature = _format_tag_signature(node_cls) + obj_lineno = inspect.findsource(node_cls)[1] source_code_link = _format_source_code_html(module_rel_path, obj_lineno) # Use the tag's function's docstring - docstring = dedent(obj.__doc__ or "").strip() + docstring = dedent(node_cls.__doc__ or "").strip() # Rebuild (almost) the same documentation than as if we used # mkdocstrings' `::: path.to.module` syntax. @@ -585,29 +586,29 @@ def _list_urls(urlpatterns: Sequence[Union[URLPattern, URLResolver]], prefix="") return urls -def _format_tag_signature(tag_spec: TagSpec) -> str: +def _format_tag_signature(node_cls: BaseNode) -> str: """ - Given the TagSpec instance, format the tag's function signature like: + Given the Node class, format the tag's function signature like: ```django - {% component [arg, ...] **kwargs [only] %} + {% component arg1: int, arg2: str, *args, **kwargs: Any [only] %} {% endcomponent %} ``` """ # The signature returns a string like: # `(arg: Any, **kwargs: Any) -> None` - params_str = str(tag_spec.signature) + params_str = str(node_cls._signature) # Remove the return type annotation, the `-> None` part params_str = params_str.rsplit("->", 1)[0] # Remove brackets around the params, to end up only with `arg: Any, **kwargs: Any` params_str = params_str.strip()[1:-1] - if tag_spec.flags: - params_str += " " + " ".join([f"[{name}]" for name in tag_spec.flags]) + if node_cls.allowed_flags: + params_str += " " + " ".join([f"[{name}]" for name in node_cls.allowed_flags]) # Create the function signature - full_tag = "{% " + tag_spec.tag + " " + params_str + " %}" - if tag_spec.end_tag: - full_tag += f"\n{{% {tag_spec.end_tag} %}}" + full_tag = "{% " + node_cls.tag + " " + params_str + " %}" + if node_cls.end_tag: + full_tag += f"\n{{% {node_cls.end_tag} %}}" return full_tag @@ -722,7 +723,7 @@ def _is_tag_formatter_instance(obj: Any) -> bool: def _is_template_tag(obj: Any) -> bool: - return callable(obj) and hasattr(obj, "_tag_spec") + return inspect.isclass(obj) and issubclass(obj, BaseNode) def gen_reference(): diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index 20087a1c..2ea9083e 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -19,6 +19,7 @@ from django_components.components import DynamicComponent from django_components.dependencies import render_dependencies from django_components.library import TagProtectedError +from django_components.node import BaseNode, template_tag from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult from django_components.tag_formatter import ( ComponentFormatter, @@ -40,6 +41,7 @@ "AlreadyRegistered", "autodiscover", "cached_template", + "BaseNode", "ContextBehavior", "ComponentsSettings", "Component", @@ -72,5 +74,6 @@ "TagFormatterABC", "TagProtectedError", "TagResult", + "template_tag", "types", ] diff --git a/src/django_components/attributes.py b/src/django_components/attributes.py index 32307a6e..d6926139 100644 --- a/src/django_components/attributes.py +++ b/src/django_components/attributes.py @@ -2,39 +2,84 @@ # See https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/templatetags/components.py # noqa: E501 # And https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/attributes.py # noqa: E501 -from typing import Any, Dict, List, Mapping, Optional, Tuple +from typing import Any, Dict, Mapping, Optional, Tuple from django.template import Context from django.utils.html import conditional_escape, format_html from django.utils.safestring import SafeString, mark_safe from django_components.node import BaseNode -from django_components.util.template_tag import TagParams - -HTML_ATTRS_DEFAULTS_KEY = "defaults" -HTML_ATTRS_ATTRS_KEY = "attrs" class HtmlAttrsNode(BaseNode): - def __init__( + """ + Generate HTML attributes (`key="value"`), combining data from multiple sources, + whether its template variables or static text. + + It is designed to easily merge HTML attributes passed from outside with the internal. + See how to in [Passing HTML attributes to components](../../guides/howto/passing_html_attrs/). + + **Args:** + + - `attrs` (dict, optional): Optional dictionary that holds HTML attributes. On conflict, overrides + values in the `default` dictionary. + - `default` (str, optional): Optional dictionary that holds HTML attributes. On conflict, is overriden + with values in the `attrs` dictionary. + - Any extra kwargs will be appended to the corresponding keys + + The attributes in `attrs` and `defaults` are merged and resulting dict is rendered as HTML attributes + (`key="value"`). + + Extra kwargs (`key=value`) are concatenated to existing keys. So if we have + + ```python + attrs = {"class": "my-class"} + ``` + + Then + + ```django + {% html_attrs attrs class="extra-class" %} + ``` + + will result in `class="my-class extra-class"`. + + **Example:** + ```django +
+ ``` + + renders + + ```html +
+ ``` + + **See more usage examples in + [HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).** + """ + + tag = "html_attrs" + end_tag = None # inline-only + allowed_flags = [] + + def render( self, - params: TagParams, - node_id: Optional[str] = None, - ): - super().__init__(nodelist=None, params=params, node_id=node_id) - - def render(self, context: Context) -> str: - append_attrs: List[Tuple[str, Any]] = [] - - # Resolve all data - args, kwargs = self.params.resolve(context) - attrs = kwargs.pop(HTML_ATTRS_ATTRS_KEY, None) or {} - defaults = kwargs.pop(HTML_ATTRS_DEFAULTS_KEY, None) or {} - append_attrs = list(kwargs.items()) - - # Merge it - final_attrs = {**defaults, **attrs} - final_attrs = append_attributes(*final_attrs.items(), *append_attrs) + context: Context, + attrs: Optional[Dict] = None, + defaults: Optional[Dict] = None, + **kwargs: Any, + ) -> SafeString: + # Merge + final_attrs = {} + final_attrs.update(defaults or {}) + final_attrs.update(attrs or {}) + final_attrs = append_attributes(*final_attrs.items(), *kwargs.items()) # Render to HTML attributes return attributes_to_string(final_attrs) diff --git a/src/django_components/component.py b/src/django_components/component.py index 4fdb2033..0d6ff573 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -11,6 +11,7 @@ Dict, Generator, Generic, + List, Literal, Mapping, NamedTuple, @@ -26,7 +27,7 @@ from django.core.exceptions import ImproperlyConfigured from django.forms.widgets import Media as MediaCls from django.http import HttpRequest, HttpResponse -from django.template.base import NodeList, Template, TextNode +from django.template.base import NodeList, Parser, Template, TextNode, Token from django.template.context import Context, RequestContext from django.template.loader import get_template from django.template.loader_tags import BLOCK_CONTEXT_KEY @@ -69,9 +70,8 @@ ) from django_components.template import cached_template from django_components.util.django_monkeypatch import is_template_cls_patched -from django_components.util.logger import trace_msg from django_components.util.misc import gen_id -from django_components.util.template_tag import TagParams +from django_components.util.template_tag import TagAttr from django_components.util.validation import validate_typed_dict, validate_typed_tuple # TODO_REMOVE_IN_V1 - Users should use top-level import instead @@ -1209,32 +1209,148 @@ def _validate_outputs(self, data: Any) -> None: class ComponentNode(BaseNode): - """Django.template.Node subclass that renders a django-components component""" + """ + Renders one of the components that was previously registered with + [`@register()`](./api.md#django_components.register) + decorator. + + **Args:** + + - `name` (str, required): Registered name of the component to render + - All other args and kwargs are defined based on the component itself. + + If you defined a component `"my_table"` + + ```python + from django_component import Component, register + + @register("my_table") + class MyTable(Component): + template = \"\"\" + + + {% for header in headers %} + + {% endfor %} + + + {% for row in rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{{ header }}
{{ cell }}
+ \"\"\" + + def get_context_data(self, rows: List, headers: List): + return { + "rows": rows, + "headers": headers, + } + ``` + + Then you can render this component by referring to `MyTable` via its + registered name `"my_table"`: + + ```django + {% component "my_table" rows=rows headers=headers ... / %} + ``` + + ### Component input + + Positional and keyword arguments can be literals or template variables. + + The component name must be a single- or double-quotes string and must + be either: + + - The first positional argument after `component`: + + ```django + {% component "my_table" rows=rows headers=headers ... / %} + ``` + + - Passed as kwarg `name`: + + ```django + {% component rows=rows headers=headers name="my_table" ... / %} + ``` + + ### Inserting into slots + + If the component defined any [slots](../concepts/fundamentals/slots.md), you can + pass in the content to be placed inside those slots by inserting [`{% fill %}`](#fill) tags, + directly within the `{% component %}` tag: + + ```django + {% component "my_table" rows=rows headers=headers ... / %} + {% fill "pagination" %} + < 1 | 2 | 3 > + {% endfill %} + {% endcomponent %} + ``` + + ### Isolating components + + By default, components behave similarly to Django's + [`{% include %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#include), + and the template inside the component has access to the variables defined in the outer template. + + You can selectively isolate a component, using the `only` flag, so that the inner template + can access only the data that was explicitly passed to it: + + ```django + {% component "name" positional_arg keyword_arg=value ... only %} + ``` + """ + + tag = "component" + end_tag = "endcomponent" + allowed_flags = [COMP_ONLY_FLAG] def __init__( self, + # ComponentNode inputs name: str, registry: ComponentRegistry, # noqa F811 - nodelist: NodeList, - params: TagParams, - isolated_context: bool = False, + # BaseNode inputs + params: List[TagAttr], + flags: Optional[Dict[str, bool]] = None, + nodelist: Optional[NodeList] = None, node_id: Optional[str] = None, ) -> None: - super().__init__(nodelist=nodelist or NodeList(), params=params, node_id=node_id) + super().__init__(params=params, flags=flags, nodelist=nodelist, node_id=node_id) self.name = name - self.isolated_context = isolated_context self.registry = registry - def __repr__(self) -> str: - return "".format( - self.name, - getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later. + @classmethod + def parse( # type: ignore[override] + cls, + parser: Parser, + token: Token, + registry: ComponentRegistry, # noqa F811 + name: str, + start_tag: str, + end_tag: str, + ) -> "ComponentNode": + # Set the component-specific start and end tags by subclassing the base node + subcls_name = cls.__name__ + "_" + name + subcls: Type[ComponentNode] = type(subcls_name, (cls,), {"tag": start_tag, "end_tag": end_tag}) + + # Call `BaseNode.parse()` as if with the context of subcls. + node: ComponentNode = super().parse.__func__( # type: ignore[attr-defined] + subcls, + parser, + token, + registry=registry, + name=name, ) + return node - def render(self, context: Context) -> str: - trace_msg("RENDR", "COMP", self.name, self.node_id) - + def render(self, context: Context, *args: Any, **kwargs: Any) -> str: # Do not render nested `{% component %}` tags in other `{% component %}` tags # at the stage when we are determining if the latter has named fills or not. if _is_extracting_fill(context): @@ -1242,11 +1358,6 @@ def render(self, context: Context) -> str: component_cls: Type[Component] = self.registry.get(self.name) - # Resolve FilterExpressions and Variables that were passed as args to the - # component, then call component's context method - # to get values to insert into the context - args, kwargs = self.params.resolve(context) - slot_fills = resolve_fills(context, self.nodelist, self.name) component: Component = component_cls( @@ -1256,7 +1367,7 @@ def render(self, context: Context) -> str: ) # Prevent outer context from leaking into the template of the component - if self.isolated_context or self.registry.settings.context_behavior == ContextBehavior.ISOLATED: + if self.flags[COMP_ONLY_FLAG] or self.registry.settings.context_behavior == ContextBehavior.ISOLATED: context = make_isolated_context_copy(context) output = component._render( @@ -1269,7 +1380,6 @@ def render(self, context: Context) -> str: render_dependencies=False, ) - trace_msg("RENDR", "COMP", self.name, self.node_id, "...Done!") return output diff --git a/src/django_components/component_registry.py b/src/django_components/component_registry.py index 027bca97..bfe469ea 100644 --- a/src/django_components/component_registry.py +++ b/src/django_components/component_registry.py @@ -1,9 +1,10 @@ from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, Union from django.template import Library +from django.template.base import Parser, Token from django_components.app_settings import ContextBehaviorType, app_settings -from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter +from django_components.library import is_tag_protected, mark_protected_tags, register_tag from django_components.tag_formatter import TagFormatterABC, get_tag_formatter if TYPE_CHECKING: @@ -462,12 +463,39 @@ def _register_to_library( component: Type["Component"], ) -> ComponentRegistryEntry: # Lazily import to avoid circular dependencies - from django_components.templatetags.component_tags import component as do_component - - formatter = get_tag_formatter(self) - tag = register_tag_from_formatter(self, do_component, formatter, comp_name) - - return ComponentRegistryEntry(cls=component, tag=tag) + from django_components.component import ComponentNode + + registry = self + + # Define a tag function that pre-processes the tokens, extracting + # the component name and passing the rest to the actual tag function. + def tag_fn(parser: Parser, token: Token) -> ComponentNode: + # Let the TagFormatter pre-process the tokens + bits = token.split_contents() + formatter = get_tag_formatter(registry) + result = formatter.parse([*bits]) + start_tag = formatter.start_tag(result.component_name) + end_tag = formatter.end_tag(result.component_name) + + # NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself, + # so we add it back in. + bits = [bits[0], *result.tokens] + token.contents = " ".join(bits) + + return ComponentNode.parse( + parser, + token, + registry=registry, + name=result.component_name, + start_tag=start_tag, + end_tag=end_tag, + ) + + formatter = get_tag_formatter(registry) + start_tag = formatter.start_tag(comp_name) + register_tag(self.library, start_tag, tag_fn) + + return ComponentRegistryEntry(cls=component, tag=start_tag) # This variable represents the global component registry diff --git a/src/django_components/dependencies.py b/src/django_components/dependencies.py index 4710cbc9..e7d2dd72 100644 --- a/src/django_components/dependencies.py +++ b/src/django_components/dependencies.py @@ -28,11 +28,13 @@ from django.forms import Media from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound, StreamingHttpResponse from django.http.response import HttpResponseBase +from django.template import Context, TemplateSyntaxError from django.templatetags.static import static from django.urls import path, reverse from django.utils.decorators import sync_and_async_middleware from django.utils.safestring import SafeString, mark_safe +from django_components.node import BaseNode from django_components.util.html import SoupNode from django_components.util.misc import get_import_path, is_nonempty_str @@ -1036,3 +1038,66 @@ def _process_response(self, response: HttpResponse) -> HttpResponse: response.content = render_dependencies(response.content, type="document") return response + + +######################################################### +# 6. Template tags +######################################################### + + +def _component_dependencies(type: Literal["js", "css"]) -> SafeString: + """Marks location where CSS link and JS script tags should be rendered.""" + if type == "css": + placeholder = CSS_DEPENDENCY_PLACEHOLDER + elif type == "js": + placeholder = JS_DEPENDENCY_PLACEHOLDER + else: + raise TemplateSyntaxError( + f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {type}" + ) + + return mark_safe(placeholder) + + +class ComponentCssDependenciesNode(BaseNode): + """ + Marks location where CSS link tags should be rendered after the whole HTML has been generated. + + Generally, this should be inserted into the `` tag of the HTML. + + If the generated HTML does NOT contain any `{% component_css_dependencies %}` tags, CSS links + are by default inserted into the `` tag of the HTML. (See + [JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations)) + + Note that there should be only one `{% component_css_dependencies %}` for the whole HTML document. + If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places. + """ + + tag = "component_css_dependencies" + end_tag = None # inline-only + allowed_flags = [] + + def render(self, context: Context) -> str: + return _component_dependencies("css") + + +class ComponentJsDependenciesNode(BaseNode): + """ + Marks location where JS link tags should be rendered after the whole HTML has been generated. + + Generally, this should be inserted at the end of the `` tag of the HTML. + + If the generated HTML does NOT contain any `{% component_js_dependencies %}` tags, JS scripts + are by default inserted at the end of the `` tag of the HTML. (See + [JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations)) + + Note that there should be only one `{% component_js_dependencies %}` for the whole HTML document. + If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places. + """ + + tag = "component_js_dependencies" + end_tag = None # inline-only + allowed_flags = [] + + def render(self, context: Context) -> str: + return _component_dependencies("js") diff --git a/src/django_components/library.py b/src/django_components/library.py index 70ae94eb..cb90a297 100644 --- a/src/django_components/library.py +++ b/src/django_components/library.py @@ -1,15 +1,10 @@ """Module for interfacing with Django's Library (`django.template.library`)""" -from typing import TYPE_CHECKING, Callable, List, Optional +from typing import Callable, List, Optional from django.template.base import Node, Parser, Token from django.template.library import Library -from django_components.tag_formatter import InternalTagFormatter - -if TYPE_CHECKING: - from django_components.component_registry import ComponentRegistry - class TagProtectedError(Exception): """ @@ -56,26 +51,15 @@ class TagProtectedError(Exception): def register_tag( - registry: "ComponentRegistry", + library: Library, tag: str, - tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node], + tag_fn: Callable[[Parser, Token], Node], ) -> None: # Register inline tag - if is_tag_protected(registry.library, tag): + if is_tag_protected(library, tag): raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag) else: - registry.library.tag(tag, lambda parser, token: tag_fn(parser, token, registry, tag)) - - -def register_tag_from_formatter( - registry: "ComponentRegistry", - tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node], - formatter: InternalTagFormatter, - component_name: str, -) -> str: - tag = formatter.start_tag(component_name) - register_tag(registry, tag, tag_fn) - return tag + library.tag(tag, tag_fn) def mark_protected_tags(lib: Library, tags: Optional[List[str]] = None) -> None: diff --git a/src/django_components/node.py b/src/django_components/node.py index b106d053..cd056f5c 100644 --- a/src/django_components/node.py +++ b/src/django_components/node.py @@ -1,20 +1,475 @@ -from typing import Optional +import functools +import inspect +import keyword +from typing import Any, Callable, Dict, List, Optional, Tuple, Type, cast -from django.template.base import Node, NodeList +from django.template import Context, Library +from django.template.base import Node, NodeList, Parser, Token +from django_components.util.logger import trace_msg from django_components.util.misc import gen_id -from django_components.util.template_tag import TagParams +from django_components.util.template_tag import ( + TagAttr, + TagParam, + apply_params_in_original_order, + parse_template_tag, + resolve_params, + validate_params, +) -class BaseNode(Node): - """Shared behavior for our subclasses of Django's `Node`""" +# Normally, when `Node.render()` is called, it receives only a single argument `context`. +# +# ```python +# def render(self, context: Context) -> str: +# return self.nodelist.render(context) +# ``` +# +# In django-components, the input to template tags is treated as function inputs, e.g. +# +# `{% component name="John" age=20 %}` +# +# And, for convenience, we want to allow the `render()` method to accept these extra parameters. +# That way, user can define just the `render()` method and have access to all the information: +# +# ```python +# def render(self, context: Context, name: str, **kwargs: Any) -> str: +# return f"Hello, {name}!" +# ``` +# +# So we need to wrap the `render()` method, and for that we need the metaclass. +# +# The outer `render()` (our wrapper) will match the `Node.render()` signature (accepting only `context`), +# while the inner `render()` (the actual implementation) will match the user-defined `render()` method's signature +# (accepting all the parameters). +class NodeMeta(type): + def __new__( + mcs, + name: str, + bases: Tuple[Type, ...], + attrs: Dict[str, Any], + ) -> Type["BaseNode"]: + cls = cast(Type["BaseNode"], super().__new__(mcs, name, bases, attrs)) + + # Ignore the `BaseNode` class itself + if attrs.get("__module__", None) == "django_components.node": + return cls + + if not hasattr(cls, "tag"): + raise ValueError(f"Node {name} must have a 'tag' attribute") + + # Skip if already wrapped + orig_render = cls.render + if getattr(orig_render, "_djc_wrapped", False): + return cls + + signature = inspect.signature(orig_render) + + # A full signature of `BaseNode.render()` may look like this: + # + # `def render(self, context: Context, name: str, **kwargs) -> str:` + # + # We need to remove the first two parameters from the signature. + # So we end up only with + # + # `def render(name: str, **kwargs) -> str:` + # + # And this becomes the signature that defines what params the template tag accepts, e.g. + # + # `{% component name="John" age=20 %}` + if len(signature.parameters) < 2: + raise TypeError(f"`render()` method of {name} must have at least two parameters") + + validation_params = list(signature.parameters.values()) + validation_params = validation_params[2:] + validation_signature = signature.replace(parameters=validation_params) + + # NOTE: This is used for creating docs by `_format_tag_signature()` in `docs/scripts/reference.py` + cls._signature = validation_signature + + @functools.wraps(orig_render) + def wrapper_render(self: "BaseNode", context: Context) -> str: + trace_msg("RENDR", self.tag, self.node_id) + + resolved_params = resolve_params(self.tag, self.params, context) + + # Template tags may accept kwargs that are not valid Python identifiers, e.g. + # `{% component data-id="John" class="pt-4" :href="myVar" %}` + # + # Passing them in is still useful, as user may want to pass in arbitrary data + # to their `{% component %}` tags as HTML attributes. E.g. example below passes + # `data-id`, `class` and `:href` as HTML attributes to the `
` element: + # + # ```py + # class MyComponent(Component): + # def get_context_data(self, name: str, **kwargs: Any) -> str: + # return { + # "name": name, + # "attrs": kwargs, + # } + # template = """ + #
+ # {{ name }} + #
+ # """ + # ``` + # + # HOWEVER, these kwargs like `data-id`, `class` and `:href` may not be valid Python identifiers, + # or like in case of `class`, may be a reserved keyword. Thus, we cannot pass them in to the `render()` + # method as regular kwargs, because that will raise Python's native errors like + # `SyntaxError: invalid syntax`. E.g. + # + # ```python + # def render(self, context: Context, data-id: str, class: str, :href: str) -> str: + # ``` + # + # So instead, we filter out any invalid kwargs, and pass those in through a dictionary spread. + # We can do so, because following is allowed in Python: + # + # ```python + # def x(**kwargs): + # print(kwargs) + # + # d = {"data-id": 1} + # x(**d) + # # {'data-id': 1} + # ``` + # + # See https://github.com/EmilStenstrom/django-components/discussions/900#discussioncomment-11859970 + resolved_params_without_invalid_kwargs = [] + invalid_kwargs = {} + did_see_special_kwarg = False + for resolved_param in resolved_params: + key = resolved_param.key + if key is not None: + # Case: Special kwargs + if not key.isidentifier() or keyword.iskeyword(key): + # NOTE: Since these keys are not part of signature validation, + # we have to check ourselves if any args follow them. + invalid_kwargs[key] = resolved_param.value + did_see_special_kwarg = True + else: + # Case: Regular kwargs + resolved_params_without_invalid_kwargs.append(resolved_param) + else: + # Case: Regular positional args + if did_see_special_kwarg: + raise SyntaxError("positional argument follows keyword argument") + resolved_params_without_invalid_kwargs.append(resolved_param) + + # Validate the params against the signature + # + # Unlike the call to `apply_params_in_original_order()` further below, this uses a signature + # that has been stripped of the `self` and `context` parameters. E.g. + # + # `def render(name: str, **kwargs: Any) -> None` + # + # If there are any errors in the input, this will trigger Python's + # native error handling (e.g. `TypeError: render() got multiple values for argument 'context'`) + # + # But because we stripped the two parameters, then these errors will correctly + # point to the actual error in the template tag. + # + # E.g. if we supplied one too many positional args, + # `{% mytag "John" 20 %}` + # + # Then without stripping the two parameters, then the error could be: + # `render() takes from 3 positional arguments but 4 were given` + # + # Which is confusing, because we supplied only two positional args. + # + # But cause we stripped the two parameters, then the error will be: + # `render() takes from 1 positional arguments but 2 were given` + validate_params(self.tag, validation_signature, resolved_params_without_invalid_kwargs, invalid_kwargs) + + # The code below calls the `orig_render()` function like so: + # `orig_render(self, context, arg1, arg2, kwarg1=val1, kwarg2=val2)` + # + # So it's called in the same order as what was passed to the template tag, e.g. + # `{% component arg1 arg2 kwarg1=val1 kwarg2=val2 %}` + # + # That's why we don't simply spread all args and kwargs as `*args, **kwargs`, + # because then Python's validation wouldn't catch such errors. + resolved_params_with_context = [ + TagParam(key=None, value=self), + TagParam(key=None, value=context), + ] + resolved_params_without_invalid_kwargs + output = apply_params_in_original_order(orig_render, resolved_params_with_context, invalid_kwargs) + + trace_msg("RENDR", self.tag, self.node_id, msg="...Done!") + return output + + # Wrap cls.render() so we resolve the args and kwargs and pass them to the + # actual render method. + cls.render = wrapper_render # type: ignore + cls.render._djc_wrapped = True # type: ignore + + return cls + + +class BaseNode(Node, metaclass=NodeMeta): + """ + Node class for all django-components custom template tags. + + This class has a dual role: + + 1. It declares how a particular template tag should be parsed - By setting the + [`tag`](../api#django_components.BaseNode.tag), + [`end_tag`](../api#django_components.BaseNode.end_tag), + and [`allowed_flags`](../api#django_components.BaseNode.allowed_flags) + attributes: + + ```python + class SlotNode(BaseNode): + tag = "slot" + end_tag = "endslot" + allowed_flags = ["required"] + ``` + + This will allow the template tag `{% slot %}` to be used like this: + + ```django + {% slot required %} ... {% endslot %} + ``` + + 2. The [`render`](../api#django_components.BaseNode.render) method is + the actual implementation of the template tag. + + This is where the tag's logic is implemented: + + ```python + class MyNode(BaseNode): + tag = "mynode" + + def render(self, context: Context, name: str, **kwargs: Any) -> str: + return f"Hello, {name}!" + ``` + + This will allow the template tag `{% mynode %}` to be used like this: + + ```django + {% mynode name="John" %} + ``` + + The template tag accepts parameters as defined on the + [`render`](../api#django_components.BaseNode.render) method's signature. + + For more info, see [`BaseNode.render()`](../api#django_components.BaseNode.render). + """ + + # ##################################### + # PUBLIC API (Configurable by users) + # ##################################### + + tag: str + """ + The tag name. + + E.g. `"component"` or `"slot"` will make this class match + template tags `{% component %}` or `{% slot %}`. + """ + + end_tag: Optional[str] = None + """ + The end tag name. + + E.g. `"endcomponent"` or `"endslot"` will make this class match + template tags `{% endcomponent %}` or `{% endslot %}`. + + If not set, then this template tag has no end tag. + + So instead of `{% component %} ... {% endcomponent %}`, you'd use only + `{% component %}`. + """ + + allowed_flags: Optional[List[str]] = None + """ + The allowed flags for this tag. + + E.g. `["required"]` will allow this tag to be used like `{% slot required %}`. + """ + + def render(self, context: Context, *args: Any, **kwargs: Any) -> str: + """ + Render the node. This method is meant to be overridden by subclasses. + + The signature of this function decides what input the template tag accepts. + + The `render()` method MUST accept a `context` argument. Any arguments after that + will be part of the tag's input parameters. + + So if you define a `render` method like this: + + ```python + def render(self, context: Context, name: str, **kwargs: Any) -> str: + ``` + + Then the tag will require the `name` parameter, and accept any extra keyword arguments: + + ```django + {% component name="John" age=20 %} + ``` + """ + return self.nodelist.render(context) + + # ##################################### + # MISC + # ##################################### def __init__( self, - params: TagParams, + params: List[TagAttr], + flags: Optional[Dict[str, bool]] = None, nodelist: Optional[NodeList] = None, node_id: Optional[str] = None, ): self.params = params + self.flags = flags or {flag: False for flag in self.allowed_flags or []} self.nodelist = nodelist or NodeList() self.node_id = node_id or gen_id() + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__}: {self.node_id}. Contents: {repr(self.nodelist)}." + f" Flags: {self.active_flags}>" + ) + + @property + def active_flags(self) -> List[str]: + """Flags that were set for this specific instance.""" + flags = [] + for flag, value in self.flags.items(): + if value: + flags.append(flag) + return flags + + @classmethod + def parse(cls, parser: Parser, token: Token, **kwargs: Any) -> "BaseNode": + """ + This function is what is passed to Django's `Library.tag()` when + [registering the tag](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#registering-the-tag). + + In other words, this method is called by Django's template parser when we encounter + a tag that matches this node's tag, e.g. `{% component %}` or `{% slot %}`. + + To register the tag, you can use [`BaseNode.register()`](../api#django_components.BaseNode.register). + """ + tag_id = gen_id() + tag = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token) + + trace_msg("PARSE", cls.tag, tag_id) + + body = tag.parse_body() + node = cls( + nodelist=body, + node_id=tag_id, + params=tag.params, + flags=tag.flags, + **kwargs, + ) + + trace_msg("PARSE", cls.tag, tag_id, "...Done!") + return node + + @classmethod + def register(cls, library: Library) -> None: + """ + A convenience method for registering the tag with the given library. + + ```python + class MyNode(BaseNode): + tag = "mynode" + + MyNode.register(library) + ``` + + Allows you to then use the node in templates like so: + + ```django + {% load mylibrary %} + {% mynode %} + ``` + """ + library.tag(cls.tag, cls.parse) + + @classmethod + def unregister(cls, library: Library) -> None: + """Unregisters the node from the given library.""" + library.tags.pop(cls.tag, None) + + +def template_tag( + library: Library, + tag: str, + end_tag: Optional[str] = None, + allowed_flags: Optional[List[str]] = None, +) -> Callable[[Callable], Callable]: + """ + A simplified version of creating a template tag based on [`BaseNode`](../api#django_components.BaseNode). + + Instead of defining the whole class, you can just define the + [`render()`](../api#django_components.BaseNode.render) method. + + ```python + from django.template import Context, Library + from django_components import BaseNode, template_tag + + library = Library() + + @template_tag( + library, + tag="mytag", + end_tag="endmytag", + allowed_flags=["required"], + ) + def mytag(node: BaseNode, context: Context, name: str, **kwargs: Any) -> str: + return f"Hello, {name}!" + ``` + + This will allow the template tag `{% mytag %}` to be used like this: + + ```django + {% mytag name="John" %} + {% mytag name="John" required %} ... {% endmytag %} + ``` + + The given function will be wrapped in a class that inherits from [`BaseNode`](../api#django_components.BaseNode). + + And this class will be registered with the given library. + + The function MUST accept at least two positional arguments: `node` and `context` + + - `node` is the [`BaseNode`](../api#django_components.BaseNode) instance. + - `context` is the [`Context`](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context) + of the template. + + Any extra parameters defined on this function will be part of the tag's input parameters. + + For more info, see [`BaseNode.render()`](../api#django_components.BaseNode.render). + """ + + def decorator(fn: Callable) -> Callable: + subcls_name = fn.__name__.title().replace("_", "").replace("-", "") + "Node" + + try: + subcls: Type[BaseNode] = type( + subcls_name, + (BaseNode,), + { + "tag": tag, + "end_tag": end_tag, + "allowed_flags": allowed_flags or [], + "render": fn, + }, + ) + except Exception as e: + raise e.__class__(f"Failed to create node class in 'template_tag()' for '{fn.__name__}'") from e + + subcls.register(library) + + # Allow to access the node class + fn._node = subcls # type: ignore[attr-defined] + + return fn + + return decorator diff --git a/src/django_components/provide.py b/src/django_components/provide.py index 980c87ac..e2bf929d 100644 --- a/src/django_components/provide.py +++ b/src/django_components/provide.py @@ -1,42 +1,87 @@ -from typing import Dict, Optional, Tuple +from typing import Any from django.template import Context -from django.template.base import NodeList from django.utils.safestring import SafeString from django_components.context import set_provided_context_var from django_components.node import BaseNode -from django_components.util.logger import trace_msg -from django_components.util.template_tag import TagParams - -PROVIDE_NAME_KWARG = "name" class ProvideNode(BaseNode): """ - Implementation of the `{% provide %}` tag. - For more info see `Component.inject`. + The "provider" part of the [provide / inject feature](../../concepts/advanced/provide_inject). + Pass kwargs to this tag to define the provider's data. + Any components defined within the `{% provide %}..{% endprovide %}` tags will be able to access this data + with [`Component.inject()`](../api#django_components.Component.inject). + + This is similar to React's [`ContextProvider`](https://react.dev/learn/passing-data-deeply-with-context), + or Vue's [`provide()`](https://vuejs.org/guide/components/provide-inject). + + **Args:** + + - `name` (str, required): Provider name. This is the name you will then use in + [`Component.inject()`](../api#django_components.Component.inject). + - `**kwargs`: Any extra kwargs will be passed as the provided data. + + **Example:** + + Provide the "user_data" in parent component: + + ```python + @register("parent") + class Parent(Component): + template = \"\"\" +
+ {% provide "user_data" user=user %} + {% component "child" / %} + {% endprovide %} +
+ \"\"\" + + def get_context_data(self, user: User): + return { + "user": user, + } + ``` + + Since the "child" component is used within the `{% provide %} / {% endprovide %}` tags, + we can request the "user_data" using `Component.inject("user_data")`: + + ```python + @register("child") + class Child(Component): + template = \"\"\" +
+ User is: {{ user }} +
+ \"\"\" + + def get_context_data(self): + user = self.inject("user_data").user + return { + "user": user, + } + ``` + + Notice that the keys defined on the `{% provide %}` tag are then accessed as attributes + when accessing them with [`Component.inject()`](../api#django_components.Component.inject). + + ✅ Do this + ```python + user = self.inject("user_data").user + ``` + + ❌ Don't do this + ```python + user = self.inject("user_data")["user"] + ``` """ - def __init__( - self, - nodelist: NodeList, - params: TagParams, - trace_id: str, - node_id: Optional[str] = None, - ): - super().__init__(nodelist=nodelist, params=params, node_id=node_id) - - self.trace_id = trace_id - - def __repr__(self) -> str: - return f"" - - def render(self, context: Context) -> SafeString: - trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id) - - name, kwargs = self.resolve_kwargs(context) + tag = "provide" + end_tag = "endprovide" + allowed_flags = [] + def render(self, context: Context, name: str, **kwargs: Any) -> SafeString: # NOTE: The "provided" kwargs are meant to be shared privately, meaning that components # have to explicitly opt in by using the `Component.inject()` method. That's why we don't # add the provided kwargs into the Context. @@ -46,14 +91,4 @@ def render(self, context: Context) -> SafeString: output = self.nodelist.render(context) - trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id, msg="...Done!") return output - - def resolve_kwargs(self, context: Context) -> Tuple[str, Dict[str, Optional[str]]]: - args, kwargs = self.params.resolve(context) - name = kwargs.pop(PROVIDE_NAME_KWARG, None) - - if not name: - raise RuntimeError("Provide tag kwarg 'name' is missing") - - return (name, kwargs) diff --git a/src/django_components/slots.py b/src/django_components/slots.py index 7eb1ed64..62b52ed8 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -13,7 +13,6 @@ Optional, Protocol, Set, - Tuple, TypeVar, Union, cast, @@ -33,9 +32,7 @@ _ROOT_CTX_CONTEXT_KEY, ) from django_components.node import BaseNode -from django_components.util.logger import trace_msg from django_components.util.misc import get_last_index, is_identifier -from django_components.util.template_tag import TagParams if TYPE_CHECKING: from django_components.component_registry import ComponentRegistry @@ -150,34 +147,132 @@ class ComponentSlotContext: class SlotNode(BaseNode): - """Node corresponding to `{% slot %}`""" - - def __init__( - self, - nodelist: NodeList, - params: TagParams, - trace_id: str, - node_id: Optional[str] = None, - is_required: bool = False, - is_default: bool = False, - ): - super().__init__(nodelist=nodelist, params=params, node_id=node_id) - - self.is_required = is_required - self.is_default = is_default - self.trace_id = trace_id + """ + Slot tag marks a place inside a component where content can be inserted + from outside. - @property - def active_flags(self) -> List[str]: - flags = [] - if self.is_required: - flags.append("required") - if self.is_default: - flags.append("default") - return flags + [Learn more](../../concepts/fundamentals/slots) about using slots. + + This is similar to slots as seen in + [Web components](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot), + [Vue](https://vuejs.org/guide/components/slots.html) + or [React's `children`](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children). + + **Args:** + + - `name` (str, required): Registered name of the component to render + - `default`: Optional flag. If there is a default slot, you can pass the component slot content + without using the [`{% fill %}`](#fill) tag. See + [Default slot](../../concepts/fundamentals/slots#default-slot) + - `required`: Optional flag. Will raise an error if a slot is required but not given. + - `**kwargs`: Any extra kwargs will be passed as the slot data. + + **Example:** + + ```python + @register("child") + class Child(Component): + template = \"\"\" +
+ {% slot "content" default %} + This is shown if not overriden! + {% endslot %} +
+ + \"\"\" + ``` + + ```python + @register("parent") + class Parent(Component): + template = \"\"\" +
+ {% component "child" %} + {% fill "content" %} + 🗞️📰 + {% endfill %} + + {% fill "sidebar" %} + 🍷🧉🍾 + {% endfill %} + {% endcomponent %} +
+ \"\"\" + ``` + + ### Passing data to slots + + Any extra kwargs will be considered as slot data, and will be accessible in the [`{% fill %}`](#fill) + tag via fill's `data` kwarg: + + ```python + @register("child") + class Child(Component): + template = \"\"\" +
+ {# Passing data to the slot #} + {% slot "content" user=user %} + This is shown if not overriden! + {% endslot %} +
+ \"\"\" + ``` + + ```python + @register("parent") + class Parent(Component): + template = \"\"\" + {# Parent can access the slot data #} + {% component "child" %} + {% fill "content" data="data" %} +
+ {{ data.user }} +
+ {% endfill %} + {% endcomponent %} + \"\"\" + ``` + + ### Accessing default slot content + + The content between the `{% slot %}..{% endslot %}` tags is the default content that + will be rendered if no fill is given for the slot. + + This default content can then be accessed from within the [`{% fill %}`](#fill) tag using + the fill's `default` kwarg. + This is useful if you need to wrap / prepend / append the original slot's content. - def __repr__(self) -> str: - return f"" + ```python + @register("child") + class Child(Component): + template = \"\"\" +
+ {% slot "content" %} + This is default content! + {% endslot %} +
+ \"\"\" + ``` + + ```python + @register("parent") + class Parent(Component): + template = \"\"\" + {# Parent can access the slot's default content #} + {% component "child" %} + {% fill "content" default="default" %} + {{ default }} + {% endfill %} + {% endcomponent %} + \"\"\" + ``` + """ + + tag = "slot" + end_tag = "endslot" + allowed_flags = [SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD] # NOTE: # In the current implementation, the slots are resolved only at the render time. @@ -200,9 +295,7 @@ def __repr__(self) -> str: # for unfilled slots (rendered slots WILL raise an error if the fill is missing). # 2. User may provide extra fills, but these may belong to slots we haven't # encountered in this render run. So we CANNOT say which ones are extra. - def render(self, context: Context) -> SafeString: - trace_msg("RENDR", "SLOT", self.trace_id, self.node_id) - + def render(self, context: Context, name: str, **kwargs: Any) -> SafeString: # Do not render `{% slot %}` tags within the `{% component %} .. {% endcomponent %}` tags # at the fill discovery stage (when we render the component's body to determine if the body # is a default slot, or contains named slots). @@ -217,10 +310,12 @@ def render(self, context: Context) -> SafeString: ) component_ctx: ComponentSlotContext = context[_COMPONENT_SLOT_CTX_CONTEXT_KEY] - slot_name, kwargs = self.resolve_kwargs(context, component_ctx.component_name) + slot_name = name + is_default = self.flags[SLOT_DEFAULT_KEYWORD] + is_required = self.flags[SLOT_REQUIRED_KEYWORD] # Check for errors - if self.is_default and not component_ctx.is_dynamic_component: + if is_default and not component_ctx.is_dynamic_component: # Allow one slot to be marked as 'default', or multiple slots but with # the same name. If there is multiple 'default' slots with different names, raise. default_slot_name = component_ctx.default_slot @@ -249,7 +344,7 @@ def render(self, context: Context) -> SafeString: # If slot is marked as 'default', we use the name 'default' for the fill, # IF SUCH FILL EXISTS. Otherwise, we use the slot's name. - if self.is_default and DEFAULT_SLOT_KEY in component_ctx.fills: + if is_default and DEFAULT_SLOT_KEY in component_ctx.fills: fill_name = DEFAULT_SLOT_KEY else: fill_name = slot_name @@ -284,7 +379,7 @@ def render(self, context: Context) -> SafeString: # Note: Finding a good `cutoff` value may require further trial-and-error. # Higher values make matching stricter. This is probably preferable, as it # reduces false positives. - if self.is_required and not slot_fill.is_filled and not component_ctx.is_dynamic_component: + if is_required and not slot_fill.is_filled and not component_ctx.is_dynamic_component: msg = ( f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), " f"yet no fill is provided. Check template.'" @@ -349,7 +444,6 @@ def render(self, context: Context) -> SafeString: # the render function ALWAYS receives them. output = slot_fill.slot(used_ctx, kwargs, slot_ref) - trace_msg("RENDR", "SLOT", self.trace_id, self.node_id, msg="...Done!") return output def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context: @@ -368,99 +462,150 @@ def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Cont else: raise ValueError(f"Unknown value for context_behavior: '{registry.settings.context_behavior}'") - def resolve_kwargs( - self, - context: Context, - component_name: Optional[str] = None, - ) -> Tuple[str, Dict[str, Optional[str]]]: - _, kwargs = self.params.resolve(context) - name = kwargs.pop(SLOT_NAME_KWARG, None) - if not name: - raise RuntimeError(f"Slot tag kwarg 'name' is missing in component {component_name}") +class FillNode(BaseNode): + """ + Use this tag to insert content into component's slots. - return (name, kwargs) + `{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block. + Runtime checks should prohibit other usages. + **Args:** -class FillNode(BaseNode): - """Node corresponding to `{% fill %}`""" + - `name` (str, required): Name of the slot to insert this content into. Use `"default"` for + the default slot. + - `default` (str, optional): This argument allows you to access the original content of the slot + under the specified variable name. See + [Accessing original content of slots](../../concepts/fundamentals/slots#accessing-original-content-of-slots) + - `data` (str, optional): This argument allows you to access the data passed to the slot + under the specified variable name. See [Scoped slots](../../concepts/fundamentals/slots#scoped-slots) - def __init__( - self, - nodelist: NodeList, - params: TagParams, - trace_id: str, - node_id: Optional[str] = None, - ): - super().__init__(nodelist=nodelist, params=params, node_id=node_id) + **Examples:** - self.trace_id = trace_id + Basic usage: + ```django + {% component "my_table" %} + {% fill "pagination" %} + < 1 | 2 | 3 > + {% endfill %} + {% endcomponent %} + ``` - def render(self, context: Context) -> str: - if _is_extracting_fill(context): - self._extract_fill(context) - return "" + ### Accessing slot's default content with the `default` kwarg - raise TemplateSyntaxError( - "FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. " - "Make sure that the {% fill %} tags are nested within {% component %} tags." - ) + ```django + {# my_table.html #} + + ... + {% slot "pagination" %} + < 1 | 2 | 3 > + {% endslot %} +
+ ``` - def __repr__(self) -> str: - return f"<{self.__class__.__name__} ID: {self.node_id}. Contents: {repr(self.nodelist)}.>" + ```django + {% component "my_table" %} + {% fill "pagination" default="default_pag" %} +
+ {{ default_pag }} +
+ {% endfill %} + {% endcomponent %} + ``` - def resolve_kwargs(self, context: Context) -> "FillWithData": - _, kwargs = self.params.resolve(context) + ### Accessing slot's data with the `data` kwarg - name = self._process_kwarg(kwargs, SLOT_NAME_KWARG, identifier=False) - default_var = self._process_kwarg(kwargs, SLOT_DEFAULT_KWARG) - data_var = self._process_kwarg(kwargs, SLOT_DATA_KWARG) + ```django + {# my_table.html #} + + ... + {% slot "pagination" pages=pages %} + < 1 | 2 | 3 > + {% endslot %} +
+ ``` - if not isinstance(name, str): - raise TemplateSyntaxError(f"Fill tag '{SLOT_NAME_KWARG}' kwarg must resolve to a string, got {name}") + ```django + {% component "my_table" %} + {% fill "pagination" data="slot_data" %} + {% for page in slot_data.pages %} + + {{ page.index }} + + {% endfor %} + {% endfill %} + {% endcomponent %} + ``` + + ### Accessing slot data and default content on the default slot - if data_var is not None and not isinstance(data_var, str): - raise TemplateSyntaxError(f"Fill tag '{SLOT_DATA_KWARG}' kwarg must resolve to a string, got {data_var}") + To access slot data and the default slot content on the default slot, + use `{% fill %}` with `name` set to `"default"`: - if default_var is not None and not isinstance(default_var, str): + ```django + {% component "button" %} + {% fill name="default" data="slot_data" default="default_slot" %} + You clicked me {{ slot_data.count }} times! + {{ default_slot }} + {% endfill %} + {% endcomponent %} + ``` + """ + + tag = "fill" + end_tag = "endfill" + allowed_flags = [] + + def render(self, context: Context, name: str, *, data: Optional[str] = None, default: Optional[str] = None) -> str: + if not _is_extracting_fill(context): raise TemplateSyntaxError( - f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default_var}" + "FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. " + "Make sure that the {% fill %} tags are nested within {% component %} tags." ) + # Validate inputs + if not isinstance(name, str): + raise TemplateSyntaxError(f"Fill tag '{SLOT_NAME_KWARG}' kwarg must resolve to a string, got {name}") + + if data is not None: + if not isinstance(data, str): + raise TemplateSyntaxError(f"Fill tag '{SLOT_DATA_KWARG}' kwarg must resolve to a string, got {data}") + if not is_identifier(data): + raise RuntimeError( + f"Fill tag kwarg '{SLOT_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'" + ) + + if default is not None: + if not isinstance(default, str): + raise TemplateSyntaxError( + f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default}" + ) + if not is_identifier(default): + raise RuntimeError( + f"Fill tag kwarg '{SLOT_DEFAULT_KWARG}' does not resolve to a valid Python identifier," + f" got '{default}'" + ) + # data and default cannot be bound to the same variable - if data_var and default_var and data_var == default_var: + if data and default and data == default: raise RuntimeError( f"Fill '{name}' received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)" f" and slot data ({SLOT_DATA_KWARG}=...)" ) - return FillWithData( + fill_data = FillWithData( fill=self, name=name, - default_var=default_var, - data_var=data_var, + default_var=default, + data_var=data, extra_context={}, ) - def _process_kwarg( - self, - kwargs: Dict[str, Any], - key: str, - identifier: bool = True, - ) -> Optional[Any]: - if key not in kwargs: - return None - - value = kwargs[key] - if value is None: - return None + self._extract_fill(context, fill_data) - if identifier and not is_identifier(value): - raise RuntimeError(f"Fill tag kwarg '{key}' does not resolve to a valid Python identifier, got '{value}'") + return "" - return value - - def _extract_fill(self, context: Context) -> None: + def _extract_fill(self, context: Context, data: "FillWithData") -> None: # `FILL_GEN_CONTEXT_KEY` is only ever set when we are rendering content between the # `{% component %}...{% endcomponent %}` tags. This is done in order to collect all fill tags. # E.g. @@ -474,10 +619,6 @@ def _extract_fill(self, context: Context) -> None: if collected_fills is None: return - # NOTE: It's important that we use the context given to the fill tag, so it accounts - # for any variables set via e.g. for-loops. - data = self.resolve_kwargs(context) - # To allow using variables which were defined within the template and to which # the `{% fill %}` tag has access, we need to capture those variables too. # diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index 78938630..bc4868d5 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -1,707 +1,43 @@ -# Notes on documentation: -# - For intuitive use via Python imports, keep the tag names same as the function name. -# E.g. so if the tag name is `slot`, one can also do -# `from django_components.templatetags.component_tags import slot` -# -# - All tags are defined using `@register.tag`. Do NOT use `@register.simple_tag`. -# The reason for this is so that we use `TagSpec` and `parse_template_tag`. When generating -# documentation, we extract the `TagSpecs` to be able to describe each tag's function signature. -# -# - Use `with_tag_spec` for defining `TagSpecs`. This will make it available to the function -# as the last argument, and will also set the `TagSpec` instance to `fn._tag_spec`. -# During documentation generation, we access the `fn._tag_spec`. - -import inspect -from typing import Any, Dict, Literal, Optional - import django.template -from django.template.base import Parser, TextNode, Token -from django.template.exceptions import TemplateSyntaxError -from django.utils.safestring import SafeString, mark_safe from django_components.attributes import HtmlAttrsNode -from django_components.component import COMP_ONLY_FLAG, ComponentNode -from django_components.component_registry import ComponentRegistry -from django_components.dependencies import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER +from django_components.component import ComponentNode +from django_components.dependencies import ComponentCssDependenciesNode, ComponentJsDependenciesNode from django_components.provide import ProvideNode -from django_components.slots import SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD, FillNode, SlotNode -from django_components.tag_formatter import get_tag_formatter -from django_components.util.logger import trace_msg -from django_components.util.misc import gen_id -from django_components.util.template_tag import TagSpec, parse_template_tag, with_tag_spec +from django_components.slots import FillNode, SlotNode # NOTE: Variable name `register` is required by Django to recognize this as a template tag library # See https://docs.djangoproject.com/en/dev/howto/custom-template-tags register = django.template.Library() -def _component_dependencies(type: Literal["js", "css"]) -> SafeString: - """Marks location where CSS link and JS script tags should be rendered.""" - if type == "css": - placeholder = CSS_DEPENDENCY_PLACEHOLDER - elif type == "js": - placeholder = JS_DEPENDENCY_PLACEHOLDER - else: - raise TemplateSyntaxError( - f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {type}" - ) - - return TextNode(mark_safe(placeholder)) - - -def component_dependencies_signature() -> None: ... # noqa: E704 - - -@register.tag("component_css_dependencies") -@with_tag_spec( - TagSpec( - tag="component_css_dependencies", - end_tag=None, # inline-only - signature=inspect.Signature.from_callable(component_dependencies_signature), - ) -) -def component_css_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode: - """ - Marks location where CSS link tags should be rendered after the whole HTML has been generated. - - Generally, this should be inserted into the `` tag of the HTML. - - If the generated HTML does NOT contain any `{% component_css_dependencies %}` tags, CSS links - are by default inserted into the `` tag of the HTML. (See - [JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations)) - - Note that there should be only one `{% component_css_dependencies %}` for the whole HTML document. - If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places. - """ - # Parse to check that the syntax is valid - parse_template_tag(parser, token, tag_spec) - return _component_dependencies("css") - - -@register.tag("component_js_dependencies") -@with_tag_spec( - TagSpec( - tag="component_js_dependencies", - end_tag=None, # inline-only - signature=inspect.Signature.from_callable(component_dependencies_signature), - ) -) -def component_js_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode: - """ - Marks location where JS link tags should be rendered after the whole HTML has been generated. - - Generally, this should be inserted at the end of the `` tag of the HTML. - - If the generated HTML does NOT contain any `{% component_js_dependencies %}` tags, JS scripts - are by default inserted at the end of the `` tag of the HTML. (See - [JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations)) - - Note that there should be only one `{% component_js_dependencies %}` for the whole HTML document. - If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places. - """ - # Parse to check that the syntax is valid - parse_template_tag(parser, token, tag_spec) - return _component_dependencies("js") - - -def slot_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704 - - -@register.tag("slot") -@with_tag_spec( - TagSpec( - tag="slot", - end_tag="endslot", - signature=inspect.Signature.from_callable(slot_signature), - flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD], - ) -) -def slot(parser: Parser, token: Token, tag_spec: TagSpec) -> SlotNode: - """ - Slot tag marks a place inside a component where content can be inserted - from outside. - - [Learn more](../../concepts/fundamentals/slots) about using slots. - - This is similar to slots as seen in - [Web components](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot), - [Vue](https://vuejs.org/guide/components/slots.html) - or [React's `children`](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children). - - **Args:** - - - `name` (str, required): Registered name of the component to render - - `default`: Optional flag. If there is a default slot, you can pass the component slot content - without using the [`{% fill %}`](#fill) tag. See - [Default slot](../../concepts/fundamentals/slots#default-slot) - - `required`: Optional flag. Will raise an error if a slot is required but not given. - - `**kwargs`: Any extra kwargs will be passed as the slot data. - - **Example:** - - ```python - @register("child") - class Child(Component): - template = \"\"\" -
- {% slot "content" default %} - This is shown if not overriden! - {% endslot %} -
- - \"\"\" - ``` - - ```python - @register("parent") - class Parent(Component): - template = \"\"\" -
- {% component "child" %} - {% fill "content" %} - 🗞️📰 - {% endfill %} - - {% fill "sidebar" %} - 🍷🧉🍾 - {% endfill %} - {% endcomponent %} -
- \"\"\" - ``` - - ### Passing data to slots - - Any extra kwargs will be considered as slot data, and will be accessible in the [`{% fill %}`](#fill) - tag via fill's `data` kwarg: - - ```python - @register("child") - class Child(Component): - template = \"\"\" -
- {# Passing data to the slot #} - {% slot "content" user=user %} - This is shown if not overriden! - {% endslot %} -
- \"\"\" - ``` - - ```python - @register("parent") - class Parent(Component): - template = \"\"\" - {# Parent can access the slot data #} - {% component "child" %} - {% fill "content" data="data" %} -
- {{ data.user }} -
- {% endfill %} - {% endcomponent %} - \"\"\" - ``` - - ### Accessing default slot content - - The content between the `{% slot %}..{% endslot %}` tags is the default content that - will be rendered if no fill is given for the slot. - - This default content can then be accessed from within the [`{% fill %}`](#fill) tag using - the fill's `default` kwarg. - This is useful if you need to wrap / prepend / append the original slot's content. - - ```python - @register("child") - class Child(Component): - template = \"\"\" -
- {% slot "content" %} - This is default content! - {% endslot %} -
- \"\"\" - ``` - - ```python - @register("parent") - class Parent(Component): - template = \"\"\" - {# Parent can access the slot's default content #} - {% component "child" %} - {% fill "content" default="default" %} - {{ default }} - {% endfill %} - {% endcomponent %} - \"\"\" - ``` - """ - tag_id = gen_id() - tag = parse_template_tag(parser, token, tag_spec) - - trace_id = f"slot-id-{tag_id}" - trace_msg("PARSE", "SLOT", trace_id, tag_id) - - body = tag.parse_body() - slot_node = SlotNode( - nodelist=body, - node_id=tag_id, - params=tag.params, - is_required=tag.flags[SLOT_REQUIRED_KEYWORD], - is_default=tag.flags[SLOT_DEFAULT_KEYWORD], - trace_id=trace_id, - ) - - trace_msg("PARSE", "SLOT", trace_id, tag_id, "...Done!") - return slot_node - - -def fill_signature(name: str, *, data: Optional[str] = None, default: Optional[str] = None) -> None: ... # noqa: E704 - - -@register.tag("fill") -@with_tag_spec( - TagSpec( - tag="fill", - end_tag="endfill", - signature=inspect.Signature.from_callable(fill_signature), - ) -) -def fill(parser: Parser, token: Token, tag_spec: TagSpec) -> FillNode: - """ - Use this tag to insert content into component's slots. - - `{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block. - Runtime checks should prohibit other usages. - - **Args:** - - - `name` (str, required): Name of the slot to insert this content into. Use `"default"` for - the default slot. - - `default` (str, optional): This argument allows you to access the original content of the slot - under the specified variable name. See - [Accessing original content of slots](../../concepts/fundamentals/slots#accessing-original-content-of-slots) - - `data` (str, optional): This argument allows you to access the data passed to the slot - under the specified variable name. See [Scoped slots](../../concepts/fundamentals/slots#scoped-slots) - - **Examples:** - - Basic usage: - ```django - {% component "my_table" %} - {% fill "pagination" %} - < 1 | 2 | 3 > - {% endfill %} - {% endcomponent %} - ``` - - ### Accessing slot's default content with the `default` kwarg - - ```django - {# my_table.html #} - - ... - {% slot "pagination" %} - < 1 | 2 | 3 > - {% endslot %} -
- ``` - - ```django - {% component "my_table" %} - {% fill "pagination" default="default_pag" %} -
- {{ default_pag }} -
- {% endfill %} - {% endcomponent %} - ``` - - ### Accessing slot's data with the `data` kwarg - - ```django - {# my_table.html #} - - ... - {% slot "pagination" pages=pages %} - < 1 | 2 | 3 > - {% endslot %} -
- ``` - - ```django - {% component "my_table" %} - {% fill "pagination" data="slot_data" %} - {% for page in slot_data.pages %} - - {{ page.index }} - - {% endfor %} - {% endfill %} - {% endcomponent %} - ``` - - ### Accessing slot data and default content on the default slot - - To access slot data and the default slot content on the default slot, - use `{% fill %}` with `name` set to `"default"`: - - ```django - {% component "button" %} - {% fill name="default" data="slot_data" default="default_slot" %} - You clicked me {{ slot_data.count }} times! - {{ default_slot }} - {% endfill %} - {% endcomponent %} - ``` - """ - tag_id = gen_id() - tag = parse_template_tag(parser, token, tag_spec) - - trace_id = f"fill-id-{tag_id}" - trace_msg("PARSE", "FILL", trace_id, tag_id) - - body = tag.parse_body() - fill_node = FillNode( - nodelist=body, - node_id=tag_id, - params=tag.params, - trace_id=trace_id, - ) - - trace_msg("PARSE", "FILL", trace_id, tag_id, "...Done!") - return fill_node - - -def component_signature(*args: Any, **kwargs: Any) -> None: ... # noqa: E704 - - -@with_tag_spec( - TagSpec( - tag="component", - end_tag="endcomponent", - signature=inspect.Signature.from_callable(component_signature), - flags=[COMP_ONLY_FLAG], - ) -) -def component( - parser: Parser, - token: Token, - registry: ComponentRegistry, - tag_name: str, - tag_spec: TagSpec, -) -> ComponentNode: - """ - Renders one of the components that was previously registered with - [`@register()`](./api.md#django_components.register) - decorator. - - **Args:** - - - `name` (str, required): Registered name of the component to render - - All other args and kwargs are defined based on the component itself. - - If you defined a component `"my_table"` - - ```python - from django_component import Component, register - - @register("my_table") - class MyTable(Component): - template = \"\"\" - - - {% for header in headers %} - - {% endfor %} - - - {% for row in rows %} - - {% for cell in row %} - - {% endfor %} - - {% endfor %} - -
{{ header }}
{{ cell }}
- \"\"\" - - def get_context_data(self, rows: List, headers: List): - return { - "rows": rows, - "headers": headers, - } - ``` - - Then you can render this component by referring to `MyTable` via its - registered name `"my_table"`: - - ```django - {% component "my_table" rows=rows headers=headers ... / %} - ``` - - ### Component input - - Positional and keyword arguments can be literals or template variables. - - The component name must be a single- or double-quotes string and must - be either: - - - The first positional argument after `component`: - - ```django - {% component "my_table" rows=rows headers=headers ... / %} - ``` - - - Passed as kwarg `name`: - - ```django - {% component rows=rows headers=headers name="my_table" ... / %} - ``` - - ### Inserting into slots - - If the component defined any [slots](../concepts/fundamentals/slots.md), you can - pass in the content to be placed inside those slots by inserting [`{% fill %}`](#fill) tags, - directly within the `{% component %}` tag: - - ```django - {% component "my_table" rows=rows headers=headers ... / %} - {% fill "pagination" %} - < 1 | 2 | 3 > - {% endfill %} - {% endcomponent %} - ``` - - ### Isolating components - - By default, components behave similarly to Django's - [`{% include %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#include), - and the template inside the component has access to the variables defined in the outer template. - - You can selectively isolate a component, using the `only` flag, so that the inner template - can access only the data that was explicitly passed to it: - - ```django - {% component "name" positional_arg keyword_arg=value ... only %} - ``` - """ - tag_id = gen_id() - - bits = token.split_contents() - - # Let the TagFormatter pre-process the tokens - formatter = get_tag_formatter(registry) - result = formatter.parse([*bits]) - end_tag = formatter.end_tag(result.component_name) - - # NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself, - # so we add it back in. - bits = [bits[0], *result.tokens] - token.contents = " ".join(bits) - - # Set the component-specific start and end tags - component_tag_spec = tag_spec.copy() - component_tag_spec.tag = tag_name - component_tag_spec.end_tag = end_tag - - tag = parse_template_tag(parser, token, component_tag_spec) - - trace_msg("PARSE", "COMP", result.component_name, tag_id) - - body = tag.parse_body() - - component_node = ComponentNode( - name=result.component_name, - params=tag.params, - isolated_context=tag.flags[COMP_ONLY_FLAG], - nodelist=body, - node_id=tag_id, - registry=registry, - ) - - trace_msg("PARSE", "COMP", result.component_name, tag_id, "...Done!") - return component_node - - -def provide_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704 - - -@register.tag("provide") -@with_tag_spec( - TagSpec( - tag="provide", - end_tag="endprovide", - signature=inspect.Signature.from_callable(provide_signature), - flags=[], - ) -) -def provide(parser: Parser, token: Token, tag_spec: TagSpec) -> ProvideNode: - """ - The "provider" part of the [provide / inject feature](../../concepts/advanced/provide_inject). - Pass kwargs to this tag to define the provider's data. - Any components defined within the `{% provide %}..{% endprovide %}` tags will be able to access this data - with [`Component.inject()`](../api#django_components.Component.inject). - - This is similar to React's [`ContextProvider`](https://react.dev/learn/passing-data-deeply-with-context), - or Vue's [`provide()`](https://vuejs.org/guide/components/provide-inject). - - **Args:** - - - `name` (str, required): Provider name. This is the name you will then use in - [`Component.inject()`](../api#django_components.Component.inject). - - `**kwargs`: Any extra kwargs will be passed as the provided data. - - **Example:** - - Provide the "user_data" in parent component: - - ```python - @register("parent") - class Parent(Component): - template = \"\"\" -
- {% provide "user_data" user=user %} - {% component "child" / %} - {% endprovide %} -
- \"\"\" - - def get_context_data(self, user: User): - return { - "user": user, - } - ``` - - Since the "child" component is used within the `{% provide %} / {% endprovide %}` tags, - we can request the "user_data" using `Component.inject("user_data")`: - - ```python - @register("child") - class Child(Component): - template = \"\"\" -
- User is: {{ user }} -
- \"\"\" - - def get_context_data(self): - user = self.inject("user_data").user - return { - "user": user, - } - ``` - - Notice that the keys defined on the `{% provide %}` tag are then accessed as attributes - when accessing them with [`Component.inject()`](../api#django_components.Component.inject). - - ✅ Do this - ```python - user = self.inject("user_data").user - ``` - - ❌ Don't do this - ```python - user = self.inject("user_data")["user"] - ``` - """ - tag_id = gen_id() - - # e.g. {% provide key=val key2=val2 %} - tag = parse_template_tag(parser, token, tag_spec) - - trace_id = f"fill-id-{tag_id}" - trace_msg("PARSE", "PROVIDE", trace_id, tag_id) - - body = tag.parse_body() - provide_node = ProvideNode( - nodelist=body, - node_id=tag_id, - params=tag.params, - trace_id=trace_id, - ) - - trace_msg("PARSE", "PROVIDE", trace_id, tag_id, "...Done!") - return provide_node - - -def html_attrs_signature( # noqa: E704 - attrs: Optional[Dict] = None, defaults: Optional[Dict] = None, **kwargs: Any -) -> None: ... - - -@register.tag("html_attrs") -@with_tag_spec( - TagSpec( - tag="html_attrs", - end_tag=None, # inline-only - signature=inspect.Signature.from_callable(html_attrs_signature), - flags=[], - ) -) -def html_attrs(parser: Parser, token: Token, tag_spec: TagSpec) -> HtmlAttrsNode: - """ - Generate HTML attributes (`key="value"`), combining data from multiple sources, - whether its template variables or static text. - - It is designed to easily merge HTML attributes passed from outside with the internal. - See how to in [Passing HTML attributes to components](../../guides/howto/passing_html_attrs/). - - **Args:** - - - `attrs` (dict, optional): Optional dictionary that holds HTML attributes. On conflict, overrides - values in the `default` dictionary. - - `default` (str, optional): Optional dictionary that holds HTML attributes. On conflict, is overriden - with values in the `attrs` dictionary. - - Any extra kwargs will be appended to the corresponding keys - - The attributes in `attrs` and `defaults` are merged and resulting dict is rendered as HTML attributes - (`key="value"`). - - Extra kwargs (`key=value`) are concatenated to existing keys. So if we have - - ```python - attrs = {"class": "my-class"} - ``` - - Then - - ```django - {% html_attrs attrs class="extra-class" %} - ``` - - will result in `class="my-class extra-class"`. - - **Example:** - ```django -
- ``` - - renders - - ```html -
- ``` - - **See more usage examples in - [HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).** - """ - tag_id = gen_id() - tag = parse_template_tag(parser, token, tag_spec) - - return HtmlAttrsNode( - node_id=tag_id, - params=tag.params, - ) +# All tags are defined with our BaseNode class. Reasons for that are: +# - This ensures they all have the same set of features, like supporting flags, +# or literal lists and dicts as parameters. +# - The individual Node classes double as a source of truth for the tag's documentation. +# +# NOTE: The documentation generation script in `docs/scripts/reference.py` actually +# searches this file for all `Node` classes and uses them to generate the documentation. +# The docstring on the Node classes is used as the tag's documentation. +ComponentNode.register(register) +ComponentCssDependenciesNode.register(register) +ComponentJsDependenciesNode.register(register) +FillNode.register(register) +HtmlAttrsNode.register(register) +ProvideNode.register(register) +SlotNode.register(register) + + +# For an intuitive use via Python imports, the tags are aliased to the function name. +# E.g. so if the tag name is `slot`, one can also do: +# `from django_components.templatetags.component_tags import slot` +component = ComponentNode.parse +component_css_dependencies = ComponentCssDependenciesNode.parse +component_js_dependencies = ComponentJsDependenciesNode.parse +fill = FillNode.parse +html_attrs = HtmlAttrsNode.parse +provide = ProvideNode.parse +slot = SlotNode.parse __all__ = [ diff --git a/src/django_components/util/logger.py b/src/django_components/util/logger.py index 9b7aba4f..b3d8626b 100644 --- a/src/django_components/util/logger.py +++ b/src/django_components/util/logger.py @@ -1,6 +1,6 @@ import logging import sys -from typing import Any, Dict, Literal, Optional +from typing import Any, Dict, Literal DEFAULT_TRACE_LEVEL_NUM = 5 # NOTE: MUST be lower than DEBUG which is 10 @@ -62,27 +62,18 @@ def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> No def trace_msg( - action: Literal["PARSE", "RENDR", "GET", "SET"], - node_type: Literal["COMP", "FILL", "SLOT", "PROVIDE", "N/A"], - node_name: str, + action: Literal["PARSE", "RENDR"], + node_type: str, node_id: str, msg: str = "", - component_id: Optional[str] = None, ) -> None: """ TRACE level logger with opinionated format for tracing interaction of components, nodes, and slots. Formats messages like so: - `"ASSOC SLOT test_slot ID 0088 TO COMP 0087"` + `"PARSE slot ID 0088 ...Done!"` """ - msg_prefix = "" - if action == "RENDR" and node_type == "FILL": - if not component_id: - raise ValueError("component_id must be set for the RENDER action") - msg_prefix = f"FOR COMP {component_id}" - - msg_parts = [f"{action} {node_type} {node_name} ID {node_id}", *([msg_prefix] if msg_prefix else []), msg] - full_msg = " ".join(msg_parts) + full_msg = f"{action} {node_type} ID {node_id} {msg}" # NOTE: When debugging tests during development, it may be easier to change # this to `print()` diff --git a/src/django_components/util/template_tag.py b/src/django_components/util/template_tag.py index 520e5e23..0dd260f6 100644 --- a/src/django_components/util/template_tag.py +++ b/src/django_components/util/template_tag.py @@ -1,7 +1,12 @@ -import functools +""" +This file is for logic that focuses on transforming the AST of template tags +(as parsed from tag_parser) into a form that can be used by the Nodes. +""" + import inspect +import sys from dataclasses import dataclass -from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, Optional, Set, Tuple +from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, Optional, Set, Tuple, Union from django.template import Context, NodeList from django.template.base import Parser, Token @@ -11,152 +16,36 @@ from django_components.util.tag_parser import TagAttr, parse_tag -@dataclass -class TagSpec: - """Definition of args, kwargs, flags, etc, for a template tag.""" - - signature: inspect.Signature - """Input to the tag as a Python function signature.""" - tag: str - """Tag name. E.g. `"slot"` means the tag is written like so `{% slot ... %}`""" - end_tag: Optional[str] = None - """ - End tag. - - E.g. `"endslot"` means anything between the start tag and `{% endslot %}` - is considered the slot's body. - """ - flags: Optional[List[str]] = None +# For details see https://github.com/EmilStenstrom/django-components/pull/902#discussion_r1913611633 +# and following comments +def validate_params( + tag: str, + signature: inspect.Signature, + params: List["TagParam"], + extra_kwargs: Optional[Dict[str, Any]] = None, +) -> None: """ - List of allowed flags. + Validates a list of TagParam objects against this tag's function signature. - Flags are like kwargs, but without the value part. E.g. in `{% mytag only required %}`: - - `only` and `required` are treated as `only=True` and `required=True` if present - - and treated as `only=False` and `required=False` if omitted - """ - - def copy(self) -> "TagSpec": - sig_parameters_copy = [param.replace() for param in self.signature.parameters.values()] - signature = inspect.Signature(sig_parameters_copy) - flags = self.flags.copy() if self.flags else None - return self.__class__( - signature=signature, - tag=self.tag, - end_tag=self.end_tag, - flags=flags, - ) - - # For details see https://github.com/EmilStenstrom/django-components/pull/902 - def validate_params(self, params: List["TagParam"]) -> Tuple[List[Any], Dict[str, Any]]: - """ - Validates a list of TagParam objects against this tag spec's function signature. - - The validation preserves the order of parameters as they appeared in the template. - - Args: - params: List of TagParam objects representing the parameters as they appeared - in the template tag. - - Returns: - A tuple of (args, kwargs) containing the validated parameters. - - Raises: - TypeError: If the parameters don't match the tag spec's rules. - """ - - # Create a function with this signature that captures the input and sorts - # it into args and kwargs - def validator(*args: Any, **kwargs: Any) -> Tuple[List[Any], Dict[str, Any]]: - # Let Python do the signature validation - bound = self.signature.bind(*args, **kwargs) - bound.apply_defaults() - - # Extract positional args - pos_args: List[Any] = [] - for name, param in self.signature.parameters.items(): - # Case: `name` (positional) - if param.kind == inspect.Parameter.POSITIONAL_ONLY: - pos_args.append(bound.arguments[name]) - # Case: `*args` - elif param.kind == inspect.Parameter.VAR_POSITIONAL: - pos_args.extend(bound.arguments[name]) - - # Extract kwargs - kw_args: Dict[str, Any] = {} - for name, param in self.signature.parameters.items(): - # Case: `name=...` - if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY): - if name in bound.arguments: - kw_args[name] = bound.arguments[name] - # Case: `**kwargs` - elif param.kind == inspect.Parameter.VAR_KEYWORD: - kw_args.update(bound.arguments[name]) - - return pos_args, kw_args - - # Set the signature on the function - validator.__signature__ = self.signature # type: ignore[attr-defined] - - # Call the validator with our args and kwargs, in such a way to - # let the Python interpreter validate on repeated kwargs. E.g. - # - # ``` - # args, kwargs = validator( - # *call_args, - # **call_kwargs[0], - # **call_kwargs[1], - # ... - # ) - # ``` - call_args = [] - call_kwargs = [] - for param in params: - if param.key is None: - call_args.append(param.value) - else: - call_kwargs.append({param.key: param.value}) - - # NOTE: Although we use `exec()` here, it's safe, because we control the input - - # we make dynamic only the list index. - # - # We MUST use the indices, because we can't trust neither the param keys nor values, - # so we MUST NOT reference them directly in the exec script, otherwise we'd be at risk - # of injection attack. - validator_call_script = "args, kwargs = validator(*call_args, " - for kw_index, _ in enumerate(call_kwargs): - validator_call_script += f"**call_kwargs[{kw_index}], " - validator_call_script += ")" - - try: - # Create function namespace - namespace: Dict[str, Any] = {"validator": validator, "call_args": call_args, "call_kwargs": call_kwargs} - exec(validator_call_script, namespace) - new_args, new_kwargs = namespace["args"], namespace["kwargs"] - return new_args, new_kwargs - except TypeError as e: - # Enhance the error message - raise TypeError(f"Invalid parameters for tag '{self.tag}': {str(e)}") from None - - -def with_tag_spec(tag_spec: TagSpec) -> Callable: - """ - Decorator that binds a `tag_spec` to a template tag function, - there's a single source of truth for the tag spec, while also: + The validation preserves the order of parameters as they appeared in the template. - 1. Making the tag spec available inside the tag function as `tag_spec`. - 2. Making the tag spec accessible from outside as `_tag_spec` for documentation generation. + Raises `TypeError` if the parameters don't match the tag's signature. """ - def decorator(fn: Callable) -> Any: - fn._tag_spec = tag_spec # type: ignore[attr-defined] + # Create a function that uses the given signature + def validator(*args: Any, **kwargs: Any) -> None: + # Let Python do the signature validation + bound = signature.bind(*args, **kwargs) + bound.apply_defaults() - @functools.wraps(fn) - def wrapper(*args: Any, **kwargs: Any) -> Any: - return fn(*args, **kwargs, tag_spec=tag_spec) + validator.__signature__ = signature # type: ignore[attr-defined] - return wrapper - - return decorator + # Call the validator with our args and kwargs in the same order as they appeared + # in the template, to let the Python interpreter validate on repeated kwargs. + try: + apply_params_in_original_order(validator, params, extra_kwargs) + except TypeError as e: + raise TypeError(f"Invalid parameters for tag '{tag}': {str(e)}") from None @dataclass @@ -180,64 +69,54 @@ class TagParam: value: Any -class TagParams(NamedTuple): - """ - TagParams holds the parsed tag attributes and the tag spec, so that, at render time, - when we are able to resolve the tag inputs with the given Context, we are also able to validate - the inputs against the tag spec. - - This is done so that the tag's public API (as defined in the tag spec) can be defined - next to the tag implementation. Otherwise the input validation would have to be defined by - the internal `Node` classes. - """ - - params: List[TagAttr] - tag_spec: TagSpec - - def resolve(self, context: Context) -> Tuple[List[Any], Dict[str, Any]]: - # First, resolve any spread operators. Spreads can introduce both positional - # args (e.g. `*args`) and kwargs (e.g. `**kwargs`). - resolved_params: List[TagParam] = [] - for param in self.params: - resolved = param.value.resolve(context) - - if param.value.spread: - if param.key: - raise ValueError(f"Cannot spread a value onto a key: {param.key}") - - if isinstance(resolved, Mapping): - for key, value in resolved.items(): - resolved_params.append(TagParam(key=key, value=value)) - elif isinstance(resolved, Iterable): - for value in resolved: - resolved_params.append(TagParam(key=None, value=value)) - else: - raise ValueError( - f"Cannot spread non-iterable value: '{param.value.serialize()}' resolved to {resolved}" - ) +def resolve_params( + tag: str, + params: List[TagAttr], + context: Context, +) -> List[TagParam]: + # First, resolve any spread operators. Spreads can introduce both positional + # args (e.g. `*args`) and kwargs (e.g. `**kwargs`). + resolved_params: List[TagParam] = [] + for param in params: + resolved = param.value.resolve(context) + + if param.value.spread: + if param.key: + raise ValueError(f"Cannot spread a value onto a key: {param.key}") + + if isinstance(resolved, Mapping): + for key, value in resolved.items(): + resolved_params.append(TagParam(key=key, value=value)) + elif isinstance(resolved, Iterable): + for value in resolved: + resolved_params.append(TagParam(key=None, value=value)) else: - resolved_params.append(TagParam(key=param.key, value=resolved)) + raise ValueError( + f"Cannot spread non-iterable value: '{param.value.serialize()}' resolved to {resolved}" + ) + else: + resolved_params.append(TagParam(key=param.key, value=resolved)) - if self.tag_spec.tag == "html_attrs": - resolved_params = merge_repeated_kwargs(resolved_params) - resolved_params = process_aggregate_kwargs(resolved_params) + if tag == "html_attrs": + resolved_params = merge_repeated_kwargs(resolved_params) + resolved_params = process_aggregate_kwargs(resolved_params) - args, kwargs = self.tag_spec.validate_params(resolved_params) - return args, kwargs + return resolved_params # Data obj to give meaning to the parsed tag fields class ParsedTag(NamedTuple): - tag_name: str flags: Dict[str, bool] - params: TagParams + params: List[TagAttr] parse_body: Callable[[], NodeList] def parse_template_tag( + tag: str, + end_tag: Optional[str], + allowed_flags: Optional[List[str]], parser: Parser, token: Token, - tag_spec: TagSpec, ) -> ParsedTag: _, attrs = parse_tag(token.contents, parser) @@ -246,13 +125,14 @@ def parse_template_tag( tag_name = tag_name_attr.serialize(omit_key=True) # Sanity check - if tag_name != tag_spec.tag: - raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag_spec.tag}'") + if tag_name != tag: + raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag}'") # There's 3 ways how we tell when a tag ends: # 1. If the tag contains `/` at the end, it's a self-closing tag (like `
`), # and it doesn't have an end tag. In this case we strip the trailing slash. - # Otherwise, depending on the tag spec, the tag may be: + # + # Otherwise, depending on the end_tag, the tag may be: # 2. Block tag - With corresponding end tag, e.g. `{% endslot %}` # 3. Inlined tag - Without the end tag. last_token = attrs[-1].value if len(attrs) else None @@ -260,9 +140,9 @@ def parse_template_tag( attrs.pop() is_inline = True else: - is_inline = not tag_spec.end_tag + is_inline = not end_tag - raw_params, flags = _extract_flags(tag_name, attrs, tag_spec.flags or []) + raw_params, flags = _extract_flags(tag_name, attrs, allowed_flags or []) def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList: if inline: @@ -273,14 +153,13 @@ def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList: return body return ParsedTag( - tag_name=tag_name, - params=TagParams(params=raw_params, tag_spec=tag_spec), + params=raw_params, flags=flags, # NOTE: We defer parsing of the body, so we have the chance to call the tracing # loggers before the parsing. This is because, if the body contains any other # tags, it will trigger their tag handlers. So the code called AFTER # `parse_body()` is already after all the nested tags were processed. - parse_body=lambda: _parse_tag_body(parser, tag_spec.end_tag, is_inline) if tag_spec.end_tag else NodeList(), + parse_body=lambda: _parse_tag_body(parser, end_tag, is_inline) if end_tag else NodeList(), ) @@ -305,7 +184,7 @@ def _extract_flags( found_flags.add(value) flags_dict: Dict[str, bool] = { - # Base state, as defined in the tag spec + # Base state - all flags False **{flag: False for flag in (allowed_flags or [])}, # Flags found on the template tag **{flag: True for flag in found_flags}, @@ -348,3 +227,99 @@ def merge_repeated_kwargs(params: List[TagParam]) -> List[TagParam]: params_by_key[param.key].value += " " + str(param.value) return resolved_params + + +def apply_params_in_original_order( + fn: Callable[..., Any], + params: List[TagParam], + extra_kwargs: Optional[Dict[str, Any]] = None, +) -> Any: + """ + Apply a list of `TagParams` to another function, keeping the order of the params as they + appeared in the template. + + If a template tag was called like this: + + ```django + {% component key1=value1 arg1 arg2 key2=value2 key3=value3 %} + ``` + + Then `apply_params_in_original_order()` will call the `fn` like this: + ``` + component( + key1=call_params[0], # kwarg 1 + call_params[1], # arg 1 + call_params[2], # arg 2 + key2=call_params[3], # kwarg 2 + key3=call_params[4], # kwarg 3 + ... + **extra_kwargs, + ) + ``` + + This way, this will be effectively the same as: + + ```python + component(key1=value1, arg1, arg2, key2=value2, key3=value3, ..., **extra_kwargs) + ``` + + The problem this works around is that, dynamically, args and kwargs in Python + can be passed only with `*args` and `**kwargs`. But in such case, we're already + grouping all args and kwargs, which may not represent the original order of the params + as they appeared in the template tag. + + If you need to pass kwargs that are not valid Python identifiers, e.g. `data-id`, `class`, `:href`, + you can pass them in via `extra_kwargs`. These kwargs will be exempt from the validation, and will be + passed to the function as a dictionary spread. + """ + # Generate a script like so: + # ```py + # component( + # key1=call_params[0], + # call_params[1], + # call_params[2], + # key2=call_params[3], + # key3=call_params[4], + # ... + # **extra_kwargs, + # ) + # ``` + # + # NOTE: Instead of grouping params into args and kwargs, we preserve the original order + # of the params as they appeared in the template. + # + # NOTE: Because we use `eval()` here, we can't trust neither the param keys nor values. + # So we MUST NOT reference them directly in the exec script, otherwise we'd be at risk + # of injection attack. + # + # Currently, the use of `eval()` is safe, because we control the input: + # - List with indices is used so that we don't have to reference directly or try to print the values. + # and instead refer to them as `call_params[0]`, `call_params[1]`, etc. + # - List indices are safe, because we generate them. + # - Kwarg names come from the user. But Python expects the kwargs to be valid identifiers. + # So if a key is not a valid identifier, we'll raise an error. Before passing it to `eval()` + validator_call_script = "fn(" + call_params: List[Union[List, Dict]] = [] + for index, param in enumerate(params): + call_params.append(param.value) + if param.key is None: + validator_call_script += f"call_params[{index}], " + else: + validator_call_script += f"{param.key}=call_params[{index}], " + + validator_call_script += "**extra_kwargs, " + validator_call_script += ")" + + def applier(fn: Callable[..., Any]) -> Any: + locals = { + "fn": fn, + "call_params": call_params, + "extra_kwargs": extra_kwargs or {}, + } + # NOTE: `eval()` changed API in Python 3.13 + if sys.version_info >= (3, 13): + return eval(validator_call_script, globals={}, locals=locals) + else: + return eval(validator_call_script, {}, locals) + + return applier(fn) diff --git a/tests/django_test_setup.py b/tests/django_test_setup.py index c9c0f9d7..63807704 100644 --- a/tests/django_test_setup.py +++ b/tests/django_test_setup.py @@ -22,6 +22,11 @@ def setup_test_config( "tests/templates/", "tests/components/", # Required for template relative imports in tests ], + "OPTIONS": { + "builtins": [ + "django_components.templatetags.component_tags", + ] + }, } ], "COMPONENTS": { diff --git a/tests/test_component_media.py b/tests/test_component_media.py index b8aca25b..9329e0d0 100644 --- a/tests/test_component_media.py +++ b/tests/test_component_media.py @@ -108,7 +108,7 @@ class TestComponent(AppLvlCompComponent): self.assertInHTML( """ -
+
@@ -184,7 +184,7 @@ def get_context_data(self, variable): rendered = render_dependencies(rendered_raw) self.assertIn( - "Variable: test", + "Variable: test", rendered, ) self.assertInHTML( @@ -915,7 +915,7 @@ def test_component_with_relative_media_paths(self): self.assertInHTML( """ -
+
diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 9632d1e9..8215e675 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -265,7 +265,7 @@ def test_does_not_insert_styles_and_script_to_default_places_if_overriden(self): self.assertInHTML( """ - Variable: foo + Variable: foo @@ -510,7 +510,7 @@ def assert_dependencies(content: str): assert_dependencies(rendered1) self.assertEqual( - rendered1.count("Variable: value"), + rendered1.count("Variable: value"), 1, ) @@ -520,7 +520,7 @@ def assert_dependencies(content: str): ) assert_dependencies(rendered2) self.assertEqual( - rendered2.count("Variable: value"), + rendered2.count("Variable: value"), 1, ) @@ -531,6 +531,6 @@ def assert_dependencies(content: str): assert_dependencies(rendered3) self.assertEqual( - rendered3.count("Variable: value"), + rendered3.count("Variable: value"), 1, ) diff --git a/tests/test_expression.py b/tests/test_expression.py index 01e0e4a9..3700f0b7 100644 --- a/tests/test_expression.py +++ b/tests/test_expression.py @@ -745,10 +745,7 @@ def get_context_data( template1 = Template(template_str1) - with self.assertRaisesMessage( - TypeError, - "got multiple values for keyword argument 'x'", - ): + with self.assertRaisesMessage(SyntaxError, "keyword argument repeated"): template1.render(context) # But, similarly to python, we can merge multiple **kwargs by instead diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 00000000..cbe5b428 --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,570 @@ +from django.template import Context, Template +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_test_setup import setup_test_config +from .testutils import BaseTestCase + +setup_test_config({"autodiscover": False}) + + +class NodeTests(BaseTestCase): + def test_node_class_requires_tag(self): + with self.assertRaises(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({})) + + self.assertEqual(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 self.assertRaisesMessage(TemplateSyntaxError, "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 self.assertRaisesMessage(TemplateSyntaxError, "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({})) + self.assertEqual(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 + self.assertEqual(allowed_flags, ["required", "default"]) + self.assertEqual(flags, {"required": True, "default": False}) + self.assertEqual(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"})) + + self.assertEqual(captured, {"False": False, "None": None, "True": True, "name": "John"}) + self.assertEqual(rendered.strip(), "Hello, John!") + + TestNode.unregister(component_tags.register) + + def test_node_render_raises_if_no_context_arg(self): + with self.assertRaisesMessage(TypeError, "`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({})) + self.assertEqual(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({})) + self.assertEqual(captured, ("John2", 2, "Hello", "custom")) + + # Set no params + template3 = Template( + """ + {% load component_tags %} + {% mytag %} + """ + ) + with self.assertRaisesMessage( + TypeError, "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 self.assertRaisesMessage( + TypeError, "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 self.assertRaisesMessage( + TypeError, "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 self.assertRaisesMessage( + TypeError, "Invalid parameters for tag 'mytag': multiple values for argument 'name'" + ): + template6.render(Context({})) + + # Extra args after kwargs + template6 = Template( + """ + {% load component_tags %} + {% mytag count=1 name='John' 123 %} + """ + ) + with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"): + template6.render(Context({})) + + # Extra kwargs + template7 = Template( + """ + {% load component_tags %} + {% mytag 'John' msg='Hello' mode='custom' var=123 %} + """ + ) + with self.assertRaisesMessage( + TypeError, "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 self.assertRaisesMessage( + TypeError, "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 self.assertRaisesMessage(SyntaxError, "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({})) + self.assertEqual( + 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) + + +class DecoratorTests(BaseTestCase): + def test_decorator_requires_tag(self): + with self.assertRaisesMessage(TypeError, "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({})) + + self.assertEqual(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 self.assertRaisesMessage(TemplateSyntaxError, "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 self.assertRaisesMessage(TemplateSyntaxError, "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({})) + self.assertEqual(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"})) + + self.assertEqual(captured, {"False": False, "None": None, "True": True, "name": "John"}) + self.assertEqual(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 self.assertRaisesMessage( + TypeError, + "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({})) + self.assertEqual(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({})) + self.assertEqual(captured, ("John2", 2, "Hello", "custom")) + + # Set no params + template3 = Template( + """ + {% load component_tags %} + {% mytag %} + """ + ) + with self.assertRaisesMessage( + TypeError, "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 self.assertRaisesMessage( + TypeError, "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 self.assertRaisesMessage( + TypeError, "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 self.assertRaisesMessage( + TypeError, "Invalid parameters for tag 'mytag': multiple values for argument 'name'" + ): + template6.render(Context({})) + + # Extra args after kwargs + template6 = Template( + """ + {% load component_tags %} + {% mytag count=1 name='John' 123 %} + """ + ) + with self.assertRaisesMessage(SyntaxError, "positional argument follows keyword argument"): + template6.render(Context({})) + + # Extra kwargs + template7 = Template( + """ + {% load component_tags %} + {% mytag 'John' msg='Hello' mode='custom' var=123 %} + """ + ) + with self.assertRaisesMessage( + TypeError, "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 self.assertRaisesMessage( + TypeError, "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 self.assertRaisesMessage(SyntaxError, "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({})) + self.assertEqual( + 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] diff --git a/tests/test_templatetags_provide.py b/tests/test_templatetags_provide.py index 1951771b..0d540c51 100644 --- a/tests/test_templatetags_provide.py +++ b/tests/test_templatetags_provide.py @@ -381,7 +381,10 @@ def get_context_data(self): {% component "injectee" %} {% endcomponent %} """ - with self.assertRaisesMessage(RuntimeError, "Provide tag kwarg 'name' is missing"): + with self.assertRaisesMessage( + TemplateSyntaxError, + "Provide tag received an empty string. Key must be non-empty and a valid identifier", + ): Template(template_str).render(Context({})) @parametrize_context_behavior(["django", "isolated"])