feat: add self context var and make is_filled into attribute (#632)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-09-04 21:41:20 +02:00 committed by GitHub
parent 2d0f270df4
commit e712800f5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 265 additions and 31 deletions

View file

@ -45,7 +45,6 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
- [Use components in templates](#use-components-in-templates)
- [Use components outside of templates](#use-components-outside-of-templates)
- [Use components as views](#use-components-as-views)
- [Pre-defined components](#pre-defined-components)
- [Typing and validating components](#typing-and-validating-components)
- [Pre-defined components](#pre-defined-components)
- [Registering components](#registering-components)
@ -57,6 +56,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
- [Component hooks](#component-hooks)
- [Component context and scope](#component-context-and-scope)
- [Pre-defined template variables](#pre-defined-template-variables)
- [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)
@ -1474,7 +1474,7 @@ This produces:
_Added in version 0.26._
> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section "Accessing slot names with special characters".
> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section [Accessing `is_filled` of slot names with special characters](#accessing-is_filled-of-slot-names-with-special-characters).
In certain circumstances, you may want the behavior of slot filling to depend on
whether or not a particular slot is filled.
@ -2659,6 +2659,24 @@ 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`.
## Pre-defined template variables
Here is a list of all variables that are automatically available from within the component's template and `on_render_before` / `on_render_after` hooks.
- `component_vars.is_filled`
_New in version 0.70_
Dictonary describing which slots are filled (`True`) or are not (`False`).
Example:
```django
{% if component_vars.is_filled.my_slot %}
{% slot "my_slot" / %}
{% endif %}
```
## Customizing component tags with TagFormatter
_New in version 0.89_

View file

@ -65,7 +65,7 @@ from django_components.slots import (
)
from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple
# TODO_DEPRECATE_V1 - REMOVE IN V1, users should use top-level import instead
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
# isort: off
from django_components.component_registry import AlreadyRegistered as AlreadyRegistered # NOQA
from django_components.component_registry import ComponentRegistry as ComponentRegistry # NOQA
@ -94,6 +94,12 @@ class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
escape_slots_content: bool
@dataclass()
class RenderStackItem(Generic[ArgsType, KwargsType, SlotsType]):
input: RenderInput[ArgsType, KwargsType, SlotsType]
is_filled: Optional[Dict[str, bool]]
class ViewFn(Protocol):
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
@ -219,7 +225,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
self.fill_content = fill_content or {}
self.component_id = component_id or gen_id()
self.registry = registry or registry_
self._render_stack: Deque[RenderInput[ArgsType, KwargsType, SlotsType]] = deque()
self._render_stack: Deque[RenderStackItem[ArgsType, KwargsType, SlotsType]] = deque()
# None == uninitialized, False == No types, Tuple == types
self._types: Optional[Union[Tuple[Any, Any, Any, Any], Literal[False]]] = None
@ -241,7 +247,29 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
# NOTE: Input is managed as a stack, so if `render` is called within another `render`,
# the propertes below will return only the inner-most state.
return self._render_stack[-1]
return self._render_stack[-1].input
@property
def is_filled(self) -> Dict[str, bool]:
"""
Dictionary describing which slots have or have not been filled.
This attribute is available for use only within the template as `{{ component_vars.is_filled.slot_name }}`,
and within `on_render_before` and `on_render_after` hooks.
"""
if not len(self._render_stack):
raise RuntimeError(
f"{self.name}: Tried to access Component's `is_filled` attribute "
"while outside of rendering execution"
)
ctx = self._render_stack[-1]
if ctx.is_filled is None:
raise RuntimeError(
f"{self.name}: Tried to access Component's `is_filled` attribute " "before slots were resolved"
)
return ctx.is_filled
def get_context_data(self, *args: Any, **kwargs: Any) -> DataType:
return cast(DataType, {})
@ -508,13 +536,16 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
# to access the provided context, slots, etc. Also required so users can
# call `self.inject()` from within `get_context_data()`.
self._render_stack.append(
RenderInput(
context=context,
slots=slots,
args=args,
kwargs=kwargs,
escape_slots_content=escape_slots_content,
)
RenderStackItem(
input=RenderInput(
context=context,
slots=slots,
args=args,
kwargs=kwargs,
escape_slots_content=escape_slots_content,
),
is_filled=None,
),
)
self._validate_inputs()
@ -557,6 +588,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
# to see if given slot was filled, e.g.:
# `{% if variable > 8 and component_vars.is_filled.header %}`
slot_bools = {slot_fill.escaped_name: slot_fill.is_filled for slot_fill in resolved_fills.values()}
self._render_stack[-1].is_filled = slot_bools
with context.update(
{

View file

@ -1,4 +1,5 @@
import sys
import typing
from pathlib import Path
from typing import Any, Callable, List, Mapping, Sequence, Tuple, Union, get_type_hints
@ -39,6 +40,49 @@ def watch_files_for_autoreload(watch_list: Sequence[Union[str, Path]]) -> None:
autoreload_started.connect(autoreload_hook)
# Get all types that users may use from the `typing` module.
#
# These are the types that we do NOT try to resolve when it's a typed generic,
# e.g. `Union[int, str]`.
# If we get a typed generic that's NOT part of this set, we assume it's a user-made
# generic, e.g. `Component[Args, Kwargs]`. In such case we assert that a given value
# is an instance of the base class, e.g. `Component`.
_typing_exports = frozenset(
[
value
for value in typing.__dict__.values()
if isinstance(
value,
(
typing._SpecialForm,
# Used in 3.8 and 3.9
getattr(typing, "_GenericAlias", ()),
# Used in 3.11+ (possibly 3.10?)
getattr(typing, "_SpecialGenericAlias", ()),
),
)
]
)
def _prepare_type_for_validation(the_type: Any) -> Any:
# If we got a typed generic (AKA "subscripted" generic), e.g.
# `Component[CompArgs, CompKwargs, ...]`
# then we cannot use that generic in `isintance()`, because we get this error:
# `TypeError("Subscripted generics cannot be used with class and instance checks")`
#
# Instead, we resolve the generic to its original class, e.g. `Component`,
# which can then be used in instance assertion.
if hasattr(the_type, "__origin__"):
is_custom_typing = the_type.__origin__ not in _typing_exports
if is_custom_typing:
return the_type.__origin__
else:
return the_type
else:
return the_type
# NOTE: tuple_type is a _GenericAlias - See https://stackoverflow.com/questions/74412803
def validate_typed_tuple(
value: Tuple[Any, ...],
@ -63,7 +107,8 @@ def validate_typed_tuple(
for index, arg_type in enumerate(tuple_type.__args__):
arg = value[index]
if not isinstance(arg, arg_type):
arg_type = _prepare_type_for_validation(arg_type)
if sys.version_info >= (3, 11) and not isinstance(arg, arg_type):
# Generate errors like below (listed for searchability)
# `Component 'name' expected positional argument at index 0 to be <class 'int'>, got 123.5 of type <class 'float'>` # noqa: E501
raise TypeError(
@ -101,6 +146,7 @@ def validate_typed_dict(value: Mapping[str, Any], dict_type: Any, prefix: str, k
else:
unseen_keys.remove(key)
kwarg = value[key]
kwarg_type = _prepare_type_for_validation(kwarg_type)
# NOTE: `isinstance()` cannot be used with the version of TypedDict prior to 3.11.
# So we do type validation for TypedDicts only in 3.11 and later.

View file

@ -5,7 +5,7 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
import re
import sys
from typing import Any, Dict, Tuple, Union, no_type_check
from typing import Any, Dict, List, Tuple, Union, no_type_check
# See https://peps.python.org/pep-0655/#usage-in-python-3-11
if sys.version_info >= (3, 11):
@ -506,6 +506,111 @@ class ComponentValidationTest(BaseTestCase):
},
)
def test_handles_components_in_typing(self):
class InnerKwargs(TypedDict):
one: str
class InnerData(TypedDict):
one: Union[str, int]
self: "InnerComp" # type: ignore[misc]
InnerComp = Component[Any, InnerKwargs, InnerData, Any] # type: ignore[misc]
class Inner(InnerComp):
def get_context_data(self, one):
return {
"self": self,
"one": one,
}
template = ""
TodoArgs = Tuple[Inner] # type: ignore[misc]
class TodoKwargs(TypedDict):
inner: Inner
class TodoData(TypedDict):
one: Union[str, int]
self: "TodoComp" # type: ignore[misc]
inner: str
TodoComp = Component[TodoArgs, TodoKwargs, TodoData, Any] # type: ignore[misc]
# NOTE: Since we're using ForwardRef for "TodoComp" and "InnerComp", we need
# to ensure that the actual types are set as globals, so the ForwardRef class
# can resolve them.
globals()["TodoComp"] = TodoComp
globals()["InnerComp"] = InnerComp
class TestComponent(TodoComp):
def get_context_data(self, var1, inner):
return {
"self": self,
"one": "2123",
# NOTE: All of this is typed
"inner": self.input.kwargs["inner"].render(kwargs={"one": "abc"}),
}
template: types.django_html = """
{% load component_tags %}
Name: <strong>{{ self.name }}</strong>
"""
rendered = TestComponent.render(args=(Inner(),), kwargs={"inner": Inner()})
self.assertHTMLEqual(
rendered,
"""
Name: <strong>TestComponent</strong>
""",
)
def test_handles_typing_module(self):
TodoArgs = Tuple[
Union[str, int],
Dict[str, int],
List[str],
Tuple[int, Union[str, int]],
]
class TodoKwargs(TypedDict):
one: Union[str, int]
two: Dict[str, int]
three: List[str]
four: Tuple[int, Union[str, int]]
class TodoData(TypedDict):
one: Union[str, int]
two: Dict[str, int]
three: List[str]
four: Tuple[int, Union[str, int]]
TodoComp = Component[TodoArgs, TodoKwargs, TodoData, Any]
# NOTE: Since we're using ForwardRef for "TodoComp", we need
# to ensure that the actual types are set as globals, so the ForwardRef class
# can resolve them.
globals()["TodoComp"] = TodoComp
class TestComponent(TodoComp):
def get_context_data(self, *args, **kwargs):
return {
**kwargs,
}
template = ""
TestComponent.render(
args=("str", {"str": 123}, ["a", "b", "c"], (123, "123")),
kwargs={
"one": "str",
"two": {"str": 123},
"three": ["a", "b", "c"],
"four": (123, "123"),
},
)
class ComponentRenderTest(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])

View file

@ -1,6 +1,6 @@
from django.template import Context, Template
from django_components import Component, registry, types
from django_components import Component, register, registry, types
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
@ -591,20 +591,6 @@ class ContextVarsIsFilledTests(BaseTestCase):
</div>
"""
class ComponentWithNegatedConditionalSlot(Component):
template: types.django_html = """
{# Example from django-components/issues/98 #}
{% load component_tags %}
<div class="frontmatter-component">
<div class="title">{% slot "title" %}Title{% endslot %}</div>
{% if not component_vars.is_filled.subtitle %}
<div class="warning">Subtitle not filled!</div>
{% else %}
<div class="subtitle">{% slot "alt_subtitle" %}Why would you want this?{% endslot %}</div>
{% endif %}
</div>
"""
def setUp(self) -> None:
super().setUp()
registry.register("is_filled_vars", self.IsFilledVarsComponent)
@ -613,7 +599,6 @@ class ContextVarsIsFilledTests(BaseTestCase):
"complex_conditional_slots",
self.ComponentWithComplexConditionalSlots,
)
registry.register("negated_conditional_slot", self.ComponentWithNegatedConditionalSlot)
@parametrize_context_behavior(["django", "isolated"])
def test_is_filled_vars(self):
@ -679,7 +664,7 @@ class ContextVarsIsFilledTests(BaseTestCase):
template: types.django_html = """
{% load component_tags %}
{% component "conditional_slots" %}
{% fill "subtitle" %} My subtitle {% endfill %}
{% fill "subtitle" %} My subtitle {% endfill %}
{% endcomponent %}
"""
expected = """
@ -736,6 +721,21 @@ class ContextVarsIsFilledTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_component_with_negated_conditional_slot(self):
@register("negated_conditional_slot")
class ComponentWithNegatedConditionalSlot(Component):
template: types.django_html = """
{# Example from django-components/issues/98 #}
{% load component_tags %}
<div class="frontmatter-component">
<div class="title">{% slot "title" %}Title{% endslot %}</div>
{% if not component_vars.is_filled.subtitle %}
<div class="warning">Subtitle not filled!</div>
{% else %}
<div class="subtitle">{% slot "alt_subtitle" %}Why would you want this?{% endslot %}</div>
{% endif %}
</div>
"""
template: types.django_html = """
{% load component_tags %}
{% component "negated_conditional_slot" %}
@ -752,3 +752,36 @@ class ContextVarsIsFilledTests(BaseTestCase):
"""
rendered = Template(template).render(Context({}))
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_is_filled_vars_in_hooks(self):
captured_before = None
captured_after = None
@register("is_filled_vars")
class IsFilledVarsComponent(self.IsFilledVarsComponent): # type: ignore[name-defined]
def on_render_before(self, context: Context, template: Template) -> None:
nonlocal captured_before
captured_before = self.is_filled.copy()
def on_render_after(self, context: Context, template: Template, content: str) -> None:
nonlocal captured_after
captured_after = self.is_filled.copy()
template: types.django_html = """
{% load component_tags %}
{% component "is_filled_vars" %}
bla bla
{% endcomponent %}
"""
Template(template).render(Context())
expected = {
"title": True,
"my_title": False,
"my_title_1": False,
"my_title_2": False,
"escape_this_________": False,
}
self.assertEqual(captured_before, expected)
self.assertEqual(captured_after, expected)