From 71d8679e8d0a10768070fede1dd2cc422482b24e Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Sun, 18 Aug 2024 16:58:56 +0200 Subject: [PATCH] feat: TagFormatter - Allow users to customize component template tags (#572) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 238 ++++++- src/django_components/__init__.py | 27 +- src/django_components/app_settings.py | 9 +- src/django_components/attributes.py | 51 +- src/django_components/component.py | 19 +- src/django_components/component_registry.py | 104 +-- src/django_components/expression.py | 25 +- src/django_components/library.py | 60 ++ src/django_components/slots.py | 6 +- src/django_components/tag_formatter.py | 219 ++++++ src/django_components/template_parser.py | 17 +- .../templatetags/component_tags.py | 642 ++++++++++-------- src/django_components/utils.py | 4 + tests/test_attributes.py | 2 +- tests/test_context.py | 2 +- tests/test_registry.py | 51 +- tests/test_tag_formatter.py | 402 +++++++++++ tests/test_template_parser.py | 39 +- tests/test_templatetags_component.py | 56 +- tests/test_templatetags_provide.py | 20 +- tests/test_templatetags_slot_fill.py | 52 +- tests/test_templatetags_templating.py | 2 +- tests/test_utils.py | 20 + 23 files changed, 1593 insertions(+), 474 deletions(-) create mode 100644 src/django_components/library.py create mode 100644 src/django_components/tag_formatter.py create mode 100644 tests/test_tag_formatter.py create mode 100644 tests/test_utils.py diff --git a/README.md b/README.md index 90e0d43f..fdec02a7 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): - [Rendering HTML attributes](#rendering-html-attributes) - [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject) - [Component context and scope](#component-context-and-scope) +- [Customizing component tags with TagFormatter](#customizing-component-tags-with-tagformatter) - [Defining HTML/JS/CSS files](#defining-htmljscss-files) - [Rendering JS/CSS dependencies](#rendering-jscss-dependencies) - [Available settings](#available-settings) @@ -48,6 +49,34 @@ And this is what gets rendered (plus the CSS and Javascript you've specified): ## Release notes +**Version 0.90** +- All tags (`component`, `slot`, `fill`, ...) now support "self-closing" or "inline" form, where you can omit the closing tag: + ```django + {# Before #} + {% component "button" %}{% endcomponent %} + {# After #} + {% component "button" / %} + ``` +- All tags now support the "dictionary key" or "aggregate" syntax (`kwarg:key=val`): + ```django + {% component "button" attrs:class="hidden" %} + ``` +- You can change how the components are written in the template with [TagFormatter](#customizing-component-tags-with-tagformatter). + + The default is `django_components.component_formatter`: + ```django + {% component "button" href="..." disabled %} + Click me! + {% endcomponent %} + ``` + + While `django_components.shorthand_component_formatter` allows you to write components like so: + + ```django + {% button href="..." disabled %} + Click me! + {% endbutton %} + 🚨📢 **Version 0.85** Autodiscovery module resolution changed. Following undocumented behavior was removed: - Previously, autodiscovery also imported any `[app]/components.py` files, and used `SETTINGS_MODULE` to search for component dirs. @@ -420,6 +449,10 @@ First load the `component_tags` tag library, then use the `component_[js/css]_de ``` +> NOTE: Instead of writing `{% endcomponent %}` at the end, you can use a self-closing tag: +> +> `{% component "calendar" date="2015-06-19" / %}` + The output from the above template will be: ```html @@ -461,7 +494,7 @@ class SimpleComponent(Component): hello: {{ hello }} foo: {{ foo }} kwargs: {{ kwargs|safe }} - slot_first: {% slot "first" required %}{% endslot %} + slot_first: {% slot "first" required / %} """ def get_context_data(self, arg1, arg2, **kwargs): @@ -597,7 +630,7 @@ class Calendar(Component): template = """
- {% slot "header" %}{% endslot %} + {% slot "header" / %}
Today's date is {{ date }} @@ -1134,7 +1167,7 @@ To negate the meaning of `component_vars.is_filled`, simply treat it as boolean ```htmldjango {% if not component_vars.is_filled.subtitle %}
- {% slot "subtitle" %}{% endslot %} + {% slot "subtitle" / %}
{% endif %} ``` @@ -1266,8 +1299,7 @@ so are still valid: ```django - {% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True %} - {% endcomponent %} + {% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %} ``` @@ -1302,8 +1334,7 @@ But for that, we need to define this dictionary on Python side: @register("my_comp") class MyComp(Component): template = """ - {% component "other" attrs=attrs %} - {% endcomponent %} + {% component "other" attrs=attrs / %} """ def get_context_data(self, some_id: str): @@ -1334,8 +1365,7 @@ class MyComp(Component): attrs:class="pa-4 flex" attrs:data-some-id=some_id attrs:@click.stop="onClickHandler" - %} - {% endcomponent %} + / %} """ def get_context_data(self, some_id: str): @@ -1349,8 +1379,7 @@ Sweet! Now all the relevant HTML is inside the template, and we can move it to a attrs:class="pa-4 flex" attrs:data-some-id=some_id attrs:@click.stop="onClickHandler" -%} -{% endcomponent %} +/ %} ``` > Note: It is NOT possible to define nested dictionaries, so @@ -1646,8 +1675,7 @@ class Parent(Component): attrs:class="pa-0 border-solid border-red" attrs:data-json=json_data attrs:@click="(e) => onClick(e, 'from_parent')" - %} - {% endcomponent %} + / %} """ def get_context_data(self, date: Date): @@ -1775,12 +1803,10 @@ First we use the `{% provide %}` tag to define the data we want to "provide" (ma ```django {% provide "my_data" key="hi" another=123 %} - {% component "child" %} <--- Can access "my_data" - {% endcomponent %} + {% component "child" / %} <--- Can access "my_data" {% endprovide %} -{% component "child" %} <--- Cannot access "my_data" -{% endcomponent %} +{% component "child" / %} <--- Cannot access "my_data" ``` Notice that the `provide` tag REQUIRES a name as a first argument. This is the _key_ by which we can then access the data passed to this tag. @@ -1854,8 +1880,7 @@ class ChildComponent(Component): template_str = """ {% load component_tags %} {% provide "my_data" key="hi" another=123 %} - {% component "child" %} - {% endcomponent %} + {% component "child" / %} {% endprovide %} """ ``` @@ -1873,7 +1898,7 @@ By default, context variables are passed down the template as in regular Django With this in mind, the `{% component %}` tag behaves similarly to `{% include %}` tag - inside the component tag, you can access all variables that were defined outside of it. -And just like with `{% include %}`, if you don't want a specific component template to have access to the parent context, add `only` to the end of the `{% component %}` tag: +And just like with `{% include %}`, if you don't want a specific component template to have access to the parent context, add `only` to the `{% component %}` tag: ```htmldjango {% component "calendar" date="2015-06-19" only %}{% endcomponent %} @@ -1885,6 +1910,155 @@ If you find yourself using the `only` modifier often, you can set the [context_b Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`. +## Customizing component tags with TagFormatter + +_New in version 0.89_ + +By default, components are rendered using the pair of `{% component %}` / `{% endcomponent %}` template tags: + +```django +{% component "button" href="..." disabled %} +Click me! +{% endcomponent %} + +{# or #} + +{% component "button" href="..." disabled / %} +``` + +You can change this behaviour in the settings under the [`COMPONENTS.tag_formatter`](#tag-formatter-setting). + +For example, if you set the tag formatter to `django_components.shorthand_component_formatter`, the components will use their name as the template tags: + +```django +{% button href="..." disabled %} + Click me! +{% endbutton %} + +{# or #} + +{% button href="..." disabled / %} +``` + +### Available TagFormatters + +django_components provides following predefined TagFormatters: + +- **`ComponentFormatter` (`django_components.component_formatter`)** + + Default + + Uses the `component` and `endcomponent` tags, and the component name is gives as the first positional argument. + + Example as block: + ```django + {% component "button" href="..." %} + {% fill "content" %} + ... + {% endfill %} + {% endcomponent %} + ``` + + Example as inlined tag: + ```django + {% component "button" href="..." / %} + ``` + +- **`ShorthandComponentFormatter` (`django_components.shorthand_component_formatter`)** + + Uses the component name as start tag, and `end` + as an end tag. + + Example as block: + ```django + {% button href="..." %} + Click me! + {% endbutton %} + ``` + + Example as inlined tag: + ```django + {% button href="..." / %} + ``` + +### Writing your own TagFormatter + +#### Background + +First, let's discuss how TagFormatters work, and how components are rendered in django_components. + +When you render a component with `{% component %}` (or your own tag), the following happens: +1. `component` must be registered as a Django's template tag +2. Django triggers django_components's tag handler for tag `component`. +3. The tag handler passes the tag contents for pre-processing to `TagFormatter.parse()`. + + So if you render this: + ```django + {% component "button" href="..." disabled %} + {% endcomponent %} + ``` + + Then `TagFormatter.parse()` will receive a following input: + ```py + ["component", '"button"', 'href="..."', 'disabled'] + ``` +4. `TagFormatter` extracts the component name and the remaining input. + + So, given the above, `TagFormatter.parse()` returns the following: + ```py + TagResult( + component_name="button", + tokens=['href="..."', 'disabled'] + ) + ``` +5. The tag handler resumes, using the tokens returned from `TagFormatter`. + + So, continuing the example, at this point the tag handler practically behaves as if you rendered: + ```django + {% component href="..." disabled %} + ``` +6. Tag handler looks up the component `button`, and passes the args, kwargs, and slots to it. + +#### TagFormatter + +`TagFormatter` handles following parts of the process above: +- Generates start/end tags, given a component. This is what you then call from within your template as `{% component %}`. + +- When you `{% component %}`, tag formatter pre-processes the tag contents, so it can link back the custom template tag to the right component. + +To do so, subclass from `TagFormatterABC` and implement following method: +- `start_tag` +- `end_tag` +- `parse` + +For example, this is the implementation of [`ShorthandComponentFormatter`](#available-tagformatters) + +```py +class ShorthandComponentFormatter(TagFormatterABC): + # Given a component name, generate the start template tag + def start_tag(self, name: str) -> str: + return name # e.g. 'button' + + # Given a component name, generate the start template tag + def end_tag(self, name: str) -> str: + return f"end{name}" # e.g. 'endbutton' + + # Given a tag, e.g. + # `{% button href="..." disabled %}` + # + # The parser receives: + # `['button', 'href="..."', 'disabled']` + def parse(self, tokens: List[str]) -> TagResult: + tokens = [*tokens] + name = tokens.pop(0) + return TagResult( + name, # e.g. 'button' + tokens # e.g. ['href="..."', 'disabled'] + ) +``` + +That's it! And once your `TagFormatter` is ready, don't forget to update the settings! + ## Defining HTML/JS/CSS files django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/). @@ -2187,7 +2361,7 @@ COMPONENTS = { } ``` -### Context behavior +### Context behavior setting > NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70. > @@ -2292,6 +2466,28 @@ But since `"cheese"` is not defined there, it's empty. Notice that the variables defined with the `{% with %}` tag are ignored inside the `{% fill %}` tag with the `"isolated"` mode. +### Tag formatter setting + +Set the [`TagFormatter`](#available-tagformatters) instance. + +Can be set either as direct reference, or as an import string; + +```py +COMPONENTS = { + "tag_formatter": "django_components.component_formatter" +} +``` + +Or + +```py +from django_components import component_formatter + +COMPONENTS = { + "tag_formatter": component_formatter +} +``` + ## Logging and debugging Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting. diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index 373c78e4..e93c5ada 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -3,14 +3,27 @@ import django # Public API # isort: off -from django_components.autodiscover import autodiscover as autodiscover -from django_components.autodiscover import import_libraries as import_libraries +from django_components.autodiscover import ( + autodiscover as autodiscover, + import_libraries as import_libraries, +) from django_components.component import Component as Component -from django_components.component_registry import AlreadyRegistered as AlreadyRegistered -from django_components.component_registry import ComponentRegistry as ComponentRegistry -from django_components.component_registry import NotRegistered as NotRegistered -from django_components.component_registry import register as register -from django_components.component_registry import registry as registry +from django_components.component_registry import ( + AlreadyRegistered as AlreadyRegistered, + ComponentRegistry as ComponentRegistry, + NotRegistered as NotRegistered, + register as register, + registry as registry, +) +from django_components.library import TagProtectedError as TagProtectedError +from django_components.tag_formatter import ( + ComponentFormatter as ComponentFormatter, + ShorthandComponentFormatter as ShorthandComponentFormatter, + TagFormatterABC as TagFormatterABC, + TagResult as TagResult, + component_formatter as component_formatter, + component_shorthand_formatter as component_shorthand_formatter, +) import django_components.types as types # isort: on diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index a7bdfcdb..755b6260 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -1,8 +1,11 @@ from enum import Enum -from typing import Dict, List +from typing import TYPE_CHECKING, Dict, List, Union from django.conf import settings +if TYPE_CHECKING: + from django_components.tag_formatter import TagFormatterABC + class ContextBehavior(str, Enum): DJANGO = "django" @@ -115,5 +118,9 @@ class AppSettings: valid_values = [behavior.value for behavior in ContextBehavior] raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}") + @property + def TAG_FORMATTER(self) -> Union["TagFormatterABC", str]: + return self.settings.get("tag_formatter", "django_components.component_formatter") + app_settings = AppSettings() diff --git a/src/django_components/attributes.py b/src/django_components/attributes.py index 99fce6e5..296f97bd 100644 --- a/src/django_components/attributes.py +++ b/src/django_components/attributes.py @@ -5,69 +5,42 @@ from typing import Any, Dict, List, Mapping, Optional, Tuple from django.template import Context, Node -from django.template.base import FilterExpression from django.utils.html import conditional_escape, format_html from django.utils.safestring import SafeString, mark_safe -from django_components.template_parser import process_aggregate_kwargs +from django_components.expression import Expression, safe_resolve HTML_ATTRS_DEFAULTS_KEY = "defaults" HTML_ATTRS_ATTRS_KEY = "attrs" -def _default(val: Any, default_val: Any) -> Any: - return val if val is not None else default_val - - class HtmlAttrsNode(Node): def __init__( self, - attributes: Optional[FilterExpression], - default_attrs: Optional[FilterExpression], - kwargs: List[Tuple[str, FilterExpression]], + attributes: Optional[Expression], + defaults: Optional[Expression], + kwargs: List[Tuple[str, Expression]], ): self.attributes = attributes - self.default_attrs = default_attrs + self.defaults = defaults self.kwargs = kwargs def render(self, context: Context) -> str: append_attrs: List[Tuple[str, Any]] = [] - attrs_and_defaults_from_kwargs = {} - # Resolve kwargs, while also extracting attrs and defaults keys + # Resolve all data for key, value in self.kwargs: - resolved_value = value.resolve(context) - if key.startswith(f"{HTML_ATTRS_ATTRS_KEY}:") or key.startswith(f"{HTML_ATTRS_DEFAULTS_KEY}:"): - attrs_and_defaults_from_kwargs[key] = resolved_value - continue - # NOTE: These were already extracted into separate variables, so - # ignore them here. - elif key == HTML_ATTRS_ATTRS_KEY or key == HTML_ATTRS_DEFAULTS_KEY: - continue - + resolved_value = safe_resolve(value, context) append_attrs.append((key, resolved_value)) - # NOTE: Here we delegate validation to `process_aggregate_kwargs`, which should - # raise error if the dict includes both `attrs` and `attrs:` keys. - # - # So by assigning the `attrs` and `defaults` keys, users are forced to use only - # one approach or the other, but not both simultaneously. - if self.attributes: - attrs_and_defaults_from_kwargs[HTML_ATTRS_ATTRS_KEY] = self.attributes.resolve(context) - if self.default_attrs: - attrs_and_defaults_from_kwargs[HTML_ATTRS_DEFAULTS_KEY] = self.default_attrs.resolve(context) + defaults = safe_resolve(self.defaults, context) if self.defaults else {} + attrs = safe_resolve(self.attributes, context) if self.attributes else {} - # Turn `{"attrs:blabla": 1}` into `{"attrs": {"blabla": 1}}` - attrs_and_defaults_from_kwargs = process_aggregate_kwargs(attrs_and_defaults_from_kwargs) - - # NOTE: We want to allow to use `html_attrs` even without `attrs` or `defaults` params - # Or when they are None - attrs = _default(attrs_and_defaults_from_kwargs.get(HTML_ATTRS_ATTRS_KEY, None), {}) - default_attrs = _default(attrs_and_defaults_from_kwargs.get(HTML_ATTRS_DEFAULTS_KEY, None), {}) - - final_attrs = {**default_attrs, **attrs} + # Merge it + final_attrs = {**defaults, **attrs} final_attrs = append_attributes(*final_attrs.items(), *append_attrs) + # 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 1f61cf4f..3a5d1951 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -477,7 +477,7 @@ class ComponentNode(Node): def __init__( self, - name_fexp: FilterExpression, + name: str, context_args: List[FilterExpression], context_kwargs: Mapping[str, FilterExpression], isolated_context: bool = False, @@ -485,7 +485,7 @@ class ComponentNode(Node): component_id: Optional[str] = None, ) -> None: self.component_id = component_id or gen_id() - self.name_fexp = name_fexp + self.name = name self.context_args = context_args or [] self.context_kwargs = context_kwargs or {} self.isolated_context = isolated_context @@ -494,15 +494,14 @@ class ComponentNode(Node): def __repr__(self) -> str: return "".format( - self.name_fexp, + self.name, getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later. ) def render(self, context: Context) -> str: - trace_msg("RENDR", "COMP", self.name_fexp, self.component_id) + trace_msg("RENDR", "COMP", self.name, self.component_id) - resolved_component_name = self.name_fexp.resolve(context) - component_cls: Type[Component] = registry.get(resolved_component_name) + component_cls: Type[Component] = registry.get(self.name) # Resolve FilterExpressions and Variables that were passed as args to the # component, then call component's context method @@ -532,8 +531,8 @@ class ComponentNode(Node): f"Detected duplicate fill tag name '{resolved_name}'." ) - resolved_slot_default_var = fill_node.resolve_slot_default(context, resolved_component_name) - resolved_slot_data_var = fill_node.resolve_slot_data(context, resolved_component_name) + resolved_slot_default_var = fill_node.resolve_slot_default(context, self.name) + resolved_slot_data_var = fill_node.resolve_slot_data(context, self.name) fill_content[resolved_name] = FillContent( content_func=_nodelist_to_slot_render_func(fill_node.nodelist), slot_default_var=resolved_slot_default_var, @@ -541,7 +540,7 @@ class ComponentNode(Node): ) component: Component = component_cls( - registered_name=resolved_component_name, + registered_name=self.name, outer_context=context, fill_content=fill_content, component_id=self.component_id, @@ -557,7 +556,7 @@ class ComponentNode(Node): kwargs=resolved_context_kwargs, ) - trace_msg("RENDR", "COMP", self.name_fexp, self.component_id, "...Done!") + trace_msg("RENDR", "COMP", self.name, self.component_id, "...Done!") return output diff --git a/src/django_components/component_registry.py b/src/django_components/component_registry.py index ee04a94b..c4eaed47 100644 --- a/src/django_components/component_registry.py +++ b/src/django_components/component_registry.py @@ -1,25 +1,16 @@ -from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, TypeVar +from typing import TYPE_CHECKING, Callable, Dict, NamedTuple, Optional, Set, Type, TypeVar from django.template import Library +from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter +from django_components.tag_formatter import get_tag_formatter + if TYPE_CHECKING: from django_components.component import Component _TComp = TypeVar("_TComp", bound=Type["Component"]) -PROTECTED_TAGS = [ - "component", - "component_dependencies", - "component_css_dependencies", - "component_js_dependencies", - "fill", - "html_attrs", - "provide", - "slot", -] - - class AlreadyRegistered(Exception): pass @@ -28,20 +19,10 @@ class NotRegistered(Exception): pass -# Why do we store the tags with the component? +# Why do we store the tags with the components? # -# Each component may be associated with two template tags - One for "block" -# and one for "inline" usage. E.g. in the following snippets, the template -# tags are `component` and `#component`: -# -# `{% component "abc" %}{% endcomponent %}` -# `{% #component "abc" %}` -# -# (NOTE: While `endcomponent` also looks like a template tag, we don't have to register -# it, because it simply marks the end of body.) -# -# With the component tag formatter (configurable tags per component class), -# each component may have a unique set of template tags. +# With the addition of TagFormatter, each component class may have a unique +# set of template tags. # # For user's convenience, we automatically add/remove the tags from Django's tag Library, # when a component is (un)registered. @@ -49,12 +30,7 @@ class NotRegistered(Exception): # Thus we need to remember which component used which template tags. class ComponentRegistryEntry(NamedTuple): cls: Type["Component"] - block_tag: str - inline_tag: str - - @property - def tags(self) -> List[str]: - return [self.block_tag, self.inline_tag] + tag: str class ComponentRegistry: @@ -111,7 +87,7 @@ class ComponentRegistry: # On the other hand, if user provided their own Library instance, # it is up to the user to use `mark_protected_tags` if they want # to protect any tags. - mark_protected_tags(tag_library, PROTECTED_TAGS) + mark_protected_tags(tag_library) lib = self._library = tag_library return lib @@ -137,21 +113,14 @@ class ComponentRegistry: if existing_component and existing_component.cls._class_hash != component._class_hash: raise AlreadyRegistered('The component "%s" has already been registered' % name) - block_tag = "component" - inline_tag = "#component" - - entry = ComponentRegistryEntry( - cls=component, - block_tag=block_tag, - inline_tag=inline_tag, - ) + entry = self._register_to_library(name, component) # Keep track of which components use which tags, because multiple components may # use the same tag. - for tag in entry.tags: - if tag not in self._tags: - self._tags[tag] = set() - self._tags[tag].add(name) + tag = entry.tag + if tag not in self._tags: + self._tags[tag] = set() + self._tags[tag].add(name) self._registry[name] = entry @@ -180,22 +149,20 @@ class ComponentRegistry: self.get(name) entry = self._registry[name] + tag = entry.tag # Unregister the tag from library if this was the last component using this tag - for tag in entry.tags: - # Unlink component from tag - self._tags[tag].remove(name) + # Unlink component from tag + self._tags[tag].remove(name) - # Cleanup - is_tag_empty = not len(self._tags[tag]) - if is_tag_empty: - del self._tags[tag] - - # Do NOT unregister tag if it's protected - is_protected = is_tag_protected(self.library, tag) - if is_protected: - continue + # Cleanup + is_tag_empty = not len(self._tags[tag]) + if is_tag_empty: + del self._tags[tag] + # Only unregister a tag if it's NOT protected + is_protected = is_tag_protected(self.library, tag) + if not is_protected: # Unregister the tag from library if this was the last component using this tag if is_tag_empty and tag in self.library.tags: del self.library.tags[tag] @@ -268,6 +235,19 @@ class ComponentRegistry: self._registry = {} self._tags = {} + def _register_to_library( + self, + comp_name: str, + 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() + tag = register_tag_from_formatter(self.library, do_component, formatter, comp_name) + + return ComponentRegistryEntry(cls=component, tag=tag) + # This variable represents the global component registry registry: ComponentRegistry = ComponentRegistry() @@ -326,13 +306,3 @@ def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callabl return component return decorator - - -def mark_protected_tags(lib: Library, tags: List[str]) -> None: - # By marking the library as default, - lib._protected_tags = [*tags] - - -def is_tag_protected(lib: Library, tag: str) -> bool: - protected_tags = getattr(lib, "_protected_tags", []) - return tag in protected_tags diff --git a/src/django_components/expression.py b/src/django_components/expression.py index 1958863b..d9305669 100644 --- a/src/django_components/expression.py +++ b/src/django_components/expression.py @@ -4,6 +4,14 @@ from django.template import Context from django.template.base import FilterExpression, Parser +class AggregateFilterExpression: + def __init__(self, dict: Dict[str, FilterExpression]) -> None: + self.dict = dict + + +Expression = Union[FilterExpression, AggregateFilterExpression] + + def resolve_expression_as_identifier( context: Context, fexp: FilterExpression, @@ -20,19 +28,22 @@ def resolve_expression_as_identifier( return resolved -def safe_resolve_list(args: List[FilterExpression], context: Context) -> List: +def safe_resolve_list(args: List[Expression], context: Context) -> List: return [safe_resolve(arg, context) for arg in args] def safe_resolve_dict( - kwargs: Union[Mapping[str, FilterExpression], Dict[str, FilterExpression]], + kwargs: Union[Mapping[str, Expression], Dict[str, Expression]], context: Context, ) -> Dict: return {key: safe_resolve(kwarg, context) for key, kwarg in kwargs.items()} -def safe_resolve(context_item: FilterExpression, context: Context) -> Any: +def safe_resolve(context_item: Expression, context: Context) -> Any: """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged.""" + if isinstance(context_item, AggregateFilterExpression): + return safe_resolve_dict(context_item.dict, context) + return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item @@ -42,5 +53,11 @@ def resolve_string( context: Optional[Mapping[str, Any]] = None, ) -> str: parser = parser or Parser([]) - context = context or {} + context = Context(context or {}) return parser.compile_filter(s).resolve(context) + + +def is_aggregate_key(key: str) -> bool: + # NOTE: If we get a key that starts with `:`, like `:class`, we do not split it. + # This syntax is used by Vue and AlpineJS. + return ":" in key and not key.startswith(":") diff --git a/src/django_components/library.py b/src/django_components/library.py new file mode 100644 index 00000000..469000d3 --- /dev/null +++ b/src/django_components/library.py @@ -0,0 +1,60 @@ +"""Module for interfacing with Django's Library (`django.template.library`)""" + +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 + + +class TagProtectedError(Exception): + pass + + +PROTECTED_TAGS = [ + "component_dependencies", + "component_css_dependencies", + "component_js_dependencies", + "fill", + "html_attrs", + "provide", + "slot", +] +""" +These are the names that users cannot choose for their components, +as they would conflict with other tags in the Library. +""" + + +def register_tag( + library: Library, + tag: str, + tag_fn: Callable[[Parser, Token, str], Node], +) -> None: + # Register inline tag + if is_tag_protected(library, tag): + raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag) + else: + library.tag(tag, lambda parser, token: tag_fn(parser, token, tag)) + + +def register_tag_from_formatter( + library: Library, + tag_fn: Callable[[Parser, Token, str], Node], + formatter: InternalTagFormatter, + component_name: str, +) -> str: + tag = formatter.start_tag(component_name) + register_tag(library, tag, tag_fn) + return tag + + +def mark_protected_tags(lib: Library, tags: Optional[List[str]] = None) -> None: + protected_tags = tags if tags is not None else PROTECTED_TAGS + lib._protected_tags = [*protected_tags] + + +def is_tag_protected(lib: Library, tag: str) -> bool: + protected_tags = getattr(lib, "_protected_tags", []) + return tag in protected_tags diff --git a/src/django_components/slots.py b/src/django_components/slots.py index ecedeceb..9eed633b 100644 --- a/src/django_components/slots.py +++ b/src/django_components/slots.py @@ -16,10 +16,9 @@ from django_components.context import ( _INJECT_CONTEXT_KEY_PREFIX, _ROOT_CTX_CONTEXT_KEY, ) -from django_components.expression import resolve_expression_as_identifier, safe_resolve_dict +from django_components.expression import Expression, resolve_expression_as_identifier, safe_resolve_dict from django_components.logger import trace_msg from django_components.node import NodeTraverse, nodelist_has_content, walk_nodelist -from django_components.template_parser import process_aggregate_kwargs from django_components.utils import gen_id DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT" @@ -119,7 +118,7 @@ class SlotNode(Node): is_required: bool = False, is_default: bool = False, node_id: Optional[str] = None, - slot_kwargs: Optional[Dict[str, FilterExpression]] = None, + slot_kwargs: Optional[Dict[str, Expression]] = None, ): self.name = name self.nodelist = nodelist @@ -173,7 +172,6 @@ class SlotNode(Node): # are made available through a variable name that was set on the `{% fill %}` # tag. slot_kwargs = safe_resolve_dict(self.slot_kwargs, context) - slot_kwargs = process_aggregate_kwargs(slot_kwargs) data_var = slot_fill.slot_data_var if data_var: if not data_var.isidentifier(): diff --git a/src/django_components/tag_formatter.py b/src/django_components/tag_formatter.py new file mode 100644 index 00000000..cd3545fa --- /dev/null +++ b/src/django_components/tag_formatter.py @@ -0,0 +1,219 @@ +import abc +import re +from typing import List, NamedTuple + +from django.template import TemplateSyntaxError +from django.utils.module_loading import import_string + +from django_components.app_settings import app_settings +from django_components.expression import resolve_string +from django_components.template_parser import VAR_CHARS +from django_components.utils import is_str_wrapped_in_quotes + +TAG_RE = re.compile(r"^[{chars}]+$".format(chars=VAR_CHARS)) + + +class TagResult(NamedTuple): + """The return value from `TagFormatter.parse()`""" + + component_name: str + """Component name extracted from the template tag""" + tokens: List[str] + """Remaining tokens (words) that were passed to the tag, with component name removed""" + + +class TagFormatterABC(abc.ABC): + @abc.abstractmethod + def start_tag(self, name: str) -> str: + """Formats the start tag of a component.""" + ... + + @abc.abstractmethod + def end_tag(self, name: str) -> str: + """Formats the end tag of a block component.""" + ... + + @abc.abstractmethod + def parse(self, tokens: List[str]) -> TagResult: + """ + Given the tokens (words) of a component start tag, this function extracts + the component name from the tokens list, and returns `TagResult`, which + is a tuple of `(component_name, remaining_tokens)`. + + Example: + + Given a component declarations: + + `{% component "my_comp" key=val key2=val2 %}` + + This function receives a list of tokens + + `['component', '"my_comp"', 'key=val', 'key2=val2']` + + `component` is the tag name, which we drop. `"my_comp"` is the component name, + but we must remove the extra quotes. And we pass remaining tokens unmodified, + as that's the input to the component. + + So in the end, we return a tuple: + + `('my_comp', ['key=val', 'key2=val2'])` + """ + ... + + +class InternalTagFormatter: + """ + Internal wrapper around user-provided TagFormatters, so that we validate the outputs. + """ + + def __init__(self, tag_formatter: TagFormatterABC): + self.tag_formatter = tag_formatter + + def start_tag(self, name: str) -> str: + tag = self.tag_formatter.start_tag(name) + self._validate_tag(tag, "start_tag") + return tag + + def end_tag(self, name: str) -> str: + tag = self.tag_formatter.end_tag(name) + self._validate_tag(tag, "end_tag") + return tag + + def parse(self, tokens: List[str]) -> TagResult: + return self.tag_formatter.parse(tokens) + + # NOTE: We validate the generated tags, so they contain only valid characters (\w - : . @ #) + # and NO SPACE. Otherwise we wouldn't be able to distinguish a "multi-word" tag from several + # single-word tags. + def _validate_tag(self, tag: str, tag_type: str) -> None: + if not tag: + raise ValueError( + f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'." + f" Tag cannot be empty" + ) + + if not TAG_RE.match(tag): + raise ValueError( + f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'." + f" Tag must contain only following chars: {VAR_CHARS}" + ) + + +class ComponentFormatter(TagFormatterABC): + """ + The original django_component's component tag formatter, it uses the `component` + and `endcomponent` tags, and the component name is gives as the first positional arg. + + Example as block: + ```django + {% component "mycomp" abc=123 %} + {% fill "myfill" %} + ... + {% endfill %} + {% endcomponent %} + ``` + + Example as inlined tag: + ```django + {% component "mycomp" abc=123 / %} + ``` + """ + + def __init__(self, tag: str): + self.tag = tag + + def start_tag(self, name: str) -> str: + return self.tag + + def end_tag(self, name: str) -> str: + return f"end{self.tag}" + + def parse(self, tokens: List[str]) -> TagResult: + tag, *args = tokens + + if not args: + raise TemplateSyntaxError(f"{self.__class__.__name__}: Component tag did not receive tag name") + + # If the first arg is a kwarg, not a positional arg, then look for the "name" kwarg + # for component name. + if "=" in args[0]: + comp_name = None + final_args = [] + for kwarg in args: + if not kwarg.startswith("name="): + final_args.append(kwarg) + continue + + if comp_name: + raise TemplateSyntaxError( + f"ComponentFormatter: 'name' kwarg for component '{comp_name}'" " was defined more than once." + ) + + # NOTE: We intentionally do NOT add to `final_args` here + # because we want to remove the the `name=` kwarg from args list + comp_name = kwarg[5:] + else: + comp_name = args.pop(0) + final_args = args + + if not comp_name: + raise TemplateSyntaxError("Component name must be a non-empty quoted string, e.g. 'my_comp'") + + if not is_str_wrapped_in_quotes(comp_name): + raise TemplateSyntaxError(f"Component name must be a string 'literal', got: {comp_name}") + + # Remove the quotes + comp_name = resolve_string(comp_name) + + return TagResult(comp_name, final_args) + + +class ShorthandComponentFormatter(TagFormatterABC): + """ + The component tag formatter that uses `` / `end` tags. + + This is similar to django-web-components and django-slippers syntax. + + Example as block: + ```django + {% mycomp abc=123 %} + {% fill "myfill" %} + ... + {% endfill %} + {% endmycomp %} + ``` + + Example as inlined tag: + ```django + {% mycomp abc=123 / %} + ``` + """ + + def start_tag(self, name: str) -> str: + return name + + def end_tag(self, name: str) -> str: + return f"end{name}" + + def parse(self, tokens: List[str]) -> TagResult: + tokens = [*tokens] + name = tokens.pop(0) + return TagResult(name, tokens) + + +def get_tag_formatter() -> InternalTagFormatter: + """Returns an instance of the currently configured component tag formatter.""" + # Allow users to configure the component TagFormatter + formatter_cls_or_str = app_settings.TAG_FORMATTER + + if isinstance(formatter_cls_or_str, str): + tag_formatter: TagFormatterABC = import_string(formatter_cls_or_str) + else: + tag_formatter = formatter_cls_or_str + + return InternalTagFormatter(tag_formatter) + + +# Default formatters +component_formatter = ComponentFormatter("component") +component_shorthand_formatter = ShorthandComponentFormatter() diff --git a/src/django_components/template_parser.py b/src/django_components/template_parser.py index 5ea0a7ba..41d63c52 100644 --- a/src/django_components/template_parser.py +++ b/src/django_components/template_parser.py @@ -25,6 +25,9 @@ from django.utils.regex_helper import _lazy_re_compile # This is a copy of the original FilterExpression. The only difference is to allow variable names to have extra special # characters: - : . @ # ###################################################################################################################### + +VAR_CHARS = r"\w\-\:\@\.\#" + filter_raw_string = r""" ^(?P{constant})| ^(?P[{var_chars}]+|{num})| @@ -41,7 +44,7 @@ filter_raw_string = r""" num=r"[-+\.]?\d[\d\.e]*", # The following is the only difference from the original FilterExpression. We allow variable names to have extra # special characters: - : . @ # - var_chars=r"\w\-\:\@\.\#", + var_chars=VAR_CHARS, filter_sep=re.escape(FILTER_SEPARATOR), arg_sep=re.escape(FILTER_ARGUMENT_SEPARATOR), ) @@ -102,7 +105,7 @@ class ComponentsFilterExpression(FilterExpression): ###################################################################################################################### # Regex for token keyword arguments -kwarg_re = _lazy_re_compile(r"(?:([\w\-\:\@\.\#]+)=)?(.+)") +kwarg_re = _lazy_re_compile(r"(?:([{var_chars}]+)=)?(.+)".format(var_chars=VAR_CHARS)) def token_kwargs(bits: List[str], parser: Parser) -> Dict[str, FilterExpression]: @@ -261,9 +264,7 @@ def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]: processed_kwargs = {} nested_kwargs: Dict[str, Dict[str, Any]] = {} for key, val in kwargs.items(): - # NOTE: If we get a key that starts with `:`, like `:class`, we do not split it. - # This syntax is used by Vue and AlpineJS. - if ":" not in key or key.startswith(":"): + if not is_aggregate_key(key): processed_kwargs[key] = val continue @@ -283,3 +284,9 @@ def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]: processed_kwargs[key] = val return processed_kwargs + + +def is_aggregate_key(key: str) -> bool: + # NOTE: If we get a key that starts with `:`, like `:class`, we do not split it. + # This syntax is used by Vue and AlpineJS. + return ":" in key and not key.startswith(":") diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py index 86a0a4d3..567292d5 100644 --- a/src/django_components/templatetags/component_tags.py +++ b/src/django_components/templatetags/component_tags.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Dict, List, Mapping, Optional, Tuple +from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Union import django.template from django.template.base import FilterExpression, NodeList, Parser, Token @@ -10,7 +10,7 @@ from django_components.attributes import HtmlAttrsNode from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode from django_components.component_registry import ComponentRegistry from django_components.component_registry import registry as component_registry -from django_components.expression import resolve_string +from django_components.expression import AggregateFilterExpression, Expression, resolve_string from django_components.logger import trace_msg from django_components.middleware import ( CSS_DEPENDENCY_PLACEHOLDER, @@ -19,13 +19,16 @@ from django_components.middleware import ( ) from django_components.provide import ProvideNode from django_components.slots import FillNode, SlotNode, parse_slot_fill_nodes_from_component_nodelist -from django_components.template_parser import parse_bits -from django_components.utils import gen_id +from django_components.tag_formatter import get_tag_formatter +from django_components.template_parser import is_aggregate_key, parse_bits, process_aggregate_kwargs +from django_components.utils import gen_id, is_str_wrapped_in_quotes if TYPE_CHECKING: from django_components.component import Component +# 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() @@ -35,7 +38,7 @@ SLOT_DATA_ATTR = "data" SLOT_DEFAULT_ATTR = "default" -def get_components_from_registry(registry: ComponentRegistry) -> List["Component"]: +def _get_components_from_registry(registry: ComponentRegistry) -> List["Component"]: """Returns a list unique components from the registry.""" unique_component_classes = set(registry.all().values()) @@ -47,7 +50,7 @@ def get_components_from_registry(registry: ComponentRegistry) -> List["Component return components -def get_components_from_preload_str(preload_str: str) -> List["Component"]: +def _get_components_from_preload_str(preload_str: str) -> List["Component"]: """Returns a list of unique components from a comma-separated str""" components = [] @@ -62,83 +65,89 @@ def get_components_from_preload_str(preload_str: str) -> List["Component"]: @register.simple_tag(name="component_dependencies") -def component_dependencies_tag(preload: str = "") -> SafeString: +def component_dependencies(preload: str = "") -> SafeString: """Marks location where CSS link and JS script tags should be rendered.""" if is_dependency_middleware_active(): preloaded_dependencies = [] - for component in get_components_from_preload_str(preload): + for component in _get_components_from_preload_str(preload): preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name)) return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER + JS_DEPENDENCY_PLACEHOLDER) else: rendered_dependencies = [] - for component in get_components_from_registry(component_registry): + for component in _get_components_from_registry(component_registry): rendered_dependencies.append(component.render_dependencies()) return mark_safe("\n".join(rendered_dependencies)) @register.simple_tag(name="component_css_dependencies") -def component_css_dependencies_tag(preload: str = "") -> SafeString: +def component_css_dependencies(preload: str = "") -> SafeString: """Marks location where CSS link tags should be rendered.""" if is_dependency_middleware_active(): preloaded_dependencies = [] - for component in get_components_from_preload_str(preload): + for component in _get_components_from_preload_str(preload): preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name)) return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER) else: rendered_dependencies = [] - for component in get_components_from_registry(component_registry): + for component in _get_components_from_registry(component_registry): rendered_dependencies.append(component.render_css_dependencies()) return mark_safe("\n".join(rendered_dependencies)) @register.simple_tag(name="component_js_dependencies") -def component_js_dependencies_tag(preload: str = "") -> SafeString: +def component_js_dependencies(preload: str = "") -> SafeString: """Marks location where JS script tags should be rendered.""" if is_dependency_middleware_active(): preloaded_dependencies = [] - for component in get_components_from_preload_str(preload): + for component in _get_components_from_preload_str(preload): preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name)) return mark_safe("\n".join(preloaded_dependencies) + JS_DEPENDENCY_PLACEHOLDER) else: rendered_dependencies = [] - for component in get_components_from_registry(component_registry): + for component in _get_components_from_registry(component_registry): rendered_dependencies.append(component.render_js_dependencies()) return mark_safe("\n".join(rendered_dependencies)) @register.tag("slot") -def do_slot(parser: Parser, token: Token) -> SlotNode: - # e.g. {% slot ... %} - tag_name, *args = token.split_contents() - slot_name, is_default, is_required, slot_kwargs = _parse_slot_args(parser, args, tag_name) - # Use a unique ID to be able to tie the fill nodes with components and slots - # NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering - slot_id = gen_id() - trace_msg("PARSE", "SLOT", slot_name, slot_id) +def slot(parser: Parser, token: Token) -> SlotNode: + bits = token.split_contents() + tag = _parse_tag( + "slot", + parser, + bits, + params=["name"], + flags=[SLOT_DEFAULT_OPTION_KEYWORD, SLOT_REQUIRED_OPTION_KEYWORD], + keywordonly_kwargs=True, + repeatable_kwargs=False, + end_tag="endslot", + ) + data = _parse_slot_args(parser, tag) - nodelist = parser.parse(parse_until=["endslot"]) - parser.delete_first_token() + trace_msg("PARSE", "SLOT", data.name, tag.id) + + body = tag.parse_body() slot_node = SlotNode( - slot_name, - nodelist, - is_required=is_required, - is_default=is_default, - node_id=slot_id, - slot_kwargs=slot_kwargs, + name=data.name, + nodelist=body, + is_required=tag.flags[SLOT_REQUIRED_OPTION_KEYWORD], + is_default=tag.flags[SLOT_DEFAULT_OPTION_KEYWORD], + node_id=tag.id, + slot_kwargs=tag.kwargs, ) - trace_msg("PARSE", "SLOT", slot_name, slot_id, "...Done!") + trace_msg("PARSE", "SLOT", data.name, tag.id, "...Done!") return slot_node @register.tag("fill") -def do_fill(parser: Parser, token: Token) -> FillNode: +def fill(parser: Parser, token: Token) -> FillNode: """ Block tag whose contents 'fill' (are inserted into) an identically named 'slot'-block in the component template referred to by a parent component. @@ -147,32 +156,34 @@ def do_fill(parser: Parser, token: Token) -> FillNode: This tag is available only within a {% component %}..{% endcomponent %} block. Runtime checks should prohibit other usages. """ - # e.g. {% fill %} - tag_name, *args = token.split_contents() - slot_name_fexp, slot_default_var_fexp, slot_data_var_fexp = _parse_fill_args(parser, args, tag_name) + bits = token.split_contents() + tag = _parse_tag( + "fill", + parser, + bits, + params=["name"], + keywordonly_kwargs=[SLOT_DATA_ATTR, SLOT_DEFAULT_ATTR], + repeatable_kwargs=False, + end_tag="endfill", + ) + data = _parse_fill_args(parser, tag) - # Use a unique ID to be able to tie the fill nodes with components and slots - # NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering - fill_id = gen_id() - trace_msg("PARSE", "FILL", str(slot_name_fexp), fill_id) - - nodelist = parser.parse(parse_until=["endfill"]) - parser.delete_first_token() + trace_msg("PARSE", "FILL", str(data.slot_name), tag.id) + body = tag.parse_body() fill_node = FillNode( - nodelist, - name_fexp=slot_name_fexp, - slot_default_var_fexp=slot_default_var_fexp, - slot_data_var_fexp=slot_data_var_fexp, - node_id=fill_id, + nodelist=body, + name_fexp=data.slot_name, + slot_default_var_fexp=data.slot_default_var, + slot_data_var_fexp=data.slot_data_var, + node_id=tag.id, ) - trace_msg("PARSE", "FILL", str(slot_name_fexp), fill_id, "...Done!") + trace_msg("PARSE", "FILL", str(data.slot_name), tag.id, "...Done!") return fill_node -@register.tag(name="component") -def do_component(parser: Parser, token: Token) -> ComponentNode: +def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode: """ To give the component access to the template context: {% component "name" positional_arg keyword_arg=value ... %} @@ -185,64 +196,83 @@ def do_component(parser: Parser, token: Token) -> ComponentNode: be either the first positional argument or, if there are no positional arguments, passed as 'name'. """ - bits = token.split_contents() - bits, isolated_context = _check_for_isolated_context_keyword(bits) - component_name, context_args, context_kwargs = _parse_component_with_args(parser, bits, "component") - # Use a unique ID to be able to tie the fill nodes with components and slots - # NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering - component_id = gen_id() - trace_msg("PARSE", "COMP", component_name, component_id) + # Let the TagFormatter pre-process the tokens + formatter = get_tag_formatter() + result = formatter.parse([*bits]) + end_tag = formatter.end_tag(result.component_name) - body: NodeList = parser.parse(parse_until=["endcomponent"]) - parser.delete_first_token() + # NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself + bits = [bits[0], *result.tokens] + + tag = _parse_tag( + tag_name, + parser, + bits, + params=True, # Allow many args + flags=["only"], + keywordonly_kwargs=True, + repeatable_kwargs=False, + end_tag=end_tag, + ) + data = _parse_component_args(parser, tag) + + trace_msg("PARSE", "COMP", result.component_name, tag.id) + + body = tag.parse_body() fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode) # Tag all fill nodes as children of this particular component instance for node in fill_nodes: - trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=component_id) - node.component_id = component_id + trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=tag.id) + node.component_id = tag.id component_node = ComponentNode( - FilterExpression(component_name, parser), - context_args, - context_kwargs, - isolated_context=isolated_context, + name=result.component_name, + context_args=tag.args, + context_kwargs=tag.kwargs, + isolated_context=data.isolated_context, fill_nodes=fill_nodes, - component_id=component_id, + component_id=tag.id, ) - trace_msg("PARSE", "COMP", component_name, component_id, "...Done!") + trace_msg("PARSE", "COMP", result.component_name, tag.id, "...Done!") return component_node @register.tag("provide") -def do_provide(parser: Parser, token: Token) -> SlotNode: +def provide(parser: Parser, token: Token) -> ProvideNode: # e.g. {% provide key=val key2=val2 %} - tag_name, *args = token.split_contents() - provide_key, kwargs = _parse_provide_args(parser, args, tag_name) + bits = token.split_contents() + tag = _parse_tag( + "provide", + parser, + bits, + params=["name"], + flags=[], + keywordonly_kwargs=True, + repeatable_kwargs=False, + end_tag="endprovide", + ) + data = _parse_provide_args(parser, tag) - # Use a unique ID to be able to tie the fill nodes with components and slots - # NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering - slot_id = gen_id() - trace_msg("PARSE", "PROVIDE", provide_key, slot_id) + trace_msg("PARSE", "PROVIDE", data.key, tag.id) - nodelist = parser.parse(parse_until=["endprovide"]) - parser.delete_first_token() + body = tag.parse_body() slot_node = ProvideNode( - provide_key, - nodelist, - node_id=slot_id, - provide_kwargs=kwargs, + name=data.key, + nodelist=body, + node_id=tag.id, + provide_kwargs=tag.kwargs, ) - trace_msg("PARSE", "PROVIDE", provide_key, slot_id, "...Done!") + trace_msg("PARSE", "PROVIDE", data.key, tag.id, "...Done!") return slot_node @register.tag("html_attrs") -def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode: +def html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode: """ This tag takes: - Optional dictionary of attributes (`attrs`) @@ -270,243 +300,315 @@ def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode: ``` """ bits = token.split_contents() - attributes, default_attrs, append_attrs = _parse_html_attrs_args(parser, bits, "html_attrs") - return HtmlAttrsNode(attributes, default_attrs, append_attrs) + + tag = _parse_tag( + "html_attrs", + parser, + bits, + params=["attrs", "defaults"], + optional_params=["attrs", "defaults"], + flags=[], + keywordonly_kwargs=True, + repeatable_kwargs=True, + ) + + return HtmlAttrsNode( + attributes=tag.kwargs.get("attrs"), + defaults=tag.kwargs.get("defaults"), + kwargs=[(key, val) for key, val in tag.kwarg_pairs if key != "attrs" and key != "defaults"], + ) -def _check_for_isolated_context_keyword(bits: List[str]) -> Tuple[List[str], bool]: - """Return True and strip the last word if token ends with 'only' keyword or if CONTEXT_BEHAVIOR is 'isolated'.""" - - if bits[-1] == "only": - return bits[:-1], True - - if app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED: - return bits, True - - return bits, False +class ParsedTag(NamedTuple): + id: str + name: str + bits: List[str] + flags: Dict[str, bool] + args: List[FilterExpression] + named_args: Dict[str, FilterExpression] + kwargs: Dict[str, Expression] + kwarg_pairs: List[Tuple[str, Expression]] + is_inline: bool + parse_body: Callable[[], NodeList] -def _parse_component_with_args( - parser: Parser, bits: List[str], tag_name: str -) -> Tuple[str, List[FilterExpression], Mapping[str, FilterExpression]]: - tag_args, tag_kwarg_pairs = parse_bits( +def _parse_tag( + tag: str, + parser: Parser, + bits: List[str], + params: Union[List[str], bool] = False, + flags: Optional[List[str]] = None, + end_tag: Optional[str] = None, + optional_params: Optional[List[str]] = None, + keywordonly_kwargs: Optional[Union[bool, List[str]]] = False, + repeatable_kwargs: Optional[Union[bool, List[str]]] = False, +) -> ParsedTag: + # Use a unique ID to be able to tie the fill nodes with components and slots + # NOTE: MUST be called BEFORE `parse_body()` to ensure predictable numbering + tag_id = gen_id() + + params = params or [] + + # e.g. {% slot ... %} + tag_name, *bits = bits + if tag_name != tag: + raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag}'") + + # Decide if the template tag is inline or block and strip the trailing slash + last_token = bits[-1] if len(bits) else None + if last_token == "/": + bits.pop() + is_inline = True + else: + # If no end tag was given, we assume that the tag is inline-only + is_inline = not end_tag + + parsed_flags = {flag: False for flag in (flags or [])} + bits_without_flags: List[str] = [] + seen_kwargs: Set[str] = set() + seen_agg_keys: Set[str] = set() + + def mark_kwarg_key(key: str, is_agg_key: bool) -> None: + if (is_agg_key and key in seen_kwargs) or (not is_agg_key and key in seen_agg_keys): + raise TemplateSyntaxError( + f"Received argument '{key}' both as a regular input ({key}=...)" + f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two" + ) + if is_agg_key: + seen_agg_keys.add(key) + else: + seen_kwargs.add(key) + + for bit in bits: + # Extract flags, which are like keywords but without the value part + if bit in parsed_flags: + parsed_flags[bit] = True + continue + else: + bits_without_flags.append(bit) + + # Record which kwargs we've seen, to detect if kwargs were passed in + # as both aggregate and regular kwargs + if "=" in bit: + key = bit.split("=")[0] + + # Also pick up on aggregate keys like `attr:key=val` + if is_aggregate_key(key): + key = key.split(":")[0] + mark_kwarg_key(key, True) + else: + mark_kwarg_key(key, False) + + bits = bits_without_flags + + # To support optional args, we need to convert these to kwargs, so `parse_bits` + # can handle them. So we assign the keys to matched positional args, + # and then move the kwarg AFTER the pos args. + # + # TODO: This following section should live in `parse_bits`, but I don't want to + # modify it much to maintain some sort of compatibility with Django's version of + # `parse_bits`. + # Ideally, Django's parser would be expanded to support our use cases. + if params != True: # noqa F712 + params_to_sort = [param for param in params if param not in seen_kwargs] + new_args = [] + new_params = [] + new_kwargs = [] + for index, bit in enumerate(bits): + if "=" in bit or not len(params_to_sort): + # Pass all remaining bits (including current one) as kwargs + new_kwargs.extend(bits[index:]) + break + + param = params_to_sort.pop(0) + if optional_params and param in optional_params: + mark_kwarg_key(param, False) + new_kwargs.append(f"{param}={bit}") + continue + new_args.append(bit) + new_params.append(param) + + bits = [*new_args, *new_kwargs] + params = [*new_params, *params_to_sort] + + # Remove any remaining optional positional args if they were not given + if optional_params: + params = [param for param in params_to_sort if param not in optional_params] + + # Parse args/kwargs that will be passed to the fill + args, raw_kwarg_pairs = parse_bits( parser=parser, bits=bits, - params=["tag_name", "name"], + params=[] if isinstance(params, bool) else params, name=tag_name, ) - tag_kwargs = {} - for key, val in tag_kwarg_pairs: - if key in tag_kwargs: - # The keyword argument has already been supplied once - raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'") - tag_kwargs[key] = val + # Post-process args/kwargs - Mark special cases like aggregate dicts + # or dynamic expressions + pre_aggregate_kwargs: Dict[str, FilterExpression] = {} + kwarg_pairs: List[Tuple[str, Expression]] = [] + for key, val in raw_kwarg_pairs: + # NOTE: If a tag allows mutliple kwargs, and we provide a same aggregate key + # multiple times (e.g. `attr:class="hidden" and `attr:class="another"`), then + # we take only the last instance. + if is_aggregate_key(key): + pre_aggregate_kwargs[key] = val + else: + kwarg_pairs.append((key, val)) + aggregate_kwargs: Dict[str, Dict[str, FilterExpression]] = process_aggregate_kwargs(pre_aggregate_kwargs) - if tag_name != tag_args[0].token: - raise RuntimeError(f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}") + for key, agg_dict in aggregate_kwargs.items(): + entry = (key, AggregateFilterExpression(agg_dict)) + kwarg_pairs.append(entry) - component_name = _get_positional_param(tag_name, "name", 1, tag_args, tag_kwargs).token - if len(tag_args) > 1: - # Positional args given. Skip tag and component name and take the rest - context_args = tag_args[2:] - else: # No positional args - context_args = [] + # Allow only as many positional args as given + if params != True and len(args) > len(params): # noqa F712 + raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}") - return component_name, context_args, tag_kwargs + # For convenience, allow to access named args by their name instead of index + if params != True: # noqa F712 + named_args = {param: args[index] for index, param in enumerate(params)} + else: + named_args = {} + # Validate kwargs + kwargs: Dict[str, Expression] = {} + extra_keywords: Set[str] = set() + for key, val in kwarg_pairs: + # Check if key allowed + if not keywordonly_kwargs: + is_key_allowed = False + else: + is_key_allowed = keywordonly_kwargs == True or key in keywordonly_kwargs # noqa: E712 + if not is_key_allowed: + extra_keywords.add(key) -def _parse_html_attrs_args( - parser: Parser, bits: List[str], tag_name: str -) -> Tuple[Optional[FilterExpression], Optional[FilterExpression], List[Tuple[str, FilterExpression]]]: - tag_args, tag_kwarg_pairs = parse_bits( - parser=parser, - bits=bits, - params=["tag_name"], + # Check for repeated keys + if key in kwargs: + if not repeatable_kwargs: + is_key_repeatable = False + else: + is_key_repeatable = repeatable_kwargs == True or key in repeatable_kwargs # noqa: E712 + if not is_key_repeatable: + # The keyword argument has already been supplied once + raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'") + # All ok + kwargs[key] = val + + if len(extra_keywords): + extra_keys = ", ".join(extra_keywords) + raise TemplateSyntaxError(f"'{tag_name}' received unexpected kwargs: {extra_keys}") + + return ParsedTag( + id=tag_id, name=tag_name, + bits=bits, + flags=parsed_flags, + args=args, + named_args=named_args, + kwargs=kwargs, + kwarg_pairs=kwarg_pairs, + # 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, end_tag, is_inline) if end_tag else NodeList(), + is_inline=is_inline, ) - # NOTE: Unlike in the `component` tag, in this case we don't care about duplicates, - # as we're constructing the dict simply to find the `attrs` kwarg. - tag_kwargs = {key: val for key, val in tag_kwarg_pairs} - if tag_name != tag_args[0].token: - raise RuntimeError(f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}") +def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList: + if inline: + body = NodeList() + else: + body = parser.parse(parse_until=[end_tag]) + parser.delete_first_token() + return body - # Allow to optioanlly provide `attrs` as positional arg `{% html_attrs attrs %}` - try: - attrs = _get_positional_param(tag_name, "attrs", 1, tag_args, tag_kwargs) - except TemplateSyntaxError: - attrs = None - # Allow to optionally provide `defaults` as positional arg `{% html_attrs attrs defaults %}` - try: - defaults = _get_positional_param(tag_name, "defaults", 2, tag_args, tag_kwargs) - except TemplateSyntaxError: - defaults = None +class ParsedComponentTag(NamedTuple): + isolated_context: bool - # Allow only up to 2 positional args - [0] == tag name, [1] == attrs, [2] == defaults - if len(tag_args) > 3: - raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {tag_args[2:]}") - return attrs, defaults, tag_kwarg_pairs +def _parse_component_args( + parser: Parser, + tag: ParsedTag, +) -> ParsedComponentTag: + # Check for isolated context keyword + isolated_context = tag.flags["only"] or app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED + + return ParsedComponentTag(isolated_context=isolated_context) + + +class ParsedSlotTag(NamedTuple): + name: str def _parse_slot_args( parser: Parser, - bits: List[str], - tag_name: str, -) -> Tuple[str, bool, bool, Dict[str, FilterExpression]]: - if not len(bits): - raise TemplateSyntaxError( - "'slot' tag does not match pattern " - "{% slot ['default'] ['required'] [key=val, ...] %}. " - "Order of options is free." - ) - - slot_name, *options = bits - if not is_wrapped_in_quotes(slot_name): - raise TemplateSyntaxError(f"'{tag_name}' name must be a string 'literal'.") + tag: ParsedTag, +) -> ParsedSlotTag: + slot_name = tag.named_args["name"].token + if not is_str_wrapped_in_quotes(slot_name): + raise TemplateSyntaxError(f"'{tag.name}' name must be a string 'literal', got {slot_name}.") slot_name = resolve_string(slot_name, parser) - # Parse flags - Since `parse_bits` doesn't handle "shorthand" kwargs - # (AKA `required` for `required=True`), we have to first get the flags out - # of the way. - def extract_value(lst: List[str], value: str) -> bool: - """Check if value exists in list, and if so, remove it from said list""" - try: - lst.remove(value) - return True - except ValueError: - return False + return ParsedSlotTag(name=slot_name) - is_default = extract_value(options, SLOT_DEFAULT_OPTION_KEYWORD) - is_required = extract_value(options, SLOT_REQUIRED_OPTION_KEYWORD) - # Parse kwargs that will be passed to the fill - _, tag_kwarg_pairs = parse_bits( - parser=parser, - bits=options, - params=[], - name=tag_name, - ) - tag_kwargs: Dict[str, FilterExpression] = {} - for key, val in tag_kwarg_pairs: - if key in tag_kwargs: - # The keyword argument has already been supplied once - raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'") - tag_kwargs[key] = val - - return slot_name, is_default, is_required, tag_kwargs +class ParsedFillTag(NamedTuple): + slot_name: FilterExpression + slot_default_var: Optional[FilterExpression] + slot_data_var: Optional[FilterExpression] def _parse_fill_args( parser: Parser, - bits: List[str], - tag_name: str, -) -> Tuple[FilterExpression, Optional[FilterExpression], Optional[FilterExpression]]: - if not len(bits): - raise TemplateSyntaxError( - "'fill' tag does not match pattern " - f"{{% fill [{SLOT_DATA_ATTR}=val] [{SLOT_DEFAULT_ATTR}=slot_var] %}} " - ) - - slot_name = bits.pop(0) - slot_name_fexp = parser.compile_filter(slot_name) - - # Even tho we want to parse only single kwarg, we use the same logic for parsing - # as we use for other tags, for consistency. - _, tag_kwarg_pairs = parse_bits( - parser=parser, - bits=bits, - params=[], - name=tag_name, - ) - - tag_kwargs: Dict[str, FilterExpression] = {} - for key, val in tag_kwarg_pairs: - if key in tag_kwargs: - raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'") - tag_kwargs[key] = val + tag: ParsedTag, +) -> ParsedFillTag: + slot_name_fexp = tag.named_args["name"] # Extract known kwargs - slot_data_var_fexp: Optional[FilterExpression] = None - if SLOT_DATA_ATTR in tag_kwargs: - slot_data_var_fexp = tag_kwargs.pop(SLOT_DATA_ATTR) - if not is_wrapped_in_quotes(slot_data_var_fexp.token): - raise TemplateSyntaxError( - f"Value of '{SLOT_DATA_ATTR}' in '{tag_name}' tag must be a string literal, got '{slot_data_var_fexp}'" - ) + slot_data_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DATA_ATTR) + if slot_data_var_fexp and not is_str_wrapped_in_quotes(slot_data_var_fexp.token): + raise TemplateSyntaxError( + f"Value of '{SLOT_DATA_ATTR}' in '{tag.name}' tag must be a string literal, got '{slot_data_var_fexp}'" + ) - slot_default_var_fexp: Optional[FilterExpression] = None - if SLOT_DEFAULT_ATTR in tag_kwargs: - slot_default_var_fexp = tag_kwargs.pop(SLOT_DEFAULT_ATTR) - if not is_wrapped_in_quotes(slot_default_var_fexp.token): - raise TemplateSyntaxError( - f"Value of '{SLOT_DEFAULT_ATTR}' in '{tag_name}' tag must be a string literal," - f" got '{slot_default_var_fexp}'" - ) + slot_default_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DEFAULT_ATTR) + if slot_default_var_fexp and not is_str_wrapped_in_quotes(slot_default_var_fexp.token): + raise TemplateSyntaxError( + f"Value of '{SLOT_DEFAULT_ATTR}' in '{tag.name}' tag must be a string literal," + f" got '{slot_default_var_fexp}'" + ) # data and default cannot be bound to the same variable if slot_data_var_fexp and slot_default_var_fexp and slot_data_var_fexp.token == slot_default_var_fexp.token: raise TemplateSyntaxError( - f"'{tag_name}' received the same string for slot default ({SLOT_DEFAULT_ATTR}=...)" + f"'{tag.name}' received the same string for slot default ({SLOT_DEFAULT_ATTR}=...)" f" and slot data ({SLOT_DATA_ATTR}=...)" ) - if len(tag_kwargs): - extra_keywords = tag_kwargs.keys() - extra_keys = ", ".join(extra_keywords) - raise TemplateSyntaxError(f"'{tag_name}' received unexpected kwargs: {extra_keys}") + return ParsedFillTag( + slot_name=slot_name_fexp, + slot_default_var=slot_default_var_fexp, + slot_data_var=slot_data_var_fexp, + ) - return slot_name_fexp, slot_default_var_fexp, slot_data_var_fexp + +class ParsedProvideTag(NamedTuple): + key: str def _parse_provide_args( parser: Parser, - bits: List[str], - tag_name: str, -) -> Tuple[str, Dict[str, FilterExpression]]: - if not len(bits): - raise TemplateSyntaxError("'provide' tag does not match pattern {% provide [key=val, ...] %}. ") - - provide_key, *options = bits - if not is_wrapped_in_quotes(provide_key): - raise TemplateSyntaxError(f"'{tag_name}' key must be a string 'literal'.") + tag: ParsedTag, +) -> ParsedProvideTag: + provide_key = tag.named_args["name"].token + if not is_str_wrapped_in_quotes(provide_key): + raise TemplateSyntaxError(f"'{tag.name}' key must be a string 'literal'.") provide_key = resolve_string(provide_key, parser) - # Parse kwargs that will be 'provided' under the given key - _, tag_kwarg_pairs = parse_bits(parser=parser, bits=options, params=[], name=tag_name) - tag_kwargs: Dict[str, FilterExpression] = {} - for key, val in tag_kwarg_pairs: - if key in tag_kwargs: - # The keyword argument has already been supplied once - raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'") - tag_kwargs[key] = val - - return provide_key, tag_kwargs - - -def _get_positional_param( - tag_name: str, - param_name: str, - param_index: int, - args: List[FilterExpression], - kwargs: Dict[str, FilterExpression], -) -> FilterExpression: - # Param is given as positional arg, e.g. `{% tag param %}` - if len(args) > param_index: - param = args[param_index] - return param - # Check if param was given as kwarg, e.g. `{% tag param_name=param %}` - elif param_name in kwargs: - param = kwargs.pop(param_name) - return param - - raise TemplateSyntaxError(f"Param '{param_name}' not found in '{tag_name}' tag") - - -def is_wrapped_in_quotes(s: str) -> bool: - return s.startswith(('"', "'")) and s[0] == s[-1] + return ParsedProvideTag(key=provide_key) diff --git a/src/django_components/utils.py b/src/django_components/utils.py index 38b3a726..e77e9b28 100644 --- a/src/django_components/utils.py +++ b/src/django_components/utils.py @@ -19,3 +19,7 @@ def find_last_index(lst: List, predicate: Callable[[Any], bool]) -> Any: if predicate(elem): return len(lst) - 1 - r_idx return -1 + + +def is_str_wrapped_in_quotes(s: str) -> bool: + return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2 diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 51187a6f..9a9eb919 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -128,7 +128,7 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) with self.assertRaisesMessage( - TemplateSyntaxError, "Tag 'html_attrs' received unexpected positional arguments" + TemplateSyntaxError, "'html_attrs' received some positional argument(s) after some keyword" ): template.render(Context({"class_var": "padding-top-8"})) diff --git a/tests/test_context.py b/tests/test_context.py index 4bc9c443..2c69a974 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -5,7 +5,7 @@ from django_components import Component, registry, types from .django_test_setup import setup_test_config from .testutils import BaseTestCase, parametrize_context_behavior -setup_test_config() +setup_test_config({"autodiscover": False}) ######################### diff --git a/tests/test_registry.py b/tests/test_registry.py index e7ecd3cc..ce2fd7a3 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -1,12 +1,21 @@ import unittest from django.template import Library +from django.test import override_settings -from django_components import AlreadyRegistered, Component, ComponentRegistry, NotRegistered, register, registry +from django_components import ( + AlreadyRegistered, + Component, + ComponentRegistry, + NotRegistered, + TagProtectedError, + register, + registry, +) from .django_test_setup import setup_test_config -setup_test_config() +setup_test_config({"autodiscover": False}) class MockComponent(Component): @@ -69,7 +78,7 @@ class ComponentRegistryTest(unittest.TestCase): def test_unregisters_only_unused_tags(self): self.assertDictEqual(self.registry._tags, {}) # NOTE: We preserve the default component tags - self.assertIn("component", self.registry.library.tags) + self.assertNotIn("component", self.registry.library.tags) # Register two components that use the same tag self.registry.register(name="testcomponent", component=MockComponent) @@ -78,7 +87,6 @@ class ComponentRegistryTest(unittest.TestCase): self.assertDictEqual( self.registry._tags, { - "#component": {"testcomponent", "testcomponent2"}, "component": {"testcomponent", "testcomponent2"}, }, ) @@ -91,7 +99,6 @@ class ComponentRegistryTest(unittest.TestCase): self.assertDictEqual( self.registry._tags, { - "#component": {"testcomponent2"}, "component": {"testcomponent2"}, }, ) @@ -102,7 +109,7 @@ class ComponentRegistryTest(unittest.TestCase): self.registry.unregister(name="testcomponent2") self.assertDictEqual(self.registry._tags, {}) - self.assertIn("component", self.registry.library.tags) + self.assertNotIn("component", self.registry.library.tags) def test_prevent_registering_different_components_with_the_same_name(self): self.registry.register(name="testcomponent", component=MockComponent) @@ -124,3 +131,35 @@ class ComponentRegistryTest(unittest.TestCase): def test_raises_on_failed_unregister(self): with self.assertRaises(NotRegistered): self.registry.unregister(name="testcomponent") + + +class ProtectedTagsTest(unittest.TestCase): + def setUp(self): + super().setUp() + self.registry = ComponentRegistry() + + # NOTE: Use the `component_shorthand_formatter` formatter, so the components + # are registered under that tag + @override_settings(COMPONENTS={"tag_formatter": "django_components.component_shorthand_formatter"}) + def test_raises_on_overriding_our_tags(self): + for tag in [ + "component_dependencies", + "component_css_dependencies", + "component_js_dependencies", + "fill", + "html_attrs", + "provide", + "slot", + ]: + with self.assertRaises(TagProtectedError): + + @register(tag) + class TestComponent(Component): + pass + + @register("sth_else") + class TestComponent2(Component): + pass + + # Cleanup + registry.unregister("sth_else") diff --git a/tests/test_tag_formatter.py b/tests/test_tag_formatter.py new file mode 100644 index 00000000..96c8ef78 --- /dev/null +++ b/tests/test_tag_formatter.py @@ -0,0 +1,402 @@ +from django.template import Context, Template + +from django_components import Component, register, types +from django_components.tag_formatter import ShorthandComponentFormatter + +from .django_test_setup import setup_test_config +from .testutils import BaseTestCase, parametrize_context_behavior + +setup_test_config({"autodiscover": False}) + + +class MultiwordStartTagFormatter(ShorthandComponentFormatter): + def start_tag(self, name): + return f"{name} comp" + + +class MultiwordBlockEndTagFormatter(ShorthandComponentFormatter): + def end_tag(self, name): + return f"end {name}" + + +# Create a TagFormatter class to validate the public interface +def create_validator_tag_formatter(tag_name: str): + class ValidatorTagFormatter(ShorthandComponentFormatter): + def start_tag(self, name): + assert name == tag_name + return super().start_tag(name) + + def end_tag(self, name): + assert name == tag_name + return super().end_tag(name) + + def parse(self, tokens): + assert isinstance(tokens, list) + assert tokens[0] == tag_name + return super().parse(tokens) + + return ValidatorTagFormatter() + + +class ComponentTagTests(BaseTestCase): + @parametrize_context_behavior(["django", "isolated"]) + def test_formatter_default_inline(self): + @register("simple") + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + hello1 +
+ {% slot "content" default %} SLOT_DEFAULT {% endslot %} +
+ hello2 + """ + + template = Template( + """ + {% load component_tags %} + {% component "simple" / %} + """ + ) + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + hello1 +
+ SLOT_DEFAULT +
+ hello2 + """, + ) + + @parametrize_context_behavior(["django", "isolated"]) + def test_formatter_default_block(self): + @register("simple") + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + hello1 +
+ {% slot "content" default %} SLOT_DEFAULT {% endslot %} +
+ hello2 + """ + + template = Template( + """ + {% load component_tags %} + {% component "simple" %} + OVERRIDEN! + {% endcomponent %} + """ + ) + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + hello1 +
+ OVERRIDEN! +
+ hello2 + """, + ) + + @parametrize_context_behavior( + cases=["django", "isolated"], + settings={ + "COMPONENTS": { + "tag_formatter": "django_components.component_formatter", + }, + }, + ) + def test_formatter_component_inline(self): + @register("simple") + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + hello1 +
+ {% slot "content" default %} SLOT_DEFAULT {% endslot %} +
+ hello2 + """ + + template = Template( + """ + {% load component_tags %} + {% component "simple" / %} + """ + ) + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + hello1 +
+ SLOT_DEFAULT +
+ hello2 + """, + ) + + @parametrize_context_behavior( + cases=["django", "isolated"], + settings={ + "COMPONENTS": { + "tag_formatter": "django_components.component_formatter", + }, + }, + ) + def test_formatter_component_block(self): + @register("simple") + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + hello1 +
+ {% slot "content" default %} SLOT_DEFAULT {% endslot %} +
+ hello2 + """ + + template = Template( + """ + {% load component_tags %} + {% component "simple" %} + OVERRIDEN! + {% endcomponent %} + """ + ) + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + hello1 +
+ OVERRIDEN! +
+ hello2 + """, + ) + + @parametrize_context_behavior( + cases=["django", "isolated"], + settings={ + "COMPONENTS": { + "tag_formatter": "django_components.component_shorthand_formatter", + }, + }, + ) + def test_formatter_shorthand_inline(self): + @register("simple") + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + hello1 +
+ {% slot "content" default %} SLOT_DEFAULT {% endslot %} +
+ hello2 + """ + + template = Template( + """ + {% load component_tags %} + {% simple / %} + """ + ) + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + hello1 +
+ SLOT_DEFAULT +
+ hello2 + """, + ) + + @parametrize_context_behavior( + cases=["django", "isolated"], + settings={ + "COMPONENTS": { + "tag_formatter": "django_components.component_shorthand_formatter", + }, + }, + ) + def test_formatter_shorthand_block(self): + @register("simple") + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + hello1 +
+ {% slot "content" default %} SLOT_DEFAULT {% endslot %} +
+ hello2 + """ + + template = Template( + """ + {% load component_tags %} + {% simple %} + OVERRIDEN! + {% endsimple %} + """ + ) + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + hello1 +
+ OVERRIDEN! +
+ hello2 + """, + ) + + @parametrize_context_behavior( + cases=["django", "isolated"], + settings={ + "COMPONENTS": { + "tag_formatter": ShorthandComponentFormatter(), + }, + }, + ) + def test_import_formatter_by_value(self): + @register("simple") + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} +
+ {% slot "content" default %} SLOT_DEFAULT {% endslot %} +
+ """ + + template = Template( + """ + {% load component_tags %} + {% simple %} + OVERRIDEN! + {% endsimple %} + """ + ) + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ +
+ OVERRIDEN! +
+ """, + ) + + @parametrize_context_behavior( + cases=["django", "isolated"], + settings={ + "COMPONENTS": { + "tag_formatter": MultiwordStartTagFormatter(), + }, + }, + ) + def test_raises_on_invalid_start_tag(self): + with self.assertRaisesMessage( + ValueError, "MultiwordStartTagFormatter returned an invalid tag for start_tag: 'simple comp'" + ): + + @register("simple") + class SimpleComponent(Component): + template = """{% load component_tags %}""" + + @parametrize_context_behavior( + cases=["django", "isolated"], + settings={ + "COMPONENTS": { + "tag_formatter": MultiwordBlockEndTagFormatter(), + }, + }, + ) + def test_raises_on_invalid_block_end_tag(self): + with self.assertRaisesMessage( + ValueError, "MultiwordBlockEndTagFormatter returned an invalid tag for end_tag: 'end simple'" + ): + + @register("simple") + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} +
+ {% slot "content" default %} SLOT_DEFAULT {% endslot %} +
+ """ + + Template( + """ + {% load component_tags %} + {% simple %} + OVERRIDEN! + {% bar %} + """ + ) + + @parametrize_context_behavior( + cases=["django", "isolated"], + settings={ + "COMPONENTS": { + "tag_formatter": create_validator_tag_formatter("simple"), + }, + }, + ) + def test_method_args(self): + @register("simple") + class SimpleComponent(Component): + template: types.django_html = """ + {% load component_tags %} + hello1 +
+ {% slot "content" default %} SLOT_DEFAULT {% endslot %} +
+ hello2 + """ + + template = Template( + """ + {% load component_tags %} + {% simple / %} + """ + ) + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + hello1 +
+ SLOT_DEFAULT +
+ hello2 + """, + ) + + template = Template( + """ + {% load component_tags %} + {% simple %} + OVERRIDEN! + {% endsimple %} + """ + ) + rendered = template.render(Context()) + self.assertHTMLEqual( + rendered, + """ + hello1 +
+ OVERRIDEN! +
+ hello2 + """, + ) diff --git a/tests/test_template_parser.py b/tests/test_template_parser.py index b804ab71..ac680f41 100644 --- a/tests/test_template_parser.py +++ b/tests/test_template_parser.py @@ -3,50 +3,49 @@ from django.template.base import Parser from django_components import Component, registry, types from django_components.component import safe_resolve_dict, safe_resolve_list -from django_components.template_parser import process_aggregate_kwargs -from django_components.templatetags.component_tags import _parse_component_with_args +from django_components.template_parser import is_aggregate_key, process_aggregate_kwargs +from django_components.templatetags.component_tags import _parse_tag from .django_test_setup import setup_test_config from .testutils import BaseTestCase, parametrize_context_behavior -setup_test_config() +setup_test_config({"autodiscover": False}) class ParserTest(BaseTestCase): def test_parses_args_kwargs(self): - bits = ["component", "my_component", "42", "myvar", "key='val'", "key2=val2"] - name, raw_args, raw_kwargs = _parse_component_with_args(Parser(""), bits, "component") + bits = ["component", "42", "myvar", "key='val'", "key2=val2"] + tag = _parse_tag("component", Parser(""), bits, params=["num", "var"], keywordonly_kwargs=True) ctx = {"myvar": {"a": "b"}, "val2": 1} - args = safe_resolve_list(raw_args, ctx) - kwargs = safe_resolve_dict(raw_kwargs, ctx) + args = safe_resolve_list(tag.args, ctx) + named_args = safe_resolve_dict(tag.named_args, ctx) + kwargs = safe_resolve_dict(tag.kwargs, ctx) - self.assertEqual(name, "my_component") self.assertListEqual(args, [42, {"a": "b"}]) + self.assertDictEqual(named_args, {"num": 42, "var": {"a": "b"}}) self.assertDictEqual(kwargs, {"key": "val", "key2": 1}) def test_parses_special_kwargs(self): bits = [ "component", - "my_component", "date=date", "@lol=2", "na-me=bzz", "@event:na-me.mod=bzz", "#my-id=True", ] - name, raw_args, raw_kwargs = _parse_component_with_args(Parser(""), bits, "component") + tag = _parse_tag("component", Parser(""), bits, keywordonly_kwargs=True) ctx = Context({"date": 2024, "bzz": "fzz"}) - args = safe_resolve_list(raw_args, ctx) - kwargs = safe_resolve_dict(raw_kwargs, ctx) + args = safe_resolve_list(tag.args, ctx) + kwargs = safe_resolve_dict(tag.kwargs, ctx) - self.assertEqual(name, "my_component") self.assertListEqual(args, []) self.assertDictEqual( kwargs, { - "@event:na-me.mod": "fzz", + "@event": {"na-me.mod": "fzz"}, "@lol": 2, "date": 2024, "na-me": "fzz", @@ -117,3 +116,15 @@ class AggregateKwargsTest(BaseTestCase): ":placeholder": "No text", }, ) + + def is_aggregate_key(self): + self.assertEqual(is_aggregate_key(""), False) + self.assertEqual(is_aggregate_key(" "), False) + self.assertEqual(is_aggregate_key(" : "), False) + self.assertEqual(is_aggregate_key("attrs"), False) + self.assertEqual(is_aggregate_key(":attrs"), False) + self.assertEqual(is_aggregate_key(" :attrs "), False) + self.assertEqual(is_aggregate_key("attrs:"), False) + self.assertEqual(is_aggregate_key(":attrs:"), False) + self.assertEqual(is_aggregate_key("at:trs"), True) + self.assertEqual(is_aggregate_key(":at:trs"), False) diff --git a/tests/test_templatetags_component.py b/tests/test_templatetags_component.py index 8acb1ebe..e1c3419d 100644 --- a/tests/test_templatetags_component.py +++ b/tests/test_templatetags_component.py @@ -35,7 +35,9 @@ class SlottedComponentWithContext(Component): class ComponentTemplateTagTest(BaseTestCase): class SimpleComponent(Component): - template_name = "simple_template.html" + template: types.django_html = """ + Variable: {{ variable }} + """ def get_context_data(self, variable, variable2="default"): return { @@ -61,7 +63,20 @@ class ComponentTemplateTagTest(BaseTestCase): self.assertHTMLEqual(rendered, "Variable: variable\n") @parametrize_context_behavior(["django", "isolated"]) - def test_call_with_invalid_name(self): + def test_single_component_self_closing(self): + registry.register(name="test", component=self.SimpleComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% component name="test" variable="variable" /%} + """ + + template = Template(simple_tag_template) + rendered = template.render(Context({})) + self.assertHTMLEqual(rendered, "Variable: variable\n") + + @parametrize_context_behavior(["django", "isolated"]) + def test_raises_on_no_registered_components(self): # Note: No tag registered simple_tag_template: types.django_html = """ @@ -69,6 +84,18 @@ class ComponentTemplateTagTest(BaseTestCase): {% component name="test" variable="variable" %}{% endcomponent %} """ + with self.assertRaisesMessage(TemplateSyntaxError, "Invalid block tag on line 3: 'component'"): + Template(simple_tag_template) + + @parametrize_context_behavior(["django", "isolated"]) + def test_call_with_invalid_name(self): + registry.register(name="test_one", component=self.SimpleComponent) + + simple_tag_template: types.django_html = """ + {% load component_tags %} + {% component name="test" variable="variable" %}{% endcomponent %} + """ + template = Template(simple_tag_template) with self.assertRaises(NotRegistered): template.render(Context({})) @@ -131,7 +158,7 @@ class ComponentTemplateTagTest(BaseTestCase): self.assertHTMLEqual(rendered, "Variable: variable\n") @parametrize_context_behavior(["django", "isolated"]) - def test_component_called_with_variable_as_name(self): + def test_raises_on_component_called_with_variable_as_name(self): registry.register(name="test", component=self.SimpleComponent) simple_tag_template: types.django_html = """ @@ -141,24 +168,11 @@ class ComponentTemplateTagTest(BaseTestCase): {% endwith %} """ - template = Template(simple_tag_template) - rendered = template.render(Context({})) - self.assertHTMLEqual(rendered, "Variable: variable\n") - - @parametrize_context_behavior(["django", "isolated"]) - def test_component_called_with_invalid_variable_as_name(self): - registry.register(name="test", component=self.SimpleComponent) - - simple_tag_template: types.django_html = """ - {% load component_tags %} - {% with component_name="BLAHONGA" %} - {% component component_name variable="variable" %}{% endcomponent %} - {% endwith %} - """ - - template = Template(simple_tag_template) - with self.assertRaises(NotRegistered): - template.render(Context({})) + with self.assertRaisesMessage( + TemplateSyntaxError, + "Component name must be a string 'literal', got: component_name", + ): + Template(simple_tag_template) @parametrize_context_behavior(["django", "isolated"]) def test_component_accepts_provided_and_default_parameters(self): diff --git a/tests/test_templatetags_provide.py b/tests/test_templatetags_provide.py index 5ea61651..b08e8923 100644 --- a/tests/test_templatetags_provide.py +++ b/tests/test_templatetags_provide.py @@ -5,7 +5,7 @@ from django_components import Component, register, types from .django_test_setup import setup_test_config from .testutils import BaseTestCase, parametrize_context_behavior -setup_test_config() +setup_test_config({"autodiscover": False}) class ProvideTemplateTagTest(BaseTestCase): @@ -38,6 +38,24 @@ class ProvideTemplateTagTest(BaseTestCase): """, ) + @parametrize_context_behavior(["django", "isolated"]) + def test_provide_basic_self_closing(self): + template_str: types.django_html = """ + {% load component_tags %} +
+ {% provide "my_provide" key="hi" another=123 / %} +
+ """ + template = Template(template_str) + rendered = template.render(Context({})) + + self.assertHTMLEqual( + rendered, + """ +
+ """, + ) + @parametrize_context_behavior(["django", "isolated"]) def test_provide_access_keys_in_python(self): @register("injectee") diff --git a/tests/test_templatetags_slot_fill.py b/tests/test_templatetags_slot_fill.py index a38b8608..f3df47d8 100644 --- a/tests/test_templatetags_slot_fill.py +++ b/tests/test_templatetags_slot_fill.py @@ -7,7 +7,7 @@ from django_components import Component, register, registry, types from .django_test_setup import setup_test_config from .testutils import BaseTestCase, parametrize_context_behavior -setup_test_config() +setup_test_config({"autodiscover": False}) class SlottedComponent(Component): @@ -75,6 +75,56 @@ class ComponentSlottedTemplateTagTest(BaseTestCase): """, ) + @parametrize_context_behavior(["django", "isolated"]) + def test_slotted_template_basic_self_closing(self): + @register("test1") + class SlottedComponent(Component): + template: types.django_html = """ + {% load component_tags %} + +
{% slot "header" / %}
+
{% slot "main" %}Default main{% endslot %}
+
{% slot "footer" / %}
+
+ """ + + registry.register(name="test1", component=SlottedComponent) + + @register("test2") + class SimpleComponent(Component): + template = """Variable: {{ variable }}""" + + def get_context_data(self, variable, variable2="default"): + return { + "variable": variable, + "variable2": variable2, + } + + template_str: types.django_html = """ + {% load component_tags %} + {% component "test1" %} + {% fill "header" %} + {% component "test2" variable="variable" / %} + {% endfill %} + {% fill "main" / %} + {% fill "footer" / %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context({})) + + # NOTE:
is empty, because the fill is provided, even if empty + self.assertHTMLEqual( + rendered, + """ + +
Variable: variable
+
+ +
+ """, + ) + # NOTE: Second arg is the expected output of `{{ variable }}` @parametrize_context_behavior([("django", "test456"), ("isolated", "")]) def test_slotted_template_with_context_var(self, context_behavior_data): diff --git a/tests/test_templatetags_templating.py b/tests/test_templatetags_templating.py index 9d56c92a..165fb79c 100644 --- a/tests/test_templatetags_templating.py +++ b/tests/test_templatetags_templating.py @@ -9,7 +9,7 @@ from django_components import Component, registry, types from .django_test_setup import setup_test_config from .testutils import BaseTestCase, parametrize_context_behavior -setup_test_config() +setup_test_config({"autodiscover": False}) class SlottedComponent(Component): diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..efa147bf --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,20 @@ +from django_components.utils import is_str_wrapped_in_quotes + +from .django_test_setup import setup_test_config +from .testutils import BaseTestCase + +setup_test_config({"autodiscover": False}) + + +class UtilsTest(BaseTestCase): + def test_is_str_wrapped_in_quotes(self): + self.assertEqual(is_str_wrapped_in_quotes("word"), False) + self.assertEqual(is_str_wrapped_in_quotes('word"'), False) + self.assertEqual(is_str_wrapped_in_quotes('"word'), False) + self.assertEqual(is_str_wrapped_in_quotes('"word"'), True) + self.assertEqual(is_str_wrapped_in_quotes("\"word'"), False) + self.assertEqual(is_str_wrapped_in_quotes('"word" '), False) + self.assertEqual(is_str_wrapped_in_quotes('"'), False) + self.assertEqual(is_str_wrapped_in_quotes(""), False) + self.assertEqual(is_str_wrapped_in_quotes('""'), True) + self.assertEqual(is_str_wrapped_in_quotes("\"'"), False)