import gc from typing import Any, Callable, Dict, List, cast import pytest from django.http import HttpRequest, HttpResponse from django.template import Context, Origin, Template from django.test import Client from django_components import Component, Slot, SlotNode, register, registry from django_components.app_settings import app_settings from django_components.component_registry import ComponentRegistry from django_components.extension import ( URLRoute, ComponentExtension, ExtensionComponentConfig, OnComponentClassCreatedContext, OnComponentClassDeletedContext, OnRegistryCreatedContext, OnRegistryDeletedContext, OnComponentRegisteredContext, OnComponentUnregisteredContext, OnComponentInputContext, OnComponentDataContext, OnComponentRenderedContext, OnSlotRenderedContext, OnTemplateLoadedContext, OnTemplateCompiledContext, OnJsLoadedContext, OnCssLoadedContext, ) from django_components.extensions.cache import CacheExtension from django_components.extensions.debug_highlight import DebugHighlightExtension from django_components.extensions.defaults import DefaultsExtension from django_components.extensions.dependencies import DependenciesExtension from django_components.extensions.view import ViewExtension from django_components.testing import djc_test from .testutils import setup_test_config setup_test_config({"autodiscover": False}) def dummy_view(request: HttpRequest): # Test that the request object is passed to the view assert isinstance(request, HttpRequest) return HttpResponse("Hello, world!") def dummy_view_2(request: HttpRequest, id: int, name: str): return HttpResponse(f"Hello, world! {id} {name}") # TODO_V1 - Remove class LegacyExtension(ComponentExtension): name = "legacy" class ExtensionClass(ExtensionComponentConfig): foo = "1" bar = "2" @classmethod def baz(cls): return "3" class DummyExtension(ComponentExtension): """ Test extension that tracks all hook calls and their arguments. """ name = "test_extension" class ComponentConfig(ExtensionComponentConfig): foo = "1" bar = "2" @classmethod def baz(cls): return "3" def __init__(self) -> None: self.calls: Dict[str, List[Any]] = { "on_component_class_created": [], "on_component_class_deleted": [], "on_registry_created": [], "on_registry_deleted": [], "on_component_registered": [], "on_component_unregistered": [], "on_component_input": [], "on_component_data": [], "on_component_rendered": [], "on_slot_rendered": [], "on_template_loaded": [], "on_template_compiled": [], "on_js_loaded": [], "on_css_loaded": [], } urls = [ URLRoute(path="dummy-view/", handler=dummy_view, name="dummy"), URLRoute(path="dummy-view-2///", handler=dummy_view_2, name="dummy-2"), ] def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None: # NOTE: Store only component name to avoid strong references self.calls["on_component_class_created"].append(ctx.component_cls.__name__) def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None: # NOTE: Store only component name to avoid strong references self.calls["on_component_class_deleted"].append(ctx.component_cls.__name__) def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None: # NOTE: Store only registry object ID to avoid strong references self.calls["on_registry_created"].append(id(ctx.registry)) def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None: # NOTE: Store only registry object ID to avoid strong references self.calls["on_registry_deleted"].append(id(ctx.registry)) def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None: self.calls["on_component_registered"].append(ctx) def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None: self.calls["on_component_unregistered"].append(ctx) def on_component_input(self, ctx: OnComponentInputContext) -> None: self.calls["on_component_input"].append(ctx) def on_component_data(self, ctx: OnComponentDataContext) -> None: self.calls["on_component_data"].append(ctx) def on_component_rendered(self, ctx: OnComponentRenderedContext) -> None: self.calls["on_component_rendered"].append(ctx) def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> None: self.calls["on_slot_rendered"].append(ctx) def on_template_loaded(self, ctx): self.calls["on_template_loaded"].append(ctx) def on_template_compiled(self, ctx): self.calls["on_template_compiled"].append(ctx) def on_js_loaded(self, ctx): self.calls["on_js_loaded"].append(ctx) def on_css_loaded(self, ctx): self.calls["on_css_loaded"].append(ctx) class DummyNestedExtension(ComponentExtension): name = "test_nested_extension" urls = [ URLRoute( path="nested-view/", children=[ URLRoute(path="//", handler=dummy_view_2, name="dummy-2"), ], name="dummy", ), ] class RenderExtension(ComponentExtension): name = "render" class SlotOverrideExtension(ComponentExtension): name = "slot_override" def on_slot_rendered(self, ctx: OnSlotRenderedContext): return "OVERRIDEN BY EXTENSION" class ErrorOnComponentRenderedExtension(ComponentExtension): name = "error_on_component_rendered" def on_component_rendered(self, ctx: OnComponentRenderedContext): raise RuntimeError("Custom error from extension") class ReturnHtmlOnComponentRenderedExtension(ComponentExtension): name = "return_html_on_component_rendered" def on_component_rendered(self, ctx: OnComponentRenderedContext): return f"
OVERRIDDEN: {ctx.result}
" def with_component_cls(on_created: Callable): class TempComponent(Component): template = "Hello {{ name }}!" def get_template_data(self, args, kwargs, slots, context): return {"name": kwargs.get("name", "World")} on_created() def with_registry(on_created: Callable): registry = ComponentRegistry() on_created(registry) class OverrideAssetExtension(ComponentExtension): name = "override_asset_extension" def on_template_loaded(self, ctx): return "OVERRIDDEN TEMPLATE" def on_js_loaded(self, ctx): return "OVERRIDDEN JS" def on_css_loaded(self, ctx): return "OVERRIDDEN CSS" @djc_test class TestExtension: @djc_test(components_settings={"extensions": [DummyExtension]}) def test_extensions_setting(self): assert len(app_settings.EXTENSIONS) == 6 assert isinstance(app_settings.EXTENSIONS[0], CacheExtension) assert isinstance(app_settings.EXTENSIONS[1], DefaultsExtension) assert isinstance(app_settings.EXTENSIONS[2], DependenciesExtension) assert isinstance(app_settings.EXTENSIONS[3], ViewExtension) assert isinstance(app_settings.EXTENSIONS[4], DebugHighlightExtension) assert isinstance(app_settings.EXTENSIONS[5], DummyExtension) @djc_test(components_settings={"extensions": [DummyExtension]}) def test_access_component_from_extension(self): class TestAccessComp(Component): template = "Hello {{ name }}!" def get_template_data(self, args, kwargs, slots, context): return {"name": kwargs.get("name", "World")} ext_class = TestAccessComp.TestExtension # type: ignore[attr-defined] assert issubclass(ext_class, ComponentExtension.ComponentConfig) assert ext_class.component_class is TestAccessComp # NOTE: Required for test_component_class_lifecycle_hooks to work del TestAccessComp gc.collect() def test_raises_on_extension_name_conflict(self): @djc_test(components_settings={"extensions": [RenderExtension]}) def inner(): pass with pytest.raises(ValueError, match="Extension name 'render' conflicts with existing Component class API"): inner() def test_raises_on_multiple_extensions_with_same_name(self): @djc_test(components_settings={"extensions": [DummyExtension, DummyExtension]}) def inner(): pass with pytest.raises(ValueError, match="Multiple extensions cannot have the same name 'test_extension'"): inner() @djc_test class TestExtensionHooks: @djc_test(components_settings={"extensions": [DummyExtension]}) def test_component_class_lifecycle_hooks(self): extension = cast(DummyExtension, app_settings.EXTENSIONS[5]) assert len(extension.calls["on_component_class_created"]) == 0 assert len(extension.calls["on_component_class_deleted"]) == 0 did_call_on_comp_cls_created = False def on_comp_cls_created(): nonlocal did_call_on_comp_cls_created did_call_on_comp_cls_created = True # Verify on_component_class_created was called assert len(extension.calls["on_component_class_created"]) == 1 assert extension.calls["on_component_class_created"][0] == "TempComponent" # Create a component class in a separate scope, to avoid any references from within # this test function, so we can garbage collect it after the function returns with_component_cls(on_comp_cls_created) assert did_call_on_comp_cls_created # This should trigger the garbage collection of the component class gc.collect() # Verify on_component_class_deleted was called # NOTE: The previous test, `test_access_component_from_extension`, is sometimes # garbage-collected too late, in which case it's included in `on_component_class_deleted`. # So in the test we check only for the last call. assert len(extension.calls["on_component_class_deleted"]) >= 1 assert extension.calls["on_component_class_deleted"][-1] == "TempComponent" @djc_test(components_settings={"extensions": [DummyExtension]}) def test_registry_lifecycle_hooks(self): extension = cast(DummyExtension, app_settings.EXTENSIONS[5]) assert len(extension.calls["on_registry_created"]) == 0 assert len(extension.calls["on_registry_deleted"]) == 0 did_call_on_registry_created = False reg_id = None def on_registry_created(reg): nonlocal did_call_on_registry_created nonlocal reg_id did_call_on_registry_created = True reg_id = id(reg) # Verify on_registry_created was called assert len(extension.calls["on_registry_created"]) == 1 assert extension.calls["on_registry_created"][0] == reg_id with_registry(on_registry_created) assert did_call_on_registry_created assert reg_id is not None gc.collect() # Verify on_registry_deleted was called assert len(extension.calls["on_registry_deleted"]) == 1 assert extension.calls["on_registry_deleted"][0] == reg_id @djc_test(components_settings={"extensions": [DummyExtension]}) def test_component_registration_hooks(self): class TestComponent(Component): template = "Hello {{ name }}!" def get_template_data(self, args, kwargs, slots, context): return {"name": kwargs.get("name", "World")} registry.register("test_comp", TestComponent) extension = cast(DummyExtension, app_settings.EXTENSIONS[5]) # Verify on_component_registered was called assert len(extension.calls["on_component_registered"]) == 1 reg_call: OnComponentRegisteredContext = extension.calls["on_component_registered"][0] assert reg_call.registry == registry assert reg_call.name == "test_comp" assert reg_call.component_cls == TestComponent registry.unregister("test_comp") # Verify on_component_unregistered was called assert len(extension.calls["on_component_unregistered"]) == 1 unreg_call: OnComponentUnregisteredContext = extension.calls["on_component_unregistered"][0] assert unreg_call.registry == registry assert unreg_call.name == "test_comp" assert unreg_call.component_cls == TestComponent @djc_test(components_settings={"extensions": [DummyExtension]}) def test_component_render_hooks(self): @register("test_comp") class TestComponent(Component): template = "Hello {{ name }}!" def get_template_data(self, args, kwargs, slots, context): return {"name": kwargs.get("name", "World")} def get_js_data(self, args, kwargs, slots, context): return {"script": "console.log('Hello!')"} def get_css_data(self, args, kwargs, slots, context): return {"style": "body { color: blue; }"} # Render the component with some args and kwargs test_context = Context({"foo": "bar"}) test_slots = {"content": "Some content"} TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots) extension = cast(DummyExtension, app_settings.EXTENSIONS[5]) # Verify on_component_input was called with correct args assert len(extension.calls["on_component_input"]) == 1 input_call: OnComponentInputContext = extension.calls["on_component_input"][0] assert input_call.component_cls == TestComponent assert isinstance(input_call.component_id, str) assert input_call.args == ["arg1", "arg2"] assert input_call.kwargs == {"name": "Test"} assert len(input_call.slots) == 1 assert isinstance(input_call.slots["content"], Slot) assert input_call.context == test_context # Verify on_component_data was called with correct args assert len(extension.calls["on_component_data"]) == 1 data_call: OnComponentDataContext = extension.calls["on_component_data"][0] assert data_call.component_cls == TestComponent assert isinstance(data_call.component_id, str) assert data_call.template_data == {"name": "Test"} assert data_call.js_data == {"script": "console.log('Hello!')"} assert data_call.css_data == {"style": "body { color: blue; }"} # Verify on_component_rendered was called with correct args assert len(extension.calls["on_component_rendered"]) == 1 rendered_call: OnComponentRenderedContext = extension.calls["on_component_rendered"][0] assert rendered_call.component_cls == TestComponent assert isinstance(rendered_call.component, TestComponent) assert isinstance(rendered_call.component_id, str) assert rendered_call.result == "Hello Test!" assert rendered_call.error is None @djc_test(components_settings={"extensions": [DummyExtension]}) def test_component_render_hooks__error(self): @register("test_comp") class TestComponent(Component): template = "Hello {{ name }}!" def on_render_after(self, context, template, result, error): raise Exception("Oopsie woopsie") with pytest.raises(Exception, match="Oopsie woopsie"): # Render the component with some args and kwargs TestComponent.render( context=Context({"foo": "bar"}), args=("arg1", "arg2"), kwargs={"name": "Test"}, slots={"content": "Some content"}, ) extension = cast(DummyExtension, app_settings.EXTENSIONS[5]) # Verify on_component_rendered was called with correct args assert len(extension.calls["on_component_rendered"]) == 1 rendered_call: OnComponentRenderedContext = extension.calls["on_component_rendered"][0] assert rendered_call.component_cls == TestComponent assert isinstance(rendered_call.component, TestComponent) assert isinstance(rendered_call.component_id, str) assert rendered_call.result is None assert isinstance(rendered_call.error, Exception) assert str(rendered_call.error) == "An error occured while rendering components TestComponent:\nOopsie woopsie" @djc_test(components_settings={"extensions": [DummyExtension]}) def test_on_slot_rendered(self): @register("test_comp") class TestComponent(Component): template = "Hello {% slot 'content' required default / %}!" # Render the component with some args and kwargs test_context = Context({"foo": "bar"}) rendered = TestComponent.render( context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots={"content": "Some content"}, ) assert rendered == "Hello Some content!" extension = cast(DummyExtension, app_settings.EXTENSIONS[5]) # Verify on_slot_rendered was called with correct args assert len(extension.calls["on_slot_rendered"]) == 1 slot_call: OnSlotRenderedContext = extension.calls["on_slot_rendered"][0] assert isinstance(slot_call.component, TestComponent) assert slot_call.component_cls == TestComponent assert slot_call.component_id == "ca1bc3e" assert isinstance(slot_call.slot, Slot) assert slot_call.slot_name == "content" assert isinstance(slot_call.slot_node, SlotNode) assert slot_call.slot_node.template_name.endswith("test_extension.py::TestComponent") # type: ignore assert slot_call.slot_node.template_component == TestComponent assert slot_call.slot_is_required is True assert slot_call.slot_is_default is True assert slot_call.result == "Some content" @djc_test(components_settings={"extensions": [SlotOverrideExtension]}) def test_on_slot_rendered__override(self): @register("test_comp") class TestComponent(Component): template = "Hello {% slot 'content' required default / %}!" rendered = TestComponent.render( slots={"content": "Some content"}, ) assert rendered == "Hello OVERRIDEN BY EXTENSION!" @djc_test(components_settings={"extensions": [ErrorOnComponentRenderedExtension]}) def test_on_component_rendered__error_from_extension(self): @register("test_comp_error_ext") class TestComponent(Component): template = "Hello {{ name }}!" def get_template_data(self, args, kwargs, slots, context): return {"name": kwargs.get("name", "World")} with pytest.raises(RuntimeError, match="Custom error from extension"): TestComponent.render(args=(), kwargs={"name": "Test"}) @djc_test(components_settings={"extensions": [ReturnHtmlOnComponentRenderedExtension]}) def test_on_component_rendered__return_html_from_extension(self): @register("test_comp_html_ext") class TestComponent(Component): template = "Hello {{ name }}!" def get_template_data(self, args, kwargs, slots, context): return {"name": kwargs.get("name", "World")} rendered = TestComponent.render(args=(), kwargs={"name": "Test"}) assert rendered == "
OVERRIDDEN: Hello Test!
" @djc_test(components_settings={"extensions": [DummyExtension]}) def test_asset_hooks__inlined(self): @register("test_comp_hooks") class TestComponent(Component): template = "Hello {{ name }}!" js = "console.log('hi');" css = "body { color: red; }" def get_template_data(self, args, kwargs, slots, context): return {"name": kwargs.get("name", "World")} # Render the component to trigger all hooks TestComponent.render(args=(), kwargs={"name": "Test"}) extension = cast(DummyExtension, app_settings.EXTENSIONS[5]) # on_template_loaded assert len(extension.calls["on_template_loaded"]) == 1 ctx1: OnTemplateLoadedContext = extension.calls["on_template_loaded"][0] assert ctx1.component_cls == TestComponent assert ctx1.content == "Hello {{ name }}!" assert isinstance(ctx1.origin, Origin) assert ctx1.origin.name.endswith("test_extension.py::TestComponent") assert ctx1.name is None # on_template_compiled assert len(extension.calls["on_template_compiled"]) == 1 ctx2: OnTemplateCompiledContext = extension.calls["on_template_compiled"][0] assert ctx2.component_cls == TestComponent assert isinstance(ctx2.template, Template) # on_js_loaded assert len(extension.calls["on_js_loaded"]) == 1 ctx3: OnJsLoadedContext = extension.calls["on_js_loaded"][0] assert ctx3.component_cls == TestComponent assert ctx3.content == "console.log('hi');" # on_css_loaded assert len(extension.calls["on_css_loaded"]) == 1 ctx4: OnCssLoadedContext = extension.calls["on_css_loaded"][0] assert ctx4.component_cls == TestComponent assert ctx4.content == "body { color: red; }" @djc_test(components_settings={"extensions": [DummyExtension]}) def test_asset_hooks__file(self): @register("test_comp_hooks") class TestComponent(Component): template_file = "relative_file/relative_file.html" js_file = "relative_file/relative_file.js" css_file = "relative_file/relative_file.css" def get_template_data(self, args, kwargs, slots, context): return {"name": kwargs.get("name", "World")} # Render the component to trigger all hooks TestComponent.render(args=(), kwargs={"name": "Test"}) extension = cast(DummyExtension, app_settings.EXTENSIONS[5]) # on_template_loaded # NOTE: The template file gets picked up by 'django.template.loaders.filesystem.Loader', # as well as our own loader, so we get two calls here. assert len(extension.calls["on_template_loaded"]) == 2 ctx1: OnTemplateLoadedContext = extension.calls["on_template_loaded"][0] assert ctx1.component_cls == TestComponent assert ctx1.content == ( '
\n' ' {% csrf_token %}\n' ' \n' ' \n' '
\n' ) assert isinstance(ctx1.origin, Origin) assert ctx1.origin.name.endswith("relative_file.html") assert ctx1.name == "relative_file/relative_file.html" # on_template_compiled assert len(extension.calls["on_template_compiled"]) == 2 ctx2: OnTemplateCompiledContext = extension.calls["on_template_compiled"][0] assert ctx2.component_cls == TestComponent assert isinstance(ctx2.template, Template) # on_js_loaded assert len(extension.calls["on_js_loaded"]) == 1 ctx3: OnJsLoadedContext = extension.calls["on_js_loaded"][0] assert ctx3.component_cls == TestComponent assert ctx3.content == 'console.log("JS file");\n' # on_css_loaded assert len(extension.calls["on_css_loaded"]) == 1 ctx4: OnCssLoadedContext = extension.calls["on_css_loaded"][0] assert ctx4.component_cls == TestComponent assert ctx4.content == ( '.html-css-only {\n' ' color: blue;\n' '}\n' ) @djc_test(components_settings={"extensions": [OverrideAssetExtension]}) def test_asset_hooks_override(self): @register("test_comp_override") class TestComponent(Component): template = "Hello {{ name }}!" js = "console.log('hi');" css = "body { color: red; }" def get_template_data(self, args, kwargs, slots, context): return {"name": kwargs.get("name", "World")} # No need to render, accessing the attributes should trigger the hooks assert TestComponent.template == "OVERRIDDEN TEMPLATE" assert TestComponent.js == "OVERRIDDEN JS" assert TestComponent.css == "OVERRIDDEN CSS" @djc_test class TestExtensionViews: @djc_test(components_settings={"extensions": [DummyExtension]}) def test_views(self): client = Client() # Check basic view response = client.get("/components/ext/test_extension/dummy-view/") assert response.status_code == 200 assert response.content == b"Hello, world!" # Check that URL parameters are passed to the view response2 = client.get("/components/ext/test_extension/dummy-view-2/123/John/") assert response2.status_code == 200 assert response2.content == b"Hello, world! 123 John" @djc_test(components_settings={"extensions": [DummyNestedExtension]}) def test_nested_views(self): client = Client() # Check basic view # NOTE: Since the parent route contains child routes, the parent route should not be matched response = client.get("/components/ext/test_nested_extension/nested-view/") assert response.status_code == 404 # Check that URL parameters are passed to the view response2 = client.get("/components/ext/test_nested_extension/nested-view/123/John/") assert response2.status_code == 200 assert response2.content == b"Hello, world! 123 John" @djc_test class TestExtensionDefaults: @djc_test( components_settings={ "extensions": [DummyExtension], "extensions_defaults": { "test_extension": {}, }, } ) def test_no_defaults(self): class TestComponent(Component): template = "Hello" dummy_ext_cls: DummyExtension.ComponentConfig = TestComponent.TestExtension # type: ignore[attr-defined] assert dummy_ext_cls.foo == "1" assert dummy_ext_cls.bar == "2" assert dummy_ext_cls.baz() == "3" @djc_test( components_settings={ "extensions": [DummyExtension], "extensions_defaults": { "test_extension": { "foo": "NEW_FOO", "baz": classmethod(lambda self: "OVERRIDEN"), }, "nonexistent": { "1": "2", }, }, } ) def test_defaults(self): class TestComponent(Component): template = "Hello" dummy_ext_cls: DummyExtension.ComponentConfig = TestComponent.TestExtension # type: ignore[attr-defined] assert dummy_ext_cls.foo == "NEW_FOO" assert dummy_ext_cls.bar == "2" assert dummy_ext_cls.baz() == "OVERRIDEN" @djc_test class TestLegacyApi: # TODO_V1 - Remove @djc_test( components_settings={ "extensions": [LegacyExtension], } ) def test_extension_class(self): class TestComponent(Component): template = "Hello" dummy_ext_cls: LegacyExtension.ExtensionClass = TestComponent.Legacy # type: ignore[attr-defined] assert dummy_ext_cls.foo == "1" assert dummy_ext_cls.bar == "2" assert dummy_ext_cls.baz() == "3"