feat: dependency injection with inject/provide (#506)

This commit is contained in:
Juro Oravec 2024-06-01 10:51:21 +02:00 committed by GitHub
parent 9bfb50b8f2
commit 8ca2814ee3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1032 additions and 7 deletions

130
README.md
View file

@ -33,6 +33,7 @@ Read on to learn about the details!
- [Using slots in templates](#using-slots-in-templates) - [Using slots in templates](#using-slots-in-templates)
- [Passing data to components](#passing-data-to-components) - [Passing data to components](#passing-data-to-components)
- [Rendering HTML attributes](#rendering-html-attributes) - [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) - [Component context and scope](#component-context-and-scope)
- [Rendering JS and CSS dependencies](#rendering-js-and-css-dependencies) - [Rendering JS and CSS dependencies](#rendering-js-and-css-dependencies)
- [Available settings](#available-settings) - [Available settings](#available-settings)
@ -44,6 +45,8 @@ Read on to learn about the details!
## Release notes ## Release notes
**Version 0.80** introduces dependency injection with the `{% provide %}` tag and `inject()` method.
🚨📢 **Version 0.79** 🚨📢 **Version 0.79**
- BREAKING CHANGE: Default value for the `COMPONENTS.context_behavior` setting was changes from `"isolated"` to `"django"`. If you did not set this value explicitly before, this may be a breaking change. See the rationale for change [here](https://github.com/EmilStenstrom/django-components/issues/498). - BREAKING CHANGE: Default value for the `COMPONENTS.context_behavior` setting was changes from `"isolated"` to `"django"`. If you did not set this value explicitly before, this may be a breaking change. See the rationale for change [here](https://github.com/EmilStenstrom/django-components/issues/498).
@ -1363,6 +1366,133 @@ attributes_to_string(attrs)
# 'class="my-class text-red pa-4" data-id="123" required' # 'class="my-class text-red pa-4" data-id="123" required'
``` ```
## Prop drilling and dependency injection (provide / inject)
_New in version 0.80_:
Django components supports dependency injection with the combination of:
1. `{% provide %}` tag
1. `inject()` method of the `Component` class
### What is "dependency injection" and "prop drilling"?
Prop drilling refers to a scenario in UI development where you need to pass data through many layers of a component tree to reach the nested components that actually need the data.
Normally, you'd use props to send data from a parent component to its children. However, this straightforward method becomes cumbersome and inefficient if the data has to travel through many levels or if several components scattered at different depths all need the same piece of information.
This results in a situation where the intermediate components, which don't need the data for their own functioning, end up having to manage and pass along these props. This clutters the component tree and makes the code verbose and harder to manage.
A neat solution to avoid prop drilling is using the "provide and inject" technique, AKA dependency injection.
With dependency injection, a parent component acts like a data hub for all its descendants. This setup allows any component, no matter how deeply nested it is, to access the required data directly from this centralized provider without having to messily pass props down the chain. This approach significantly cleans up the code and makes it easier to maintain.
This feature is inspired by Vue's [Provide / Inject](https://vuejs.org/guide/components/provide-inject) and React's [Context / useContext](https://react.dev/learn/passing-data-deeply-with-context).
### How to use provide / inject
As the name suggest, using provide / inject consists of 2 steps
1. Providing data
2. Injecting provided data
For examples of advanced uses of provide / inject, [see this discussion](https://github.com/EmilStenstrom/django-components/pull/506#issuecomment-2132102584).
### Using `{% provide %}` tag
First we use the `{% provide %}` tag to define the data we want to "provide" (make available).
```django
{% provide "my_data" key="hi" another=123 %}
{% component "child" %} <--- Can access "my_data"
{% endcomponent %}
{% endprovide %}
{% component "child" %} <--- Cannot access "my_data"
{% endcomponent %}
```
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.
`provide` tag _key_, similarly to the _name_ argument in `component` or `slot` tags, has these requirements:
- The _key_ must be a string literal
- It must be a valid identifier (AKA a valid Python variable name)
Once you've set the name, you define the data you want to "provide" by passing it as keyword arguments. This is similar to how you pass data to the `{% with %}` tag.
> NOTE: Kwargs passed to `{% provide %}` are NOT added to the context.
> In the example below, the `{{ key }}` won't render anything:
> ```django
> {% provide "my_data" key="hi" another=123 %}
> {{ key }}
> {% endprovide %}
> ```
### Using `inject()` method
To "inject" (access) the data defined on the `provide` tag, you can use the `inject()` method inside of `get_context_data()`.
For a component to be able to "inject" some data, the component (`{% component %}` tag) must be nested inside the `{% provide %}` tag.
In the example from previous section, we've defined two kwargs: `key="hi" another=123`. That means that if we now inject `"my_data"`, we get an object with 2 attributes - `key` and `another`.
```py
class ChildComponent(component.Component):
def get_context_data(self):
my_data = self.inject("my_data")
print(my_data.key) # hi
print(my_data.another) # 123
return {}
```
First argument to `inject` is the _key_ of the provided data. This
must match the string that you used in the `provide` tag. If no provider
with given key is found, `inject` raises a `KeyError`.
To avoid the error, you can pass a second argument to `inject` to which will act as a default value, similar to `dict.get(key, default)`:
```py
class ChildComponent(component.Component):
def get_context_data(self):
my_data = self.inject("invalid_key", DEFAULT_DATA)
assert my_data == DEFAUKT_DATA
return {}
```
The instance returned from `inject()` is a subclass of `NamedTuple`, so the instance is immutable. This ensures that the data returned from `inject` will always
have all the keys that were passed to the `provide` tag.
> NOTE: `inject()` works strictly only in `get_context_data`. If you try to call it from elsewhere, it will raise an error.
### Full example
```py
@component.register("child")
class ChildComponent(component.Component):
template = """
<div> {{ my_data.key }} </div>
<div> {{ my_data.another }} </div>
"""
def get_context_data(self):
my_data = self.inject("my_data", "default")
return {"my_data": my_data}
template_str = """
{% load component_tags %}
{% provide "my_data" key="hi" another=123 %}
{% component "child" %}
{% endcomponent %}
{% endprovide %}
"""
```
renders:
```html
<div> hi </div>
<div> 123 </div>
```
## Component context and scope ## Component context and scope
By default, context variables are passed down the template as in regular Django - deeper scopes can access the variables from the outer scopes. So if you have several nested forloops, then inside the deep-most loop you can access variables defined by all previous loops. By default, context variables are passed down the template as in regular Django - deeper scopes can access the variables from the outer scopes. So if you have several nested forloops, then inside the deep-most loop you can access variables defined by all previous loops.

View file

@ -30,6 +30,7 @@ from django_components.context import (
_FILLED_SLOTS_CONTENT_CONTEXT_KEY, _FILLED_SLOTS_CONTENT_CONTEXT_KEY,
_PARENT_COMP_CONTEXT_KEY, _PARENT_COMP_CONTEXT_KEY,
_ROOT_CTX_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY,
get_injected_context_var,
make_isolated_context_copy, make_isolated_context_copy,
prepare_context, prepare_context,
) )
@ -201,6 +202,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
self.outer_context: Context = outer_context or Context() self.outer_context: Context = outer_context or Context()
self.fill_content = fill_content or {} self.fill_content = fill_content or {}
self.component_id = component_id or gen_id() self.component_id = component_id or gen_id()
self._context: Optional[Context] = None
def __init_subclass__(cls, **kwargs: Any) -> None: def __init_subclass__(cls, **kwargs: Any) -> None:
cls.class_hash = hash(inspect.getfile(cls) + cls.__name__) cls.class_hash = hash(inspect.getfile(cls) + cls.__name__)
@ -260,13 +262,71 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
f"Note: this attribute is not required if you are overriding the class's `get_template*()` methods." f"Note: this attribute is not required if you are overriding the class's `get_template*()` methods."
) )
def render_from_input(self, context: Context, args: Union[List, Tuple], kwargs: Dict[str, Any]) -> str: def inject(self, key: str, default: Optional[Any] = None) -> Any:
component_context: dict = self.get_context_data(*args, **kwargs) """
Use this method to retrieve the data that was passed to a `{% provide %}` tag
with the corresponding key.
with context.update(component_context): To retrieve the data, `inject()` must be called inside a component that's
inside the `{% provide %}` tag.
You may also pass a default that will be used if the `provide` tag with given
key was NOT found.
This method mut be used inside the `get_context_data()` method and raises
an error if called elsewhere.
Example:
Given this template:
```django
{% provide "provider" hello="world" %}
{% component "my_comp" %}
{% endcomponent %}
{% endprovide %}
```
And given this definition of "my_comp" component:
```py
@component.register("my_comp")
class MyComp(component.Component):
template = "hi {{ data.hello }}!"
def get_context_data(self):
data = self.inject("provider")
return {"data": data}
```
This renders into:
```
hi world!
```
As the `{{ data.hello }}` is taken from the "provider".
"""
comp_name = self.registered_name or self.__class__.__name__
if self._context is None:
raise RuntimeError(
f"Method 'inject()' of component '{comp_name}' was called outside of 'get_context_data()'"
)
return get_injected_context_var(comp_name, self._context, key, default)
def render_from_input(
self,
context: Context,
args: Union[List, Tuple],
kwargs: Dict[str, Any],
) -> str:
# Temporarily populate _context so user can call `self.inject()` from
# within `get_context_data()`
self._context = context
context_data = self.get_context_data(*args, **kwargs)
self._context = None
with context.update(context_data):
rendered_component = self.render( rendered_component = self.render(
context=context, context=context,
context_data=component_context, context_data=context_data,
) )
if is_dependency_middleware_active(): if is_dependency_middleware_active():

View file

@ -5,7 +5,10 @@ pass data across components, nodes, slots, and contexts.
You can think of the Context as our storage system. You can think of the Context as our storage system.
""" """
from django.template import Context from collections import namedtuple
from typing import Any, Dict, Optional
from django.template import Context, TemplateSyntaxError
from django_components.utils import find_last_index from django_components.utils import find_last_index
@ -13,6 +16,7 @@ _FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
_ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX" _ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX"
_PARENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_PARENT_COMP" _PARENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_PARENT_COMP"
_CURRENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP" _CURRENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP"
_INJECT_CONTEXT_KEY_PREFIX = "_DJANGO_COMPONENTS_INJECT__"
def prepare_context( def prepare_context(
@ -35,7 +39,13 @@ def make_isolated_context_copy(context: Context) -> Context:
# Pass through our internal keys # Pass through our internal keys
context_copy[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {}) context_copy[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {})
if _ROOT_CTX_CONTEXT_KEY in context: if _ROOT_CTX_CONTEXT_KEY in context:
context_copy[_ROOT_CTX_CONTEXT_KEY] = context.get(_ROOT_CTX_CONTEXT_KEY, {}) context_copy[_ROOT_CTX_CONTEXT_KEY] = context[_ROOT_CTX_CONTEXT_KEY]
# Make inject/provide to work in isolated mode
context_keys = context.flatten().keys()
for key in context_keys:
if key.startswith(_INJECT_CONTEXT_KEY_PREFIX):
context_copy[key] = context[key]
return context_copy return context_copy
@ -62,3 +72,65 @@ def copy_forloop_context(from_context: Context, to_context: Context) -> None:
if "forloop" in from_context: if "forloop" in from_context:
forloop_dict_index = find_last_index(from_context.dicts, lambda d: "forloop" in d) forloop_dict_index = find_last_index(from_context.dicts, lambda d: "forloop" in d)
to_context.update(from_context.dicts[forloop_dict_index]) to_context.update(from_context.dicts[forloop_dict_index])
def get_injected_context_var(
component_name: str,
context: Context,
key: str,
default: Optional[Any] = None,
) -> Any:
"""
Retrieve a 'provided' field. The field MUST have been previously 'provided'
by the component's ancestors using the `{% provide %}` template tag.
"""
# NOTE: For simplicity, we keep the provided values directly on the context.
# This plays nicely with Django's Context, which behaves like a stack, so "newer"
# values overshadow the "older" ones.
internal_key = _INJECT_CONTEXT_KEY_PREFIX + key
# Return provided value if found
if internal_key in context:
return context[internal_key]
# If a default was given, return that
if default is not None:
return default
# Otherwise raise error
raise KeyError(
f"Component '{component_name}' tried to inject a variable '{key}' before it was provided."
f"To fix this, make sure that at least one ancestor of component '{component_name}' has"
f" the variable '{key}' in their 'provide' attribute."
)
def set_provided_context_var(
context: Context,
key: str,
provided_kwargs: Dict[str, Any],
) -> None:
"""
'Provide' given data under given key. In other words, this data can be retrieved
using `self.inject(key)` inside of `get_context_data()` method of components that
are nested inside the `{% provide %}` tag.
"""
# NOTE: We raise TemplateSyntaxError since this func should be called only from
# within template.
if not key:
raise TemplateSyntaxError(
"Provide tag received an empty string. Key must be non-empty and a valid identifier."
)
if not key.isidentifier():
raise TemplateSyntaxError(
"Provide tag received a non-identifier string. Key must be non-empty and a valid identifier."
)
# We turn the kwargs into a NamedTuple so that the object that's "provided"
# is immutable. This ensures that the data returned from `inject` will always
# have all the keys that were passed to the `provide` tag.
tpl_cls = namedtuple("DepInject", provided_kwargs.keys()) # type: ignore[misc]
payload = tpl_cls(**provided_kwargs)
internal_key = _INJECT_CONTEXT_KEY_PREFIX + key
context[internal_key] = payload

View file

@ -63,7 +63,7 @@ def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> No
def trace_msg( def trace_msg(
action: Literal["PARSE", "ASSOC", "RENDR", "GET", "SET"], action: Literal["PARSE", "ASSOC", "RENDR", "GET", "SET"],
node_type: Literal["COMP", "FILL", "SLOT", "N/A"], node_type: Literal["COMP", "FILL", "SLOT", "PROVIDE", "N/A"],
node_name: str, node_name: str,
node_id: str, node_id: str,
msg: str = "", msg: str = "",

View file

@ -0,0 +1,52 @@
from typing import Dict, Optional
from django.template import Context
from django.template.base import FilterExpression, Node, NodeList
from django.utils.safestring import SafeString
from django_components.context import set_provided_context_var
from django_components.expression import safe_resolve_dict
from django_components.logger import trace_msg
from django_components.template_parser import process_aggregate_kwargs
from django_components.utils import gen_id
class ProvideNode(Node):
"""
Implementation of the `{% provide %}` tag.
For more info see `Component.inject`.
"""
def __init__(
self,
name: str,
nodelist: NodeList,
node_id: Optional[str] = None,
provide_kwargs: Optional[Dict[str, FilterExpression]] = None,
):
self.name = name
self.nodelist = nodelist
self.node_id = node_id or gen_id()
self.provide_kwargs = provide_kwargs or {}
def __repr__(self) -> str:
return f"<Provide Node: {self.name}. Contents: {repr(self.nodelist)}. Data: {self.provide_kwargs}>"
def render(self, context: Context) -> SafeString:
trace_msg("RENDR", "PROVIDE", self.name, self.node_id)
data = safe_resolve_dict(self.provide_kwargs, context)
# Allow user to use the var:key=value syntax
data = process_aggregate_kwargs(data)
# NOTE: The "provided" kwargs are meant to be shared privately, meaning that components
# have to explicitly opt in by using the `Component.inject()` method. That's why we don't
# add the provided kwargs into the Context.
with context.update({}):
# "Provide" the data to child nodes
set_provided_context_var(context, self.name, data)
output = self.nodelist.render(context)
trace_msg("RENDR", "PROVIDE", self.name, self.node_id, msg="...Done!")
return output

View file

@ -17,6 +17,7 @@ from django_components.middleware import (
JS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER,
is_dependency_middleware_active, is_dependency_middleware_active,
) )
from django_components.provide import ProvideNode
from django_components.slots import FillNode, SlotNode, parse_slot_fill_nodes_from_component_nodelist 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.template_parser import parse_bits
from django_components.utils import gen_id from django_components.utils import gen_id
@ -216,6 +217,30 @@ def do_component(parser: Parser, token: Token) -> ComponentNode:
return component_node return component_node
@register.tag("provide")
def do_provide(parser: Parser, token: Token) -> SlotNode:
# e.g. {% provide <name> key=val key2=val2 %}
tag_name, *args = token.split_contents()
provide_key, kwargs = _parse_provide_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", "PROVIDE", provide_key, slot_id)
nodelist = parser.parse(parse_until=["endprovide"])
parser.delete_first_token()
slot_node = ProvideNode(
provide_key,
nodelist,
node_id=slot_id,
provide_kwargs=kwargs,
)
trace_msg("PARSE", "PROVIDE", provide_key, slot_id, "...Done!")
return slot_node
@register.tag("html_attrs") @register.tag("html_attrs")
def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode: def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
""" """
@ -438,6 +463,32 @@ def _parse_fill_args(
return slot_name_fexp, slot_default_var_fexp, slot_data_var_fexp return slot_name_fexp, slot_default_var_fexp, slot_data_var_fexp
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'.")
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( def _get_positional_param(
tag_name: str, tag_name: str,
param_name: str, param_name: str,

View file

@ -0,0 +1,15 @@
{% load component_tags %}
<!DOCTYPE html>
<html lang="en">
<body>
{% provide "block_provide" hello="from_block" %}
{% component "slotted_component" %}
{% fill "header" %}{% endfill %}
{% fill "main" %}
{% block body %}
{% endblock %}
{% endfill %}
{% endcomponent %}
{% endprovide %}
</body>
</html>

View file

@ -0,0 +1,5 @@
{% load component_tags %}
<div>
{% component "injectee" %}
{% endcomponent %}
</div>

View file

@ -453,3 +453,42 @@ class BlockCompatTests(BaseTestCase):
</html> </html>
""" """
self.assertHTMLEqual(rendered, expected) self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_inject_inside_block(self):
component.registry.register("slotted_component", SlottedComponent)
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("block_provide")
return {"var": var}
template: types.django_html = """
{% extends "block_in_component_provide.html" %}
{% load component_tags %}
{% block body %}
{% component "injectee" %}
{% endcomponent %}
{% endblock %}
"""
rendered = Template(template).render(Context())
expected = """
<!DOCTYPE html>
<html lang="en">
<body>
<custom-template>
<header></header>
<main>
<div> injected: DepInject(hello='from_block') </div>
</main>
<footer>Default footer</footer>
</custom-template>
</body>
</html>
"""
self.assertHTMLEqual(rendered, expected)

View file

@ -0,0 +1,601 @@
from django.template import Context, Template, TemplateSyntaxError
# isort: off
from .django_test_setup import * # NOQA
from .testutils import BaseTestCase, parametrize_context_behavior
# isort: on
from django_components import component, types
class ProvideTemplateTagTest(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_provide_basic(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=123 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div> injected: DepInject(key='hi', another=123) </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_access_keys_in_python(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> key: {{ key }} </div>
<div> another: {{ another }} </div>
"""
def get_context_data(self):
my_provide = self.inject("my_provide")
return {
"key": my_provide.key,
"another": my_provide.another,
}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=123 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div> key: hi </div>
<div> another: 123 </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_access_keys_in_django(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> key: {{ my_provide.key }} </div>
<div> another: {{ my_provide.another }} </div>
"""
def get_context_data(self):
my_provide = self.inject("my_provide")
return {
"my_provide": my_provide,
}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=123 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div> key: hi </div>
<div> another: 123 </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_does_not_leak(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=123 %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div> injected: default </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_empty(self):
"""Check provide tag with no kwargs"""
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div> injected: DepInject() </div>
<div> injected: default </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_no_inject(self):
"""Check that nothing breaks if we do NOT inject even if some data is provided"""
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div></div>
"""
def get_context_data(self):
return {}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=123 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div></div>
<div></div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_key_single_quotes(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide 'my_provide' key="hi" another=123 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div> injected: DepInject(key='hi', another=123) </div>
<div> injected: default </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_no_key_raises(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide key="hi" another=123 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
"""
with self.assertRaises(TemplateSyntaxError):
Template(template_str)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_key_must_be_string_literal(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide my_var key="hi" another=123 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
"""
with self.assertRaises(TemplateSyntaxError):
Template(template_str)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_key_must_be_identifier(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide "%heya%" key="hi" another=123 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
"""
template = Template(template_str)
with self.assertRaises(TemplateSyntaxError):
template.render(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_provide_aggregate_dics(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" var1:key="hi" var1:another=123 var2:x="y" %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div> injected: DepInject(var1={'key': 'hi', 'another': 123}, var2={'x': 'y'}) </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_does_not_expose_kwargs_to_context(self):
"""Check that `provide` tag doesn't assign the keys to the context like `with` tag does"""
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
var_out: {{ var }}
key_out: {{ key }}
{% provide "my_provide" key="hi" another=123 %}
var_in: {{ var }}
key_in: {{ key }}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(Context({"var": "123"}))
self.assertHTMLEqual(
rendered,
"""
var_out: 123
key_out:
var_in: 123
key_in:
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_nested_in_provide_same_key(self):
"""Check that inner `provide` with same key overshadows outer `provide`"""
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=123 lost=0 %}
{% provide "my_provide" key="hi1" another=1231 new=3 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div> injected: DepInject(key='hi1', another=1231, new=3) </div>
<div> injected: DepInject(key='hi', another=123, lost=0) </div>
<div> injected: default </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_nested_in_provide_different_key(self):
"""Check that `provide` tag with different keys don't affect each other"""
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> first_provide: {{ first_provide|safe }} </div>
<div> second_provide: {{ second_provide|safe }} </div>
"""
def get_context_data(self):
first_provide = self.inject("first_provide", "default")
second_provide = self.inject("second_provide", "default")
return {
"first_provide": first_provide,
"second_provide": second_provide,
}
template_str: types.django_html = """
{% load component_tags %}
{% provide "first_provide" key="hi" another=123 lost=0 %}
{% provide "second_provide" key="hi1" another=1231 new=3 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div> first_provide: DepInject(key='hi', another=123, lost=0) </div>
<div> second_provide: DepInject(key='hi1', another=1231, new=3) </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_provide_in_include(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=123 %}
{% include "inject.html" %}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div>
<div> injected: DepInject(key='hi', another=123) </div>
</div>
""",
)
class InjectTest(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_inject_basic(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("my_provide")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=123 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div> injected: DepInject(key='hi', another=123) </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_inject_missing_key_raises_without_default(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("abc")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% component "injectee" %}
{% endcomponent %}
"""
template = Template(template_str)
with self.assertRaises(KeyError):
template.render(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_inject_missing_key_ok_with_default(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("abc", "default")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% component "injectee" %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div> injected: default </div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_inject_empty_string(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("")
return {"var": var}
template_str: types.django_html = """
{% load component_tags %}
{% provide "my_provide" key="hi" another=123 %}
{% component "injectee" %}
{% endcomponent %}
{% endprovide %}
{% component "injectee" %}
{% endcomponent %}
"""
template = Template(template_str)
with self.assertRaises(KeyError):
template.render(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_inject_raises_on_called_outside_get_context_data(self):
@component.register("injectee")
class InjectComponent(component.Component):
template: types.django_html = """
<div> injected: {{ var|safe }} </div>
"""
def get_context_data(self):
var = self.inject("abc", "default")
return {"var": var}
comp = InjectComponent("")
with self.assertRaises(RuntimeError):
comp.inject("abc", "def")