mirror of
https://github.com/django-components/django-components.git
synced 2025-08-10 09:17:59 +00:00
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>
This commit is contained in:
parent
b89c09aa5f
commit
71d8679e8d
23 changed files with 1593 additions and 474 deletions
238
README.md
238
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
|
|||
<html>
|
||||
```
|
||||
|
||||
> 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 = """
|
||||
<div class="calendar-component">
|
||||
<div class="header">
|
||||
{% slot "header" %}{% endslot %}
|
||||
{% slot "header" / %}
|
||||
</div>
|
||||
<div class="body">
|
||||
Today's date is <span>{{ date }}</span>
|
||||
|
@ -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 %}
|
||||
<div class="subtitle">
|
||||
{% slot "subtitle" %}{% endslot %}
|
||||
{% slot "subtitle" / %}
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
@ -1266,8 +1299,7 @@ so are still valid:
|
|||
|
||||
```django
|
||||
<body>
|
||||
{% 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 / %}
|
||||
</body>
|
||||
```
|
||||
|
||||
|
@ -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<component_name>`
|
||||
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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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 "<ComponentNode: {}. Contents: {!r}>".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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(":")
|
||||
|
|
60
src/django_components/library.py
Normal file
60
src/django_components/library.py
Normal file
|
@ -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
|
|
@ -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():
|
||||
|
|
219
src/django_components/tag_formatter.py
Normal file
219
src/django_components/tag_formatter.py
Normal file
|
@ -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 `<name>` / `end<name>` 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()
|
|
@ -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>{constant})|
|
||||
^(?P<var>[{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(":")
|
||||
|
|
|
@ -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 <name> ... %}
|
||||
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 <name> %}
|
||||
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 <name> 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 <name> ... %}
|
||||
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 <name> ['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 <name> [{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> [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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"}))
|
||||
|
||||
|
|
|
@ -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})
|
||||
|
||||
|
||||
#########################
|
||||
|
|
|
@ -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")
|
||||
|
|
402
tests/test_tag_formatter.py
Normal file
402
tests/test_tag_formatter.py
Normal file
|
@ -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
|
||||
<div>
|
||||
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||
</div>
|
||||
hello2
|
||||
"""
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "simple" / %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context())
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
hello1
|
||||
<div>
|
||||
SLOT_DEFAULT
|
||||
</div>
|
||||
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
|
||||
<div>
|
||||
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||
</div>
|
||||
hello2
|
||||
"""
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "simple" %}
|
||||
OVERRIDEN!
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context())
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
hello1
|
||||
<div>
|
||||
OVERRIDEN!
|
||||
</div>
|
||||
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
|
||||
<div>
|
||||
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||
</div>
|
||||
hello2
|
||||
"""
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "simple" / %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context())
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
hello1
|
||||
<div>
|
||||
SLOT_DEFAULT
|
||||
</div>
|
||||
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
|
||||
<div>
|
||||
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||
</div>
|
||||
hello2
|
||||
"""
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% component "simple" %}
|
||||
OVERRIDEN!
|
||||
{% endcomponent %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context())
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
hello1
|
||||
<div>
|
||||
OVERRIDEN!
|
||||
</div>
|
||||
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
|
||||
<div>
|
||||
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||
</div>
|
||||
hello2
|
||||
"""
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% simple / %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context())
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
hello1
|
||||
<div>
|
||||
SLOT_DEFAULT
|
||||
</div>
|
||||
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
|
||||
<div>
|
||||
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||
</div>
|
||||
hello2
|
||||
"""
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% simple %}
|
||||
OVERRIDEN!
|
||||
{% endsimple %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context())
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
hello1
|
||||
<div>
|
||||
OVERRIDEN!
|
||||
</div>
|
||||
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 %}
|
||||
<div>
|
||||
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% simple %}
|
||||
OVERRIDEN!
|
||||
{% endsimple %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context())
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div>
|
||||
OVERRIDEN!
|
||||
</div>
|
||||
""",
|
||||
)
|
||||
|
||||
@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 %}
|
||||
<div>
|
||||
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
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
|
||||
<div>
|
||||
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
|
||||
</div>
|
||||
hello2
|
||||
"""
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% simple / %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context())
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
hello1
|
||||
<div>
|
||||
SLOT_DEFAULT
|
||||
</div>
|
||||
hello2
|
||||
""",
|
||||
)
|
||||
|
||||
template = Template(
|
||||
"""
|
||||
{% load component_tags %}
|
||||
{% simple %}
|
||||
OVERRIDEN!
|
||||
{% endsimple %}
|
||||
"""
|
||||
)
|
||||
rendered = template.render(Context())
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
hello1
|
||||
<div>
|
||||
OVERRIDEN!
|
||||
</div>
|
||||
hello2
|
||||
""",
|
||||
)
|
|
@ -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)
|
||||
|
|
|
@ -35,7 +35,9 @@ class SlottedComponentWithContext(Component):
|
|||
|
||||
class ComponentTemplateTagTest(BaseTestCase):
|
||||
class SimpleComponent(Component):
|
||||
template_name = "simple_template.html"
|
||||
template: types.django_html = """
|
||||
Variable: <strong>{{ variable }}</strong>
|
||||
"""
|
||||
|
||||
def get_context_data(self, variable, variable2="default"):
|
||||
return {
|
||||
|
@ -61,7 +63,20 @@ class ComponentTemplateTagTest(BaseTestCase):
|
|||
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\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: <strong>variable</strong>\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: <strong>variable</strong>\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: <strong>variable</strong>\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):
|
||||
|
|
|
@ -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 %}
|
||||
<div>
|
||||
{% provide "my_provide" key="hi" another=123 / %}
|
||||
</div>
|
||||
"""
|
||||
template = Template(template_str)
|
||||
rendered = template.render(Context({}))
|
||||
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<div></div>
|
||||
""",
|
||||
)
|
||||
|
||||
@parametrize_context_behavior(["django", "isolated"])
|
||||
def test_provide_access_keys_in_python(self):
|
||||
@register("injectee")
|
||||
|
|
|
@ -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 %}
|
||||
<custom-template>
|
||||
<header>{% slot "header" / %}</header>
|
||||
<main>{% slot "main" %}Default main{% endslot %}</main>
|
||||
<footer>{% slot "footer" / %}</footer>
|
||||
</custom-template>
|
||||
"""
|
||||
|
||||
registry.register(name="test1", component=SlottedComponent)
|
||||
|
||||
@register("test2")
|
||||
class SimpleComponent(Component):
|
||||
template = """Variable: <strong>{{ variable }}</strong>"""
|
||||
|
||||
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: <main> is empty, because the fill is provided, even if empty
|
||||
self.assertHTMLEqual(
|
||||
rendered,
|
||||
"""
|
||||
<custom-template>
|
||||
<header> Variable: <strong>variable</strong> </header>
|
||||
<main></main>
|
||||
<footer></footer>
|
||||
</custom-template>
|
||||
""",
|
||||
)
|
||||
|
||||
# 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):
|
||||
|
|
|
@ -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):
|
||||
|
|
20
tests/test_utils.py
Normal file
20
tests/test_utils.py
Normal file
|
@ -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)
|
Loading…
Add table
Add a link
Reference in a new issue