Skip to content

Commit

Permalink
Use template_html on a component as the template_name.
Browse files Browse the repository at this point in the history
  • Loading branch information
adamghill committed Jul 25, 2024
1 parent 3a8a299 commit fc1821a
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 28 deletions.
20 changes: 17 additions & 3 deletions django_unicorn/cacher.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import django_unicorn
from django_unicorn.errors import UnicornCacheError
from django_unicorn.settings import get_cache_alias
from django_unicorn.utils import create_template

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -46,15 +47,23 @@ def __enter__(self):
else:
extra_context = None

# Pop the request off for pickling
request = component.request
component.request = None

template_name = component.template_name

# Pop the template_name off for pickling, but only if it's not a string, aka it's a `Template`
if not isinstance(component.template_name, str):
component.template_name = None

self._state[component.component_id] = (
component,
request,
extra_context,
component.parent,
component.children.copy(),
template_name,
)

if component.parent:
Expand All @@ -81,10 +90,15 @@ def __enter__(self):
return self

def __exit__(self, *args):
for component, request, extra_context, parent, children in self._state.values():
for component, request, extra_context, parent, children, template_name in self._state.values():
component.request = request
component.parent = parent
component.children = children
component.template_name = template_name

# Re-create the template_name `Template` object if it is `None`
if component.template_name is None and hasattr(component, "template_html"):
component.template_name = create_template(component.template_html)

if extra_context:
component.extra_context = extra_context
Expand Down Expand Up @@ -117,14 +131,14 @@ def restore_from_cache(component_cache_key: str, request: HttpRequest = None) ->

if cached_component:
roots = {}
root: django_unicorn.views.UnicornView = cached_component
root: "django_unicorn.views.UnicornView" = cached_component
roots[root.component_cache_key] = root

while root.parent:
root = cache.get(root.parent.component_cache_key)
roots[root.component_cache_key] = root

to_traverse: List[django_unicorn.views.UnicornView] = []
to_traverse: List["django_unicorn.views.UnicornView"] = []
to_traverse.append(root)

while to_traverse:
Expand Down
14 changes: 14 additions & 0 deletions django_unicorn/components/unicorn_template_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from bs4.dammit import EntitySubstitution
from bs4.element import Tag
from bs4.formatter import HTMLFormatter
from django.template.backends.django import Template
from django.template.response import TemplateResponse

from django_unicorn.decorators import timed
Expand Down Expand Up @@ -160,6 +161,19 @@ def __init__(
self.component = component
self.init_js = init_js

def resolve_template(self, template):
"""Override the TemplateResponseMixin to resolve a list of Templates.
Calls the super which accepts a template object, path-to-template, or list of paths if the first
object in the sequence is not a Template.
"""

if isinstance(template, (list, tuple)):
if isinstance(template[0], Template):
return template[0]

return super().resolve_template(template)

@timed
def render(self):
response = super().render()
Expand Down
15 changes: 12 additions & 3 deletions django_unicorn/components/unicorn_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
)
from django_unicorn.settings import get_setting
from django_unicorn.typer import cast_attribute_value, get_type_hints
from django_unicorn.utils import is_non_string_sequence
from django_unicorn.utils import create_template, is_non_string_sequence

try:
from cachetools.lru import LRUCache
Expand Down Expand Up @@ -232,9 +232,17 @@ def __init__(self, component_args: Optional[List] = None, **kwargs):

@timed
def _set_default_template_name(self) -> None:
"""Sets a default template name based on component's name if necessary.
Also handles `template_html` if it is set on the component which overrides `template_name`.
"""
Sets a default template name based on component's name if necessary.
"""

if hasattr(self, "template_html"):
try:
self.template_name = create_template(self.template_html)
except AssertionError:
pass

get_template_names_is_valid = False

try:
Expand Down Expand Up @@ -710,6 +718,7 @@ def _is_public(self, name: str) -> bool:
"http_method_names",
"template_engine",
"template_name",
"template_html",
"dispatch",
"id",
"get",
Expand Down
26 changes: 21 additions & 5 deletions django_unicorn/utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import collections.abc
import hmac
import logging
from collections.abc import Callable, Sequence, Set
from inspect import signature
from pprint import pprint
from typing import Dict, List, Union
from typing import Dict, List, Optional, Union

import shortuuid
from django.conf import settings
from django.template import engines
from django.template.backends.django import Template
from django.utils.html import _json_script_escapes
from django.utils.safestring import SafeText, mark_safe

Expand Down Expand Up @@ -105,9 +107,7 @@ def is_non_string_sequence(obj):
Helpful when you expect to loop over `obj`, but explicitly don't want to allow `str`.
"""

if (isinstance(obj, (collections.abc.Sequence, collections.abc.Set))) and not isinstance(
obj, (str, bytes, bytearray)
):
if (isinstance(obj, (Sequence, Set))) and not isinstance(obj, (str, bytes, bytearray)):
return True

return False
Expand All @@ -124,3 +124,19 @@ def is_int(s: str) -> bool:
return False
else:
return True


def create_template(template_html: Union[str, Callable], engine_name: Optional[str] = None) -> Template:
"""Create a `Template` from a string or callable."""

if callable(template_html):
template_html = str(template_html())

for engine in engines.all():
if engine_name is None or engine_name == engine.name:
try:
return engine.from_string(template_html)
except NotImplementedError:
pass

raise AssertionError("Template could not be created based on configured template engines")
27 changes: 27 additions & 0 deletions tests/components/test_create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import pytest

from django_unicorn.components.unicorn_view import UnicornView
from django_unicorn.errors import ComponentModuleLoadError


def test_no_component():
with pytest.raises(ComponentModuleLoadError) as e:
UnicornView.create(component_id="create-no-component", component_name="create-no-component")

assert (
e.exconly()
== "django_unicorn.errors.ComponentModuleLoadError: The component module 'create_no_component' could not be loaded."
)


class FakeComponent(UnicornView):
pass


def test_components_settings(settings):
settings.UNICORN["COMPONENTS"] = {"create-components-setting": FakeComponent}

component = UnicornView.create(
component_id="create-components-setting-id", component_name="create-components-setting"
)
assert component
99 changes: 82 additions & 17 deletions tests/test_cacher.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch

from django_unicorn.cacher import (
CacheableComponent,
Expand All @@ -12,8 +12,17 @@ class FakeComponent(UnicornView):
pass


class FakeComponentWithTemplateHtml(UnicornView):
template_html = """<div>
testing
</div>
"""


def test_cacheable_component_request_is_none_then_restored():
component = FakeComponent(component_id="asdf123498", component_name="hello-world")
component = FakeComponent(
component_id="test_cacheable_component_request_is_none_then_restored", component_name="hello-world"
)
request = component.request = MagicMock()
assert component.request

Expand All @@ -24,7 +33,9 @@ def test_cacheable_component_request_is_none_then_restored():


def test_cacheable_component_extra_context_is_none_then_restored():
component = FakeComponent(component_id="asdf123499", component_name="hello-world")
component = FakeComponent(
component_id="test_cacheable_component_extra_context_is_none_then_restored", component_name="hello-world"
)
extra_context = component.extra_context = MagicMock()
assert component.extra_context

Expand All @@ -35,9 +46,19 @@ def test_cacheable_component_extra_context_is_none_then_restored():


def test_cacheable_component_parents_have_request_restored():
component = FakeComponent(component_id="asdf123498", component_name="hello-world")
component2 = FakeComponent(component_id="asdf123499", component_name="hello-world", parent=component)
component3 = FakeComponent(component_id="asdf123500", component_name="hello-world", parent=component2)
component = FakeComponent(
component_id="test_cacheable_component_parents_have_request_restored_1", component_name="hello-world"
)
component2 = FakeComponent(
component_id="test_cacheable_component_parents_have_request_restored_2",
component_name="hello-world",
parent=component,
)
component3 = FakeComponent(
component_id="test_cacheable_component_parents_have_request_restored_3",
component_name="hello-world",
parent=component2,
)
request = MagicMock()
extra_content = "extra_content"
for c in [component, component2, component3]:
Expand All @@ -61,10 +82,18 @@ def test_cacheable_component_parents_have_request_restored():


def test_restore_cached_component_children_have_request_set():
component = FakeComponent(component_id="asdf123498", component_name="hello-world")
component2 = FakeComponent(component_id="asdf123499", component_name="hello-world")
component3 = FakeComponent(component_id="asdf123500", component_name="hello-world")
component4 = FakeComponent(component_id="asdf123501", component_name="hello-world")
component = FakeComponent(
component_id="test_restore_cached_component_children_have_request_set_1", component_name="hello-world"
)
component2 = FakeComponent(
component_id="test_restore_cached_component_children_have_request_set_2", component_name="hello-world"
)
component3 = FakeComponent(
component_id="test_restore_cached_component_children_have_request_set_3", component_name="hello-world"
)
component4 = FakeComponent(
component_id="test_restore_cached_component_children_have_request_set_4", component_name="hello-world"
)
component3.children.append(component4)
component.children.extend([component2, component3])
request = MagicMock()
Expand Down Expand Up @@ -111,13 +140,25 @@ def test_caching_components(settings):
}
}
settings.UNICORN["CACHE_ALIAS"] = "default"
root = ExampleCachingComponent(component_id="rrr", component_name="root")
child1 = ExampleCachingComponent(component_id="1111", component_name="child1", parent=root)
child2 = ExampleCachingComponent(component_id="2222", component_name="child2", parent=root)
child3 = ExampleCachingComponent(component_id="3333", component_name="child3", parent=root)
grandchild = ExampleCachingComponent(component_id="4444", component_name="grandchild", parent=child1)
grandchild2 = ExampleCachingComponent(component_id="5555", component_name="grandchild2", parent=child1)
grandchild3 = ExampleCachingComponent(component_id="6666", component_name="grandchild3", parent=child3)
root = ExampleCachingComponent(component_id="test_caching_components_1", component_name="root")
child1 = ExampleCachingComponent(
component_id="test_caching_components_child_1", component_name="child1", parent=root
)
child2 = ExampleCachingComponent(
component_id="test_caching_components_child_2", component_name="child2", parent=root
)
child3 = ExampleCachingComponent(
component_id="test_caching_components_child_3", component_name="child3", parent=root
)
grandchild = ExampleCachingComponent(
component_id="test_caching_components_grandchild_1", component_name="grandchild", parent=child1
)
grandchild2 = ExampleCachingComponent(
component_id="test_caching_components_grandchild_2", component_name="grandchild2", parent=child1
)
grandchild3 = ExampleCachingComponent(
component_id="test_caching_components_grandchild_3", component_name="grandchild3", parent=child3
)

cache_full_tree(child3)
request = MagicMock()
Expand All @@ -130,11 +171,35 @@ def test_caching_components(settings):

assert root.component_id == restored_root.component_id
assert 3 == len(restored_root.children)

for i, child in enumerate([child1, child2, child3]):
assert restored_root.children[i].component_id == child.component_id

assert 2 == len(restored_root.children[0].children)

for i, child in enumerate([grandchild, grandchild2]):
assert restored_root.children[0].children[i].component_id == child.component_id

assert not restored_root.children[1].children
assert 1 == len(restored_root.children[2].children)
assert grandchild3.component_id == restored_root.children[2].children[0].component_id


@patch("django_unicorn.cacher.create_template")
def test_caching_components_with_template_html(create_template):
component = FakeComponentWithTemplateHtml(
component_id="test_caching_components_with_template_html", component_name="template-html-test"
)
assert component.template_html

# manually set template_name to None which happens outside of this code normally
component.template_name = None

# Caching will pop the `Template` instance from component.template_name when it enters, adn re-create it when it exits
cache_full_tree(component)

assert component.template_html
assert isinstance(component.template_html, str)

# check that template_name will re-create a Template based on `template_html`
create_template.assert_called_once_with(component.template_html)
19 changes: 19 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import pytest
from django.template.backends.django import Template

from django_unicorn.utils import (
create_template,
generate_checksum,
get_method_arguments,
is_non_string_sequence,
Expand Down Expand Up @@ -100,3 +102,20 @@ def test_is_non_string_sequence_string():

def test_is_non_string_sequence_bytes():
assert not is_non_string_sequence(b"")


def test_create_template_str():
actual = create_template("<div>test string template</div>")

assert actual
assert isinstance(actual, Template)


def test_create_template_callable():
def _get_template():
return "<div>test callable template</div>"

actual = create_template(_get_template)

assert actual
assert isinstance(actual, Template)
Loading

0 comments on commit fc1821a

Please sign in to comment.