From 7e97489633c1a3bdf7776cb31e023ccfc5e5fa97 Mon Sep 17 00:00:00 2001 From: Shaheed Haque Date: Mon, 9 Oct 2023 20:48:01 +0100 Subject: [PATCH 1/6] WIP Jinja support. --- src/reactpy_django/templatetags/jinja.py | 67 ++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/reactpy_django/templatetags/jinja.py diff --git a/src/reactpy_django/templatetags/jinja.py b/src/reactpy_django/templatetags/jinja.py new file mode 100644 index 00000000..a851a4d9 --- /dev/null +++ b/src/reactpy_django/templatetags/jinja.py @@ -0,0 +1,67 @@ +# Copyright © 2023 Innovatie Ltd. All rights reserved. +""" +Jinja support. +""" +import typing as t + +from django.template import RequestContext, loader +from jinja2 import pass_context +from jinja2.ext import Extension +from jinja2.runtime import Context, Undefined + +from .reactpy import component as djt_component +from .. import config + + +class ReactPyExtension(Extension): + """ + Jinja has more expressive power than core Django's templates, and can + directly handle expansions such as: + + {{ component(*args, **kwargs) }} + """ + DJT_TEMPLATE = 'reactpy/component.html' + # + # Therefore, there is no new tag to parse(). + # + tags = {} + + def __init__(self, environment): + super().__init__(environment) + # + # All we need is to add global "component" to the environment. + # + environment.globals["component"] = self._jinja_component + + @pass_context + def _jinja_component(self, __context: Context, dotted_path: str, *args: t.Any, host: str | None = None, + prerender: str = str(config.REACTPY_PRERENDER), **kwargs: t.Any) -> t.Union[t.Any, Undefined]: + """ + This method is used to embed an existing ReactPy component into your + Jinja2 template. + + Args: + dotted_path: String of the fully qualified name of a component. + *args: The positional arguments to provide to the component. + + Keyword Args: + class: The HTML class to apply to the top-level component div. + key: Force the component's root node to use a specific key value. \ + Using key within a template tag is effectively useless. + host: The host to use for the ReactPy connections. If set to `None`, \ + the host will be automatically configured. \ + Example values include: `localhost:8000`, `example.com`, `example.com/subdir` + prerender: Configures whether to pre-render this component, which \ + enables SEO compatibility and reduces perceived latency. + **kwargs: The keyword arguments to provide to the component. + + Returns: + Whatever the components returns. + """ + djt_context = RequestContext(__context.parent['request'], autoescape=__context.eval_ctx.autoescape) + context = djt_component(djt_context, dotted_path, *args, host=host, prerender=prerender, **kwargs) + # + # TODO: can this be usefully cached? + # + result = loader.render_to_string(self.DJT_TEMPLATE, context, __context.parent['request']) + return result From d43263965787005e9569abf989f0fb9e04fd5765 Mon Sep 17 00:00:00 2001 From: Shaheed Haque Date: Thu, 12 Oct 2023 18:39:33 +0100 Subject: [PATCH 2/6] Address second pass review comments. --- src/reactpy_django/templatetags/jinja.py | 29 +++++++----------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/reactpy_django/templatetags/jinja.py b/src/reactpy_django/templatetags/jinja.py index a851a4d9..7f620e78 100644 --- a/src/reactpy_django/templatetags/jinja.py +++ b/src/reactpy_django/templatetags/jinja.py @@ -2,15 +2,12 @@ """ Jinja support. """ -import typing as t - from django.template import RequestContext, loader from jinja2 import pass_context from jinja2.ext import Extension -from jinja2.runtime import Context, Undefined - -from .reactpy import component as djt_component -from .. import config +from jinja2.runtime import Context +from reactpy_django import config +from reactpy_django.templatetags.reactpy import component class ReactPyExtension(Extension): @@ -31,11 +28,10 @@ def __init__(self, environment): # # All we need is to add global "component" to the environment. # - environment.globals["component"] = self._jinja_component + environment.globals["component"] = self.template_tag @pass_context - def _jinja_component(self, __context: Context, dotted_path: str, *args: t.Any, host: str | None = None, - prerender: str = str(config.REACTPY_PRERENDER), **kwargs: t.Any) -> t.Union[t.Any, Undefined]: + def template_tag(self, jinja_context: Context, dotted_path: str, *args, **kwargs) -> str: """ This method is used to embed an existing ReactPy component into your Jinja2 template. @@ -45,23 +41,14 @@ def _jinja_component(self, __context: Context, dotted_path: str, *args: t.Any, h *args: The positional arguments to provide to the component. Keyword Args: - class: The HTML class to apply to the top-level component div. - key: Force the component's root node to use a specific key value. \ - Using key within a template tag is effectively useless. - host: The host to use for the ReactPy connections. If set to `None`, \ - the host will be automatically configured. \ - Example values include: `localhost:8000`, `example.com`, `example.com/subdir` - prerender: Configures whether to pre-render this component, which \ - enables SEO compatibility and reduces perceived latency. **kwargs: The keyword arguments to provide to the component. Returns: Whatever the components returns. """ - djt_context = RequestContext(__context.parent['request'], autoescape=__context.eval_ctx.autoescape) - context = djt_component(djt_context, dotted_path, *args, host=host, prerender=prerender, **kwargs) + django_context = RequestContext(jinja_context.parent['request'], autoescape=jinja_context.eval_ctx.autoescape) + template_context = component(django_context, dotted_path, *args, **kwargs) # # TODO: can this be usefully cached? # - result = loader.render_to_string(self.DJT_TEMPLATE, context, __context.parent['request']) - return result + return loader.render_to_string(self.DJT_TEMPLATE, template_context, jinja_context.parent['request']) From d888b4dd5e7d7e0a22645a6dfe682de3c35303a0 Mon Sep 17 00:00:00 2001 From: Shaheed Haque Date: Sun, 26 Nov 2023 20:12:36 +0000 Subject: [PATCH 3/6] Address lastoutstadnngcomment, and run black. --- src/reactpy_django/templatetags/jinja.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/reactpy_django/templatetags/jinja.py b/src/reactpy_django/templatetags/jinja.py index 7f620e78..2f194a72 100644 --- a/src/reactpy_django/templatetags/jinja.py +++ b/src/reactpy_django/templatetags/jinja.py @@ -9,6 +9,11 @@ from reactpy_django import config from reactpy_django.templatetags.reactpy import component +# +# Point to our non-Django analogue. +# +DJT_TEMPLATE = "reactpy/component.html" + class ReactPyExtension(Extension): """ @@ -17,7 +22,7 @@ class ReactPyExtension(Extension): {{ component(*args, **kwargs) }} """ - DJT_TEMPLATE = 'reactpy/component.html' + # # Therefore, there is no new tag to parse(). # @@ -31,7 +36,9 @@ def __init__(self, environment): environment.globals["component"] = self.template_tag @pass_context - def template_tag(self, jinja_context: Context, dotted_path: str, *args, **kwargs) -> str: + def template_tag( + self, jinja_context: Context, dotted_path: str, *args, **kwargs + ) -> str: """ This method is used to embed an existing ReactPy component into your Jinja2 template. @@ -46,9 +53,14 @@ def template_tag(self, jinja_context: Context, dotted_path: str, *args, **kwargs Returns: Whatever the components returns. """ - django_context = RequestContext(jinja_context.parent['request'], autoescape=jinja_context.eval_ctx.autoescape) + django_context = RequestContext( + jinja_context.parent["request"], + autoescape=jinja_context.eval_ctx.autoescape, + ) template_context = component(django_context, dotted_path, *args, **kwargs) # # TODO: can this be usefully cached? # - return loader.render_to_string(self.DJT_TEMPLATE, template_context, jinja_context.parent['request']) + return loader.render_to_string( + DJT_TEMPLATE, template_context, jinja_context.parent["request"] + ) From 3f56d4e4b5597899f69ef67dc246ff1c10baece4 Mon Sep 17 00:00:00 2001 From: Shaheed Haque Date: Tue, 28 Nov 2023 17:23:30 +0000 Subject: [PATCH 4/6] Address copyright, and use of constant to locate template. --- src/reactpy_django/templatetags/jinja.py | 11 ++--------- src/reactpy_django/templatetags/reactpy.py | 3 ++- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/reactpy_django/templatetags/jinja.py b/src/reactpy_django/templatetags/jinja.py index 2f194a72..38b14b76 100644 --- a/src/reactpy_django/templatetags/jinja.py +++ b/src/reactpy_django/templatetags/jinja.py @@ -1,4 +1,3 @@ -# Copyright © 2023 Innovatie Ltd. All rights reserved. """ Jinja support. """ @@ -6,13 +5,7 @@ from jinja2 import pass_context from jinja2.ext import Extension from jinja2.runtime import Context -from reactpy_django import config -from reactpy_django.templatetags.reactpy import component - -# -# Point to our non-Django analogue. -# -DJT_TEMPLATE = "reactpy/component.html" +from reactpy_django.templatetags.reactpy import COMPONENT_TEMPLATE, component class ReactPyExtension(Extension): @@ -62,5 +55,5 @@ def template_tag( # TODO: can this be usefully cached? # return loader.render_to_string( - DJT_TEMPLATE, template_context, jinja_context.parent["request"] + COMPONENT_TEMPLATE, template_context, jinja_context.parent["request"] ) diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 209c0ec8..97dd99f9 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -27,11 +27,12 @@ RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/") except NoReverseMatch: RESOLVED_WEB_MODULES_PATH = "" +COMPONENT_TEMPLATE = "reactpy/component.html" register = template.Library() _logger = getLogger(__name__) -@register.inclusion_tag("reactpy/component.html", takes_context=True) +@register.inclusion_tag(COMPONENT_TEMPLATE, takes_context=True) def component( context: template.RequestContext, dotted_path: str, From 94fd2d47d5b932b453d36a677fb89fd8c28fca3c Mon Sep 17 00:00:00 2001 From: User Date: Sun, 19 Jul 2026 20:22:00 -0700 Subject: [PATCH 5/6] Add Jinja2 template support for ReactPy components - Created `ReactPyExtension` Jinja2 extension with `component`, `pyscript_component`, and `pyscript_setup` globals that delegate to the existing Django template tag implementations. - Refactored `reactpy.py` to export template name constants (COMPONENT_TEMPLATE, PYSCRIPT_COMPONENT_TEMPLATE, PYSCRIPT_SETUP_TEMPLATE) so they can be reused by the Jinja2 extension. - Added JINJA_COMPONENT_REGEX to utils.py's RootComponentFinder so that {{ component(...) }} syntax in Jinja2 templates is auto-detected. - Added test infrastructure: settings_jinja.py, jinja_env.py, jinja_views.py, jinja_urls.py, Jinja2 template files, and test_jinja.py. - Made test_app/__init__.py gracefully handle missing `bun` binary by skipping the JS rebuild when artifacts already exist. - Added jinja2 to pyproject.toml hatch-test extra-dependencies. - Updated CHANGELOG.md. (cherry picked from commit 3923cee0aa8f37a0346e59e05bf042275d32ca0f) --- CHANGELOG.md | 1 + pyproject.toml | 1 + src/reactpy_django/templatetags/jinja.py | 209 ++- src/reactpy_django/templatetags/reactpy.py | 6 +- src/reactpy_django/utils.py | 1116 +++++++++-------- tests/test_app/__init__.py | 18 +- tests/test_app/jinja2_templates/base.html | 97 ++ tests/test_app/jinja2_templates/errors.html | 34 + .../test_app/jinja2_templates/host_port.html | 20 + .../host_port_roundrobin.html | 22 + tests/test_app/jinja2_templates/offline.html | 19 + .../test_app/jinja2_templates/prerender.html | 33 + tests/test_app/jinja_env.py | 12 + tests/test_app/jinja_urls.py | 10 + tests/test_app/jinja_views.py | 15 + tests/test_app/settings_jinja.py | 147 +++ tests/test_app/tests/test_jinja.py | 112 ++ tests/test_app/urls.py | 1 + 18 files changed, 1284 insertions(+), 589 deletions(-) create mode 100644 tests/test_app/jinja2_templates/base.html create mode 100644 tests/test_app/jinja2_templates/errors.html create mode 100644 tests/test_app/jinja2_templates/host_port.html create mode 100644 tests/test_app/jinja2_templates/host_port_roundrobin.html create mode 100644 tests/test_app/jinja2_templates/offline.html create mode 100644 tests/test_app/jinja2_templates/prerender.html create mode 100644 tests/test_app/jinja_env.py create mode 100644 tests/test_app/jinja_urls.py create mode 100644 tests/test_app/jinja_views.py create mode 100644 tests/test_app/settings_jinja.py create mode 100644 tests/test_app/tests/test_jinja.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8526d721..c07a5fa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Don't forget to remove deprecated code on each major release! ### Added - Automatically serve ReactPy wheel from Django's static directory when using PyScript. +- Jinja2 template support via `reactpy_django.templatetags.jinja.ReactPyExtension`. ### Changed diff --git a/pyproject.toml b/pyproject.toml index 42e1b1f0..7c47254e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,7 @@ extra-dependencies = [ "django-bootstrap5", "decorator", "uvicorn[standard]", + "jinja2", ] matrix-name-format = "{variable}-{value}" diff --git a/src/reactpy_django/templatetags/jinja.py b/src/reactpy_django/templatetags/jinja.py index 38b14b76..8b47c01a 100644 --- a/src/reactpy_django/templatetags/jinja.py +++ b/src/reactpy_django/templatetags/jinja.py @@ -1,59 +1,210 @@ """ -Jinja support. +Jinja2 template support for ReactPy-Django. + +Provides Jinja2 global functions that mirror the functionality of Django's +``{% component %}``, ``{% pyscript_component %}``, and ``{% pyscript_setup %}`` +template tags. These are registered as environment globals so they can be +called directly from Jinja2 templates via ``{{ component(...) }}`` syntax. + +To enable, add the extension to your Jinja2 environment configuration: + +.. code-block:: python + + TEMPLATES = [ + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": [...], + "OPTIONS": { + "environment": "myproject.jinja_env.environment", + }, + }, + ] + +Then in ``myproject/jinja_env.py``: + +.. code-block:: python + + from jinja2 import Environment + from reactpy_django.templatetags.jinja import ReactPyExtension + + def environment(**options): + env = Environment(**options) + env.add_extension(ReactPyExtension) + return env """ + +from __future__ import annotations + +import json +from logging import getLogger +from typing import TYPE_CHECKING + from django.template import RequestContext, loader +from django.utils.safestring import mark_safe from jinja2 import pass_context from jinja2.ext import Extension from jinja2.runtime import Context -from reactpy_django.templatetags.reactpy import COMPONENT_TEMPLATE, component + +from reactpy_django.templatetags.reactpy import ( + COMPONENT_TEMPLATE, + PYSCRIPT_COMPONENT_TEMPLATE, + PYSCRIPT_SETUP_TEMPLATE, + component as django_component_tag, + pyscript_component as django_pyscript_component_tag, + pyscript_setup as django_pyscript_setup_tag, +) + +if TYPE_CHECKING: + from reactpy.types import Component, VdomDict + +_logger = getLogger(__name__) class ReactPyExtension(Extension): - """ - Jinja has more expressive power than core Django's templates, and can - directly handle expansions such as: + """A Jinja2 extension that adds ReactPy component rendering functions. - {{ component(*args, **kwargs) }} + This extension registers the following globals into the Jinja2 environment: + + * ``component`` - Renders a server-side ReactPy component. + * ``pyscript_component`` - Renders a client-side PyScript component. + * ``pyscript_setup`` - Renders PyScript setup configuration. + + Unlike Django's template tags, which require ``{% load reactpy %}`` and use + ``{% component %}`` syntax, Jinja2 uses ``{{ component(...) }}`` function calls. + This is because Jinja2 has more expressive power and can directly handle + function expansions. """ - # - # Therefore, there is no new tag to parse(). - # tags = {} def __init__(self, environment): super().__init__(environment) - # - # All we need is to add global "component" to the environment. - # - environment.globals["component"] = self.template_tag + environment.globals["component"] = self._component + environment.globals["pyscript_component"] = self._pyscript_component + environment.globals["pyscript_setup"] = self._pyscript_setup @pass_context - def template_tag( - self, jinja_context: Context, dotted_path: str, *args, **kwargs + def _component( + self, + jinja_context: Context, + dotted_path: str, + *args, + host: str | None = None, + prerender: str = "", + offline: str = "", + **kwargs, ) -> str: - """ - This method is used to embed an existing ReactPy component into your - Jinja2 template. + """Render a server-side ReactPy component. + + This is the Jinja2 equivalent of ``{% component "path.to.Component" %}``. Args: - dotted_path: String of the fully qualified name of a component. - *args: The positional arguments to provide to the component. + dotted_path: The dotted path to the component to render. + *args: Positional arguments to pass to the component. + host: The host to use for ReactPy connections. + prerender: If ``"true"`` the component will be pre-rendered server-side. + offline: Dotted path to an offline fallback component. + **kwargs: Keyword arguments to pass to the component. - Keyword Args: - **kwargs: The keyword arguments to provide to the component. + Returns: + Rendered HTML string. + """ + request = jinja_context.parent.get("request") + if request is None: + _logger.exception( + "Cannot render a ReactPy component in a Jinja2 template without a " + "request object. Ensure the 'django.template.context_processors.request' " + "context processor is enabled for your Jinja2 backend." + ) + return "" + + django_context = RequestContext( + request, + autoescape=jinja_context.eval_ctx.autoescape, + ) + template_context = django_component_tag( + django_context, + dotted_path, + *args, + host=host, + prerender=prerender, + offline=offline, + **kwargs, + ) + return loader.render_to_string( + COMPONENT_TEMPLATE, + template_context, + request, + ) + + @pass_context + def _pyscript_component( + self, + jinja_context: Context, + *file_paths: str, + initial: str | VdomDict | Component = "", + root: str = "root", + ) -> str: + """Render a client-side PyScript component. + + This is the Jinja2 equivalent of ``{% pyscript_component "path/to/file.py" %}``. + + Args: + file_paths: File paths to client-side component Python files. + initial: Initial HTML displayed before the PyScript component loads. + root: The name of the root component function. Returns: - Whatever the components returns. + Rendered HTML string. """ + request = jinja_context.parent.get("request") + if request is None: + _logger.exception( + "Cannot render a PyScript component in a Jinja2 template without a " + "request object." + ) + return "" + django_context = RequestContext( - jinja_context.parent["request"], + request, autoescape=jinja_context.eval_ctx.autoescape, ) - template_context = component(django_context, dotted_path, *args, **kwargs) - # - # TODO: can this be usefully cached? - # + template_context = django_pyscript_component_tag( + django_context, + *file_paths, + initial=initial, + root=root, + ) + return loader.render_to_string( + PYSCRIPT_COMPONENT_TEMPLATE, + template_context, + request, + ) + + def _pyscript_setup( + self, + *extra_py: str, + extra_js: str | dict = "", + config: str | dict = "", + ) -> str: + """Render PyScript setup configuration. + + This is the Jinja2 equivalent of ``{% pyscript_setup %}``. + + Args: + extra_py: Additional Python dependencies. + extra_js: Additional JavaScript modules. + config: PyScript configuration overrides. + + Returns: + Rendered HTML string. + """ + template_context = django_pyscript_setup_tag( + *extra_py, + extra_js=extra_js, + config=config, + ) return loader.render_to_string( - COMPONENT_TEMPLATE, template_context, jinja_context.parent["request"] + PYSCRIPT_SETUP_TEMPLATE, + template_context, ) diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 3c508023..c6cd67ab 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -46,6 +46,8 @@ _logger.exception("Could not resolve the 'web_modules' URL path!") COMPONENT_TEMPLATE = "reactpy/component.html" +PYSCRIPT_COMPONENT_TEMPLATE = "reactpy/pyscript_component.html" +PYSCRIPT_SETUP_TEMPLATE = "reactpy/pyscript_setup.html" @register.inclusion_tag(COMPONENT_TEMPLATE, takes_context=True) @@ -192,7 +194,7 @@ def component( } -@register.inclusion_tag("reactpy/pyscript_component.html", takes_context=True) +@register.inclusion_tag(PYSCRIPT_COMPONENT_TEMPLATE, takes_context=True) def pyscript_component( context: template.RequestContext, *file_paths: str, @@ -226,7 +228,7 @@ def pyscript_component( } -@register.inclusion_tag("reactpy/pyscript_setup.html") +@register.inclusion_tag(PYSCRIPT_SETUP_TEMPLATE) def pyscript_setup( *extra_py: str, extra_js: str | dict = "", diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 38b7fa66..451f82e3 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -1,553 +1,563 @@ -"""Generic functions that are used throughout the ReactPy Django package.""" - -from __future__ import annotations - -import asyncio -import contextlib -import inspect -import logging -import os -import re -from asyncio import iscoroutinefunction -from concurrent.futures import ThreadPoolExecutor -from fnmatch import fnmatch -from functools import wraps -from importlib import import_module -from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable -from uuid import UUID, uuid4 - -import dill -from channels.db import database_sync_to_async -from django.contrib.staticfiles.finders import find -from django.core.cache import caches -from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects -from django.db.models.base import Model -from django.db.models.query import QuerySet -from django.http import HttpRequest, HttpResponse -from django.template import engines -from django.utils.encoding import smart_str -from reactpy import reactpy_to_string as _reactpy_to_string -from reactpy.core.hooks import ConnectionContext -from reactpy.core.layout import Layout -from reactpy.types import Connection, Location, VdomDict - -from reactpy_django.exceptions import ( - ComponentDoesNotExistError, - ComponentParamError, - InvalidHostError, - ViewDoesNotExistError, -) - -if TYPE_CHECKING: - from collections.abc import Awaitable, Mapping, Sequence - - from django.views import View - from reactpy.types import ComponentConstructor - - from reactpy_django.types import FuncParams, Inferred - - -_logger = logging.getLogger(__name__) -_TAG_PATTERN = r"(?Pcomponent)" -_PATH_PATTERN = r"""(?P"[^"'\s]+"|'[^"'\s]+')""" -_OFFLINE_KWARG_PATTERN = rf"""(\s*offline\s*=\s*{_PATH_PATTERN.replace(r"", r"")})""" -_GENERIC_KWARG_PATTERN = r"""(\s*.*?)""" -COMMENT_REGEX = re.compile(r"") -COMPONENT_REGEX = re.compile( - r"{%\s*" - + _TAG_PATTERN - + r"\s*" - + _PATH_PATTERN - + rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?" - + r"\s*%}" -) -FILE_ASYNC_ITERATOR_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-FileAsyncIterator") -SYNC_LAYOUT_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-SyncLayout") - - -async def render_view( - view: Callable | View, - request: HttpRequest, - args: Sequence, - kwargs: dict, -) -> HttpResponse: - """Ingests a Django view (class or function) and returns an HTTP response object.""" - # Convert class-based view to function-based view - if getattr(view, "as_view", None): - view = view.as_view() # type: ignore - - # Sync/Async function view - response = await ensure_async(view)(request, *args, **kwargs) # type: ignore - - # TemplateView needs an extra render step - if getattr(response, "render", None): - response = await ensure_async(response.render)() - - return response - - -def register_component(component: ComponentConstructor | str): - """Adds a component to the list of known registered components. - - Args: - component: The component to register. Can be a component function or dotted path to a component. - - """ - from reactpy_django.config import ( - REACTPY_FAILED_COMPONENTS, - REACTPY_REGISTERED_COMPONENTS, - ) - - dotted_path = component if isinstance(component, str) else generate_obj_name(component) - try: - REACTPY_REGISTERED_COMPONENTS[dotted_path] = import_dotted_path(dotted_path) - except AttributeError as e: - REACTPY_FAILED_COMPONENTS.add(dotted_path) - msg = f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}." - raise ComponentDoesNotExistError(msg) from e - - -def register_iframe(view: Callable | View | str): - """Registers a view to be used as an iframe component. - - Args: - view: The view to register. Can be a function or class based view, or a dotted path to a view. - """ - from reactpy_django.config import REACTPY_REGISTERED_IFRAME_VIEWS - - if hasattr(view, "view_class"): - view = view.view_class # type: ignore - dotted_path = view if isinstance(view, str) else generate_obj_name(view) - try: - REACTPY_REGISTERED_IFRAME_VIEWS[dotted_path] = import_dotted_path(dotted_path) - except AttributeError as e: - msg = f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}." - raise ViewDoesNotExistError(msg) from e - - -def import_dotted_path(dotted_path: str) -> Callable: - """Imports a dotted path and returns the callable.""" - module_name, component_name = dotted_path.rsplit(".", 1) - - try: - module = import_module(module_name) - except ImportError as error: - msg = f"Failed to import {module_name!r} while loading {component_name!r}" - raise RuntimeError(msg) from error - - return getattr(module, component_name) - - -class RootComponentFinder: - """Searches Django templates to find and register all root components. - This should only be `run` once on startup to maintain synchronization during mulitprocessing. - """ - - def run(self): - """Registers all ReactPy components found within Django templates.""" - # Get all template folder paths - paths = self.get_paths() - # Get all HTML template files - templates = self.get_templates(paths) - # Get all components - components = self.get_components(templates) - # Register all components - self.register_components(components) - - def get_loaders(self): - """Obtains currently configured template loaders.""" - template_source_loaders = [] - for e in engines.all(): - if hasattr(e, "engine"): - template_source_loaders.extend(e.engine.get_template_loaders(e.engine.loaders)) # type: ignore - loaders = [] - for loader in template_source_loaders: - if hasattr(loader, "loaders"): - loaders.extend(loader.loaders) - else: - loaders.append(loader) - return loaders - - def get_paths(self) -> set[str]: - """Obtains a set of all template directories.""" - paths: set[str] = set() - for loader in self.get_loaders(): - with contextlib.suppress(ImportError, AttributeError, TypeError): - module = import_module(loader.__module__) - get_template_sources = getattr(module, "get_template_sources", None) - if get_template_sources is None: - get_template_sources = loader.get_template_sources - paths.update(smart_str(origin) for origin in get_template_sources("")) - return paths - - def get_templates(self, paths: set[str]) -> set[str]: - """Obtains a set of all HTML template paths.""" - extensions = [".html"] - templates: set[str] = set() - for path in paths: - for root, _, files in os.walk(path, followlinks=False): - templates.update( - os.path.join(root, name) - for name in files - if not name.startswith(".") and any(fnmatch(name, f"*{glob}") for glob in extensions) - ) - - return templates - - def get_components(self, templates: set[str]) -> set[str]: - """Obtains a set of all ReactPy components by parsing HTML templates.""" - components: set[str] = set() - for template in templates: - with contextlib.suppress(Exception), open(template, encoding="utf-8") as template_file: - clean_template = COMMENT_REGEX.sub("", template_file.read()) - regex_iterable = COMPONENT_REGEX.finditer(clean_template) - new_components: list[str] = [] - for match in regex_iterable: - new_components.append(match.group("path").replace('"', "").replace("'", "")) - offline_path = match.group("offline_path") - if offline_path: - new_components.append(offline_path.replace('"', "").replace("'", "")) - components.update(new_components) - if not components: - _logger.warning( - "\033[93m" - "ReactPy did not find any components! " - "You are either not using any ReactPy components, " - "using the template tag incorrectly, " - "or your HTML templates are not registered with Django." - "\033[0m" - ) - return components - - def register_components(self, components: set[str]) -> None: - """Registers all ReactPy components in an iterable.""" - if components: - _logger.debug("Auto-detected ReactPy root components:") - for component in components: - try: - _logger.debug("\t+ %s", component) - register_component(component) - except Exception: - _logger.exception( - "\033[91m" - "ReactPy failed to register component '%s'!\n" - "This component path may not be valid, " - "or an exception may have occurred while importing.\n" - "See the traceback below for more information." - "\033[0m", - component, - ) - - -def generate_obj_name(obj: Any) -> str: - """Makes a best effort to create a name for an object. - Useful for JSON serialization of Python objects.""" - - # First attempt: Create a dotted path by inspecting dunder methods - if hasattr(obj, "__module__"): - if hasattr(obj, "__name__"): - return f"{obj.__module__}.{obj.__name__}" - if hasattr(obj, "__class__") and hasattr(obj.__class__, "__name__"): - return f"{obj.__module__}.{obj.__class__.__name__}" - - # Second attempt: String representation - with contextlib.suppress(Exception): - return str(obj) - - # Fallback: Empty string - return "" - - -def django_query_postprocessor( - data: QuerySet | Model, many_to_many: bool = True, many_to_one: bool = True -) -> QuerySet | Model: - """Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily. - - Behavior can be modified through `postprocessor_kwargs` within your `use_query` hook. - - Args: - data: The `Model` or `QuerySet` to recursively fetch fields from. - - Keyword Args: - many_to_many: Whether or not to recursively fetch `ManyToManyField` relationships. - many_to_one: Whether or not to recursively fetch `ForeignKey` relationships. - - Returns: - The `Model` or `QuerySet` with all fields fetched. - """ - - # `QuerySet`, which is an iterable containing `Model`/`QuerySet` objects. - if isinstance(data, QuerySet): - for model in data: - django_query_postprocessor( - model, - many_to_many=many_to_many, - many_to_one=many_to_one, - ) - - # `Model` instances - elif isinstance(data, Model): - prefetch_fields: list[str] = [] - for field in data._meta.get_fields(): - # Force the query to execute - getattr(data, field.name, None) - - if many_to_one and type(field) is ManyToOneRel: - prefetch_fields.append(field.related_name or f"{field.name}_set") - - elif many_to_many and isinstance(field, ManyToManyField): - prefetch_fields.append(field.name) - - if prefetch_fields: - prefetch_related_objects([data], *prefetch_fields) - for field_str in prefetch_fields: - django_query_postprocessor( - getattr(data, field_str).get_queryset(), - many_to_many=many_to_many, - many_to_one=many_to_one, - ) - - # Unrecognized type - else: - msg = ( - f"Django query postprocessor expected a Model or QuerySet, got {data!r}.\n" - "One of the following may have occurred:\n" - " - You are using a non-Django ORM.\n" - " - You are attempting to use `use_query` to fetch non-ORM data.\n\n" - "If these situations apply, you may want to disable the postprocessor." - ) - raise TypeError(msg) - - return data - - -def validate_component_args(func, *args, **kwargs): - """ - Validate whether a set of args/kwargs would work on the given component. - - Raises `ComponentParamError` if the args/kwargs are invalid. - """ - signature = inspect.signature(func) - - try: - signature.bind(*args, **kwargs) - except TypeError as e: - name = generate_obj_name(func) - msg = f"Invalid args for '{name}'. {str(e).capitalize()}." - raise ComponentParamError(msg) from e - - -def create_cache_key(*args): - """Creates a cache key string that starts with `reactpy_django` contains - all *args separated by `:`.""" - - if not args: - msg = "At least one argument is required to create a cache key." - raise ValueError(msg) - - return f"reactpy_django:{':'.join(str(arg) for arg in args)}" - - -class SyncLayout(Layout): - """Sync adapter for ReactPy's `Layout`. Allows it to be used in Django template tags. - This can be removed when Django supports async template tags. - """ - - def __enter__(self): - self.loop = asyncio.new_event_loop() - SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aenter__()).result() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aexit__(exc_type, exc_val, exc_tb)).result() - self.loop.close() - - def sync_render(self): - return SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.render()).result() - - -def get_pk(model): - """Returns the value of the primary key for a Django model.""" - return getattr(model, model._meta.pk.name) - - -def str_to_bool(val: str) -> bool: - """Convert a string representation of truth to true (1) or false (0). - - True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values - are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if - 'val' is anything else. - """ - val = val.lower() - if val in {"y", "yes", "t", "true", "on", "1"}: - return True - if val in {"n", "no", "f", "false", "off", "0"}: - return False - msg = f"invalid truth value {val}" - raise ValueError(msg) - - -def prerender_component( - user_component: ComponentConstructor, - args: Sequence, - kwargs: Mapping, - uuid: str | UUID, - request: HttpRequest, -) -> str: - """Prerenders a ReactPy component and returns the HTML string.""" - search = request.GET.urlencode() - scope = getattr(request, "scope", {}) - scope["reactpy"] = {"id": str(uuid)} - dir(request.user) # Call `dir` before prerendering to make sure the user object is loaded - - with SyncLayout( - ConnectionContext( - user_component(*args, **kwargs), - value=Connection( - scope=scope, - location=Location(path=request.path, query_string=f"?{search}" if search else ""), - carrier=request, - ), - ) - ) as layout: - vdom_tree = layout.sync_render()["model"] - - return _reactpy_to_string(vdom_tree) # type: ignore - - -def reactpy_to_string(vdom_or_component: Any, request: HttpRequest | None = None, uuid: str | None = None) -> str: - """Converts a VdomDict or component to an HTML string. If a string is provided instead, it will be - automatically returned.""" - if isinstance(vdom_or_component, dict): - return _reactpy_to_string(vdom_or_component) # type: ignore - - if hasattr(vdom_or_component, "render"): - if not request: - request = HttpRequest() - request.method = "GET" - if not uuid: - uuid = uuid4().hex - return prerender_component(vdom_or_component, [], {}, uuid, request) - - if isinstance(vdom_or_component, str): - return vdom_or_component - - msg = f"Invalid type for vdom_or_component: {type(vdom_or_component)}. Expected a VdomDict, component, or string." - raise ValueError(msg) - - -def save_component_params(args, kwargs, uuid) -> None: - """Saves the component parameters to the database. - This is used within our template tag in order to propogate - the parameters between the HTTP and WebSocket stack.""" - from reactpy_django import models - from reactpy_django.types import ComponentParams - - params = ComponentParams(args, kwargs) - model = models.ComponentSession(uuid=uuid, params=dill.dumps(params)) - model.full_clean() - model.save() - - -def validate_host(host: str) -> None: - """Validates the host string to ensure it does not contain a protocol.""" - if "://" in host: - protocol = host.split("://", maxsplit=1)[0] - msg = f"Invalid host provided to component. Contains a protocol '{protocol}://'." - _logger.error(msg) - raise InvalidHostError(msg) - - -class FileAsyncIterator: - """Async iterator that yields chunks of data from the provided async file.""" - - def __init__(self, file_path: str): - self.file_path = file_path - - async def __aiter__(self): - file_handle = None - try: - file_handle = FILE_ASYNC_ITERATOR_THREAD.submit(open, self.file_path, "rb").result() - while True: - chunk = FILE_ASYNC_ITERATOR_THREAD.submit(file_handle.read, 8192).result() - if not chunk: - break - yield chunk - finally: - if file_handle: - file_handle.close() - - -def ensure_async( - func: Callable[FuncParams, Inferred], *, thread_sensitive: bool = True -) -> Callable[FuncParams, Awaitable[Inferred]]: - """Ensure the provided function is always an async coroutine. If the provided function is - not async, it will be adapted.""" - - @wraps(func) - def wrapper(*args, **kwargs): - return ( - func(*args, **kwargs) - if iscoroutinefunction(func) - else database_sync_to_async(func, thread_sensitive=thread_sensitive)(*args, **kwargs) - ) - - return wrapper - - -def cached_static_file(static_path: str) -> str: - from reactpy_django.config import REACTPY_CACHE - - # Try to find the file within Django's static files - abs_path = find(static_path) - if not abs_path: - msg = f"Could not find static file {static_path} within Django's static files." - raise FileNotFoundError(msg) - if isinstance(abs_path, (list, tuple)): - abs_path = abs_path[0] - - # Fetch the file from cache, if available - last_modified_time = os.stat(abs_path).st_mtime - cache_key = f"reactpy_django:static_contents:{static_path}" - file_contents: str | None = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) - if file_contents is None: - with open(abs_path, encoding="utf-8") as static_file: - file_contents = static_file.read() - caches[REACTPY_CACHE].delete(cache_key) - caches[REACTPY_CACHE].set(cache_key, file_contents, timeout=None, version=int(last_modified_time)) - - return file_contents - - -def del_html_head_body_transform(vdom: VdomDict) -> VdomDict: - """Transform intended for use with `string_to_reactpy `. - - Removes ``, ``, and `` while preserving their children. - - Parameters: - vdom: - The VDOM dictionary to transform. - """ - if vdom["tagName"] in {"html", "body", "head"}: - return VdomDict(tagName="", children=vdom.setdefault("children", [])) - return vdom - - -def fetch_cached_python_file(file_path: str, minify: bool = True) -> str: - from reactpy.executors.pyscript.utils import minify_python - - from reactpy_django.config import REACTPY_CACHE - - # Try to get user code from cache - cache_key = create_cache_key("pyscript", file_path) - last_modified_time = os.stat(file_path).st_mtime - file_contents: str = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) - if file_contents: - return file_contents - - file_contents = Path(file_path).read_text(encoding="utf-8").strip() - if minify: - file_contents = minify_python(file_contents) - caches[REACTPY_CACHE].set(cache_key, file_contents, version=int(last_modified_time)) - return file_contents +"""Generic functions that are used throughout the ReactPy Django package.""" + +from __future__ import annotations + +import asyncio +import contextlib +import inspect +import logging +import os +import re +from asyncio import iscoroutinefunction +from concurrent.futures import ThreadPoolExecutor +from fnmatch import fnmatch +from functools import wraps +from importlib import import_module +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable +from uuid import UUID, uuid4 + +import dill +from channels.db import database_sync_to_async +from django.contrib.staticfiles.finders import find +from django.core.cache import caches +from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects +from django.db.models.base import Model +from django.db.models.query import QuerySet +from django.http import HttpRequest, HttpResponse +from django.template import engines +from django.utils.encoding import smart_str +from reactpy import reactpy_to_string as _reactpy_to_string +from reactpy.core.hooks import ConnectionContext +from reactpy.core.layout import Layout +from reactpy.types import Connection, Location, VdomDict + +from reactpy_django.exceptions import ( + ComponentDoesNotExistError, + ComponentParamError, + InvalidHostError, + ViewDoesNotExistError, +) + +if TYPE_CHECKING: + from collections.abc import Awaitable, Mapping, Sequence + + from django.views import View + from reactpy.types import ComponentConstructor + + from reactpy_django.types import FuncParams, Inferred + + +_logger = logging.getLogger(__name__) +_TAG_PATTERN = r"(?Pcomponent)" +_PATH_PATTERN = r"""(?P"[^"'\s]+"|'[^"'\s]+')""" +_OFFLINE_KWARG_PATTERN = rf"""(\s*offline\s*=\s*{_PATH_PATTERN.replace(r"", r"")})""" +_GENERIC_KWARG_PATTERN = r"""(\s*.*?)""" +COMMENT_REGEX = re.compile(r"") +COMPONENT_REGEX = re.compile( + r"{%\s*" + + _TAG_PATTERN + + r"\s*" + + _PATH_PATTERN + + rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?" + + r"\s*%}" +) +JINJA_COMPONENT_REGEX = re.compile( + r"\{\{\s*" + + _TAG_PATTERN + + r"\s*\(" + + r"\s*" + + _PATH_PATTERN + + rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?" + + r"\s*\)\s*\}\}" +) +FILE_ASYNC_ITERATOR_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-FileAsyncIterator") +SYNC_LAYOUT_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-SyncLayout") + + +async def render_view( + view: Callable | View, + request: HttpRequest, + args: Sequence, + kwargs: dict, +) -> HttpResponse: + """Ingests a Django view (class or function) and returns an HTTP response object.""" + # Convert class-based view to function-based view + if getattr(view, "as_view", None): + view = view.as_view() # type: ignore + + # Sync/Async function view + response = await ensure_async(view)(request, *args, **kwargs) # type: ignore + + # TemplateView needs an extra render step + if getattr(response, "render", None): + response = await ensure_async(response.render)() + + return response + + +def register_component(component: ComponentConstructor | str): + """Adds a component to the list of known registered components. + + Args: + component: The component to register. Can be a component function or dotted path to a component. + + """ + from reactpy_django.config import ( + REACTPY_FAILED_COMPONENTS, + REACTPY_REGISTERED_COMPONENTS, + ) + + dotted_path = component if isinstance(component, str) else generate_obj_name(component) + try: + REACTPY_REGISTERED_COMPONENTS[dotted_path] = import_dotted_path(dotted_path) + except AttributeError as e: + REACTPY_FAILED_COMPONENTS.add(dotted_path) + msg = f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}." + raise ComponentDoesNotExistError(msg) from e + + +def register_iframe(view: Callable | View | str): + """Registers a view to be used as an iframe component. + + Args: + view: The view to register. Can be a function or class based view, or a dotted path to a view. + """ + from reactpy_django.config import REACTPY_REGISTERED_IFRAME_VIEWS + + if hasattr(view, "view_class"): + view = view.view_class # type: ignore + dotted_path = view if isinstance(view, str) else generate_obj_name(view) + try: + REACTPY_REGISTERED_IFRAME_VIEWS[dotted_path] = import_dotted_path(dotted_path) + except AttributeError as e: + msg = f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}." + raise ViewDoesNotExistError(msg) from e + + +def import_dotted_path(dotted_path: str) -> Callable: + """Imports a dotted path and returns the callable.""" + module_name, component_name = dotted_path.rsplit(".", 1) + + try: + module = import_module(module_name) + except ImportError as error: + msg = f"Failed to import {module_name!r} while loading {component_name!r}" + raise RuntimeError(msg) from error + + return getattr(module, component_name) + + +class RootComponentFinder: + """Searches Django templates to find and register all root components. + This should only be `run` once on startup to maintain synchronization during mulitprocessing. + """ + + def run(self): + """Registers all ReactPy components found within Django templates.""" + # Get all template folder paths + paths = self.get_paths() + # Get all HTML template files + templates = self.get_templates(paths) + # Get all components + components = self.get_components(templates) + # Register all components + self.register_components(components) + + def get_loaders(self): + """Obtains currently configured template loaders.""" + template_source_loaders = [] + for e in engines.all(): + if hasattr(e, "engine"): + template_source_loaders.extend(e.engine.get_template_loaders(e.engine.loaders)) # type: ignore + loaders = [] + for loader in template_source_loaders: + if hasattr(loader, "loaders"): + loaders.extend(loader.loaders) + else: + loaders.append(loader) + return loaders + + def get_paths(self) -> set[str]: + """Obtains a set of all template directories.""" + paths: set[str] = set() + for loader in self.get_loaders(): + with contextlib.suppress(ImportError, AttributeError, TypeError): + module = import_module(loader.__module__) + get_template_sources = getattr(module, "get_template_sources", None) + if get_template_sources is None: + get_template_sources = loader.get_template_sources + paths.update(smart_str(origin) for origin in get_template_sources("")) + return paths + + def get_templates(self, paths: set[str]) -> set[str]: + """Obtains a set of all HTML template paths.""" + extensions = [".html"] + templates: set[str] = set() + for path in paths: + for root, _, files in os.walk(path, followlinks=False): + templates.update( + os.path.join(root, name) + for name in files + if not name.startswith(".") and any(fnmatch(name, f"*{glob}") for glob in extensions) + ) + + return templates + + def get_components(self, templates: set[str]) -> set[str]: + """Obtains a set of all ReactPy components by parsing HTML templates.""" + components: set[str] = set() + for template in templates: + with contextlib.suppress(Exception), open(template, encoding="utf-8") as template_file: + clean_template = COMMENT_REGEX.sub("", template_file.read()) + regex_iterable = list(COMPONENT_REGEX.finditer(clean_template)) + regex_iterable.extend(JINJA_COMPONENT_REGEX.finditer(clean_template)) + new_components: list[str] = [] + for match in regex_iterable: + new_components.append(match.group("path").replace('"', "").replace("'", "")) + offline_path = match.group("offline_path") + if offline_path: + new_components.append(offline_path.replace('"', "").replace("'", "")) + components.update(new_components) + if not components: + _logger.warning( + "\033[93m" + "ReactPy did not find any components! " + "You are either not using any ReactPy components, " + "using the template tag incorrectly, " + "or your HTML templates are not registered with Django." + "\033[0m" + ) + return components + + def register_components(self, components: set[str]) -> None: + """Registers all ReactPy components in an iterable.""" + if components: + _logger.debug("Auto-detected ReactPy root components:") + for component in components: + try: + _logger.debug("\t+ %s", component) + register_component(component) + except Exception: + _logger.exception( + "\033[91m" + "ReactPy failed to register component '%s'!\n" + "This component path may not be valid, " + "or an exception may have occurred while importing.\n" + "See the traceback below for more information." + "\033[0m", + component, + ) + + +def generate_obj_name(obj: Any) -> str: + """Makes a best effort to create a name for an object. + Useful for JSON serialization of Python objects.""" + + # First attempt: Create a dotted path by inspecting dunder methods + if hasattr(obj, "__module__"): + if hasattr(obj, "__name__"): + return f"{obj.__module__}.{obj.__name__}" + if hasattr(obj, "__class__") and hasattr(obj.__class__, "__name__"): + return f"{obj.__module__}.{obj.__class__.__name__}" + + # Second attempt: String representation + with contextlib.suppress(Exception): + return str(obj) + + # Fallback: Empty string + return "" + + +def django_query_postprocessor( + data: QuerySet | Model, many_to_many: bool = True, many_to_one: bool = True +) -> QuerySet | Model: + """Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily. + + Behavior can be modified through `postprocessor_kwargs` within your `use_query` hook. + + Args: + data: The `Model` or `QuerySet` to recursively fetch fields from. + + Keyword Args: + many_to_many: Whether or not to recursively fetch `ManyToManyField` relationships. + many_to_one: Whether or not to recursively fetch `ForeignKey` relationships. + + Returns: + The `Model` or `QuerySet` with all fields fetched. + """ + + # `QuerySet`, which is an iterable containing `Model`/`QuerySet` objects. + if isinstance(data, QuerySet): + for model in data: + django_query_postprocessor( + model, + many_to_many=many_to_many, + many_to_one=many_to_one, + ) + + # `Model` instances + elif isinstance(data, Model): + prefetch_fields: list[str] = [] + for field in data._meta.get_fields(): + # Force the query to execute + getattr(data, field.name, None) + + if many_to_one and type(field) is ManyToOneRel: + prefetch_fields.append(field.related_name or f"{field.name}_set") + + elif many_to_many and isinstance(field, ManyToManyField): + prefetch_fields.append(field.name) + + if prefetch_fields: + prefetch_related_objects([data], *prefetch_fields) + for field_str in prefetch_fields: + django_query_postprocessor( + getattr(data, field_str).get_queryset(), + many_to_many=many_to_many, + many_to_one=many_to_one, + ) + + # Unrecognized type + else: + msg = ( + f"Django query postprocessor expected a Model or QuerySet, got {data!r}.\n" + "One of the following may have occurred:\n" + " - You are using a non-Django ORM.\n" + " - You are attempting to use `use_query` to fetch non-ORM data.\n\n" + "If these situations apply, you may want to disable the postprocessor." + ) + raise TypeError(msg) + + return data + + +def validate_component_args(func, *args, **kwargs): + """ + Validate whether a set of args/kwargs would work on the given component. + + Raises `ComponentParamError` if the args/kwargs are invalid. + """ + signature = inspect.signature(func) + + try: + signature.bind(*args, **kwargs) + except TypeError as e: + name = generate_obj_name(func) + msg = f"Invalid args for '{name}'. {str(e).capitalize()}." + raise ComponentParamError(msg) from e + + +def create_cache_key(*args): + """Creates a cache key string that starts with `reactpy_django` contains + all *args separated by `:`.""" + + if not args: + msg = "At least one argument is required to create a cache key." + raise ValueError(msg) + + return f"reactpy_django:{':'.join(str(arg) for arg in args)}" + + +class SyncLayout(Layout): + """Sync adapter for ReactPy's `Layout`. Allows it to be used in Django template tags. + This can be removed when Django supports async template tags. + """ + + def __enter__(self): + self.loop = asyncio.new_event_loop() + SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aenter__()).result() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aexit__(exc_type, exc_val, exc_tb)).result() + self.loop.close() + + def sync_render(self): + return SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.render()).result() + + +def get_pk(model): + """Returns the value of the primary key for a Django model.""" + return getattr(model, model._meta.pk.name) + + +def str_to_bool(val: str) -> bool: + """Convert a string representation of truth to true (1) or false (0). + + True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values + are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if + 'val' is anything else. + """ + val = val.lower() + if val in {"y", "yes", "t", "true", "on", "1"}: + return True + if val in {"n", "no", "f", "false", "off", "0"}: + return False + msg = f"invalid truth value {val}" + raise ValueError(msg) + + +def prerender_component( + user_component: ComponentConstructor, + args: Sequence, + kwargs: Mapping, + uuid: str | UUID, + request: HttpRequest, +) -> str: + """Prerenders a ReactPy component and returns the HTML string.""" + search = request.GET.urlencode() + scope = getattr(request, "scope", {}) + scope["reactpy"] = {"id": str(uuid)} + dir(request.user) # Call `dir` before prerendering to make sure the user object is loaded + + with SyncLayout( + ConnectionContext( + user_component(*args, **kwargs), + value=Connection( + scope=scope, + location=Location(path=request.path, query_string=f"?{search}" if search else ""), + carrier=request, + ), + ) + ) as layout: + vdom_tree = layout.sync_render()["model"] + + return _reactpy_to_string(vdom_tree) # type: ignore + + +def reactpy_to_string(vdom_or_component: Any, request: HttpRequest | None = None, uuid: str | None = None) -> str: + """Converts a VdomDict or component to an HTML string. If a string is provided instead, it will be + automatically returned.""" + if isinstance(vdom_or_component, dict): + return _reactpy_to_string(vdom_or_component) # type: ignore + + if hasattr(vdom_or_component, "render"): + if not request: + request = HttpRequest() + request.method = "GET" + if not uuid: + uuid = uuid4().hex + return prerender_component(vdom_or_component, [], {}, uuid, request) + + if isinstance(vdom_or_component, str): + return vdom_or_component + + msg = f"Invalid type for vdom_or_component: {type(vdom_or_component)}. Expected a VdomDict, component, or string." + raise ValueError(msg) + + +def save_component_params(args, kwargs, uuid) -> None: + """Saves the component parameters to the database. + This is used within our template tag in order to propogate + the parameters between the HTTP and WebSocket stack.""" + from reactpy_django import models + from reactpy_django.types import ComponentParams + + params = ComponentParams(args, kwargs) + model = models.ComponentSession(uuid=uuid, params=dill.dumps(params)) + model.full_clean() + model.save() + + +def validate_host(host: str) -> None: + """Validates the host string to ensure it does not contain a protocol.""" + if "://" in host: + protocol = host.split("://", maxsplit=1)[0] + msg = f"Invalid host provided to component. Contains a protocol '{protocol}://'." + _logger.error(msg) + raise InvalidHostError(msg) + + +class FileAsyncIterator: + """Async iterator that yields chunks of data from the provided async file.""" + + def __init__(self, file_path: str): + self.file_path = file_path + + async def __aiter__(self): + file_handle = None + try: + file_handle = FILE_ASYNC_ITERATOR_THREAD.submit(open, self.file_path, "rb").result() + while True: + chunk = FILE_ASYNC_ITERATOR_THREAD.submit(file_handle.read, 8192).result() + if not chunk: + break + yield chunk + finally: + if file_handle: + file_handle.close() + + +def ensure_async( + func: Callable[FuncParams, Inferred], *, thread_sensitive: bool = True +) -> Callable[FuncParams, Awaitable[Inferred]]: + """Ensure the provided function is always an async coroutine. If the provided function is + not async, it will be adapted.""" + + @wraps(func) + def wrapper(*args, **kwargs): + return ( + func(*args, **kwargs) + if iscoroutinefunction(func) + else database_sync_to_async(func, thread_sensitive=thread_sensitive)(*args, **kwargs) + ) + + return wrapper + + +def cached_static_file(static_path: str) -> str: + from reactpy_django.config import REACTPY_CACHE + + # Try to find the file within Django's static files + abs_path = find(static_path) + if not abs_path: + msg = f"Could not find static file {static_path} within Django's static files." + raise FileNotFoundError(msg) + if isinstance(abs_path, (list, tuple)): + abs_path = abs_path[0] + + # Fetch the file from cache, if available + last_modified_time = os.stat(abs_path).st_mtime + cache_key = f"reactpy_django:static_contents:{static_path}" + file_contents: str | None = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) + if file_contents is None: + with open(abs_path, encoding="utf-8") as static_file: + file_contents = static_file.read() + caches[REACTPY_CACHE].delete(cache_key) + caches[REACTPY_CACHE].set(cache_key, file_contents, timeout=None, version=int(last_modified_time)) + + return file_contents + + +def del_html_head_body_transform(vdom: VdomDict) -> VdomDict: + """Transform intended for use with `string_to_reactpy `. + + Removes ``, ``, and `` while preserving their children. + + Parameters: + vdom: + The VDOM dictionary to transform. + """ + if vdom["tagName"] in {"html", "body", "head"}: + return VdomDict(tagName="", children=vdom.setdefault("children", [])) + return vdom + + +def fetch_cached_python_file(file_path: str, minify: bool = True) -> str: + from reactpy.executors.pyscript.utils import minify_python + + from reactpy_django.config import REACTPY_CACHE + + # Try to get user code from cache + cache_key = create_cache_key("pyscript", file_path) + last_modified_time = os.stat(file_path).st_mtime + file_contents: str = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) + if file_contents: + return file_contents + + file_contents = Path(file_path).read_text(encoding="utf-8").strip() + if minify: + file_contents = minify_python(file_contents) + caches[REACTPY_CACHE].set(cache_key, file_contents, version=int(last_modified_time)) + return file_contents diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index df44ad6c..8a93887b 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -5,15 +5,23 @@ # Make sure the JS is always re-built before running the tests js_dir = Path(__file__).parent.parent.parent / "src" / "js" static_dir = Path(__file__).parent.parent.parent / "src" / "reactpy_django" / "static" / "reactpy_django" -assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0 -assert ( + +# Check if bun is available; if so, rebuild the JS. If not, assume artifacts exist. +_bun_available = shutil.which("bun") is not None +if _bun_available: + subprocess.run(["bun", "install"], cwd=str(js_dir), check=True) subprocess.run( ["bun", "build", "./src/index.ts", f"--outdir={static_dir}", "--sourcemap=linked"], cwd=str(js_dir), check=True, - ).returncode - == 0 -) + ) +else: + # Verify that JS artifacts already exist so we don't silently skip a needed build + if not (static_dir / "index.js").exists(): + raise RuntimeError( + "bun is not installed and JS artifacts are missing. " + f"Run 'bun install && bun build' in {js_dir} first." + ) # Make sure the test environment is always using the latest JS diff --git a/tests/test_app/jinja2_templates/base.html b/tests/test_app/jinja2_templates/base.html new file mode 100644 index 00000000..90aa4068 --- /dev/null +++ b/tests/test_app/jinja2_templates/base.html @@ -0,0 +1,97 @@ + + + + + + + + + ReactPy + + + + +

ReactPy Test Page (Jinja2)

+
+ {{ component("test_app.components.hello_world", class="hello-world") }} +
+ {{ component("test_app.components.button", class="button") }} +
+ {{ component("test_app.components.parameterized_component", class="parametarized-component", x=123, y=456) }} +
+ {{ component("test_app.components.object_in_templatetag", my_object) }} +
+ {{ component("test_app.components.button_from_js_module") }} +
+ {{ component("test_app.components.use_connection") }} +
+ {{ component("test_app.components.use_scope") }} +
+ {{ component("test_app.components.use_location") }} +
+ {{ component("test_app.components.use_origin") }} +
+ {{ component("test_app.components.django_css") }} +
+ {{ component("test_app.components.django_js") }} +
+ {{ component("test_app.components.unauthorized_user") }} +
+ {{ component("test_app.components.authorized_user") }} +
+ {{ component("test_app.components.relational_query") }} +
+ {{ component("test_app.components.async_relational_query") }} +
+ {{ component("test_app.components.todo_list") }} +
+ {{ component("test_app.components.async_todo_list") }} +
+ {{ component("test_app.components.view_to_component_sync_func") }} +
+ {{ component("test_app.components.view_to_component_async_func") }} +
+ {{ component("test_app.components.view_to_component_sync_class") }} +
+ {{ component("test_app.components.view_to_component_async_class") }} +
+ {{ component("test_app.components.view_to_component_template_view_class") }} +
+ {{ component("test_app.components.view_to_component_script") }} +
+ {{ component("test_app.components.view_to_component_request") }} +
+ {{ component("test_app.components.view_to_component_args") }} +
+ {{ component("test_app.components.view_to_component_kwargs") }} +
+ {{ component("test_app.components.view_to_iframe_sync_func") }} +
+ {{ component("test_app.components.view_to_iframe_async_func") }} +
+ {{ component("test_app.components.view_to_iframe_sync_class") }} +
+ {{ component("test_app.components.view_to_iframe_async_class") }} +
+ {{ component("test_app.components.view_to_iframe_template_view_class") }} +
+ {{ component("test_app.components.view_to_iframe_args") }} +
+ {{ component("test_app.components.use_user_data") }} +
+ {{ component("test_app.components.use_user_data_with_default") }} +
+ {{ component("test_app.components.use_auth") }} +
+ {{ component("test_app.components.use_auth_no_rerender") }} +
+ {{ component("test_app.components.use_rerender") }} +
+ + + diff --git a/tests/test_app/jinja2_templates/errors.html b/tests/test_app/jinja2_templates/errors.html new file mode 100644 index 00000000..f9e5d17c --- /dev/null +++ b/tests/test_app/jinja2_templates/errors.html @@ -0,0 +1,34 @@ + + + + + + + + + ReactPy + + + +

ReactPy Errors Test Page (Jinja2)

+
+
{{ component("test_app.components.does_not_exist") }}
+
+
{{ component("test_app.components.hello_world", invalid_param="random_value") }}
+
+
{{ component("test_app.components.hello_world", host="https://example.com/") }}
+
+
+ {{ component("test_app.components.broken_postprocessor_query") }} +
+
+
+ {{ component("test_app.components.view_to_iframe_not_registered") }} +
+
+
+ {{ component("test_app.components.incorrect_user_passes_test_decorator") }}
+
+ + + diff --git a/tests/test_app/jinja2_templates/host_port.html b/tests/test_app/jinja2_templates/host_port.html new file mode 100644 index 00000000..bbd83e48 --- /dev/null +++ b/tests/test_app/jinja2_templates/host_port.html @@ -0,0 +1,20 @@ + + + + + + + + + ReactPy + + + +

ReactPy Test Page

+
+ Custom Host ({{ new_host }}): + {{ component("test_app.components.custom_host", host=new_host, number=0) }} +
+ + + diff --git a/tests/test_app/jinja2_templates/host_port_roundrobin.html b/tests/test_app/jinja2_templates/host_port_roundrobin.html new file mode 100644 index 00000000..47f5229a --- /dev/null +++ b/tests/test_app/jinja2_templates/host_port_roundrobin.html @@ -0,0 +1,22 @@ + + + + + + + + + ReactPy + + + +

ReactPy Test Page

+
+ {% for count_val in count %} + Round-Robin Host: + {{ component("test_app.components.custom_host", number=count_val) }} +
+ {% endfor %} + + + diff --git a/tests/test_app/jinja2_templates/offline.html b/tests/test_app/jinja2_templates/offline.html new file mode 100644 index 00000000..545895f8 --- /dev/null +++ b/tests/test_app/jinja2_templates/offline.html @@ -0,0 +1,19 @@ + + + + + + + + + ReactPy + + + +

ReactPy Offline Test Page

+
+ {{ component("test_app.offline.components.online", offline="test_app.offline.components.offline") }} +
+ + + diff --git a/tests/test_app/jinja2_templates/prerender.html b/tests/test_app/jinja2_templates/prerender.html new file mode 100644 index 00000000..cfea84b3 --- /dev/null +++ b/tests/test_app/jinja2_templates/prerender.html @@ -0,0 +1,33 @@ + + + + + + + + + ReactPy + + + +

ReactPy Prerender Test Page

+
+
+ {{ component("test_app.prerender.components.prerender_string", class="prerender-string", prerender="true") }} +
+
+
+ {{ component("test_app.prerender.components.prerender_vdom", class="prerender-vdom", prerender="true") }} +
+
+
+ {{ component("test_app.prerender.components.prerender_component", class="prerender-component", prerender="true") }} +
+
+ {{ component("test_app.prerender.components.use_user", prerender="true") }} +
+ {{ component("test_app.prerender.components.use_root_id", prerender="true") }} +
+ + + diff --git a/tests/test_app/jinja_env.py b/tests/test_app/jinja_env.py new file mode 100644 index 00000000..c162ec1d --- /dev/null +++ b/tests/test_app/jinja_env.py @@ -0,0 +1,12 @@ +"""Jinja2 environment configuration for the test app.""" + +from jinja2 import Environment + + +def environment(**options): + """Create a Jinja2 environment with the ReactPy extension loaded.""" + from reactpy_django.templatetags.jinja import ReactPyExtension + + env = Environment(**options) + env.add_extension(ReactPyExtension) + return env diff --git a/tests/test_app/jinja_urls.py b/tests/test_app/jinja_urls.py new file mode 100644 index 00000000..009b0d71 --- /dev/null +++ b/tests/test_app/jinja_urls.py @@ -0,0 +1,10 @@ +"""URL patterns for Jinja2 template views.""" + +from django.urls import path + +from . import jinja_views + +urlpatterns = [ + path("", jinja_views.jinja_base_template, name="jinja_base_template"), + path("errors/", jinja_views.jinja_errors_template, name="jinja_errors_template"), +] diff --git a/tests/test_app/jinja_views.py b/tests/test_app/jinja_views.py new file mode 100644 index 00000000..e21520ca --- /dev/null +++ b/tests/test_app/jinja_views.py @@ -0,0 +1,15 @@ +"""Jinja2 template views for the test app.""" + +from django.shortcuts import render + +from .types import TestObject + + +def jinja_base_template(request): + """Render the Jinja2 version of the base template.""" + return render(request, "base.html", {"my_object": TestObject(1)}, using="jinja2") + + +def jinja_errors_template(request): + """Render the Jinja2 version of the errors template.""" + return render(request, "errors.html", {}, using="jinja2") diff --git a/tests/test_app/settings_jinja.py b/tests/test_app/settings_jinja.py new file mode 100644 index 00000000..77655523 --- /dev/null +++ b/tests/test_app/settings_jinja.py @@ -0,0 +1,147 @@ +import os +import sys +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +SRC_DIR = BASE_DIR.parent / "src" + +# Quick-start development settings - unsuitable for production + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c" + +# Run in production mode when using a real web server +DEBUG = not any(sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn", "daphne"]) +ALLOWED_HOSTS = ["*"] + +# Application definition +INSTALLED_APPS = [ + "servestatic", + "daphne", # Overrides `runserver` command with an ASGI server + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "reactpy_django", # Django compatiblity layer for ReactPy + "test_app", # This test application + "django_bootstrap5", + "jinja2", # Jinja2 template engine +] +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "servestatic.middleware.ServeStaticMiddleware", + "test_app.middleware.AutoCreateAdminMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] +ROOT_URLCONF = "test_app.urls" +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "test_app", "templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": [os.path.join(BASE_DIR, "test_app", "jinja2_templates")], + "OPTIONS": { + "environment": "test_app.jinja_env.environment", + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] +ASGI_APPLICATION = "test_app.asgi.application" +sys.path.append(str(SRC_DIR)) + +# Database +# WARNING: There are overrides in `test_components.py` that require no in-memory +# databases are used for testing. Make sure all SQLite databases are on disk. +DB_NAME = "single_db" +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), + "TEST": { + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), + "OPTIONS": {"timeout": 20}, + "DEPENDENCIES": [], + }, + "OPTIONS": {"timeout": 20}, + }, +} + +# Cache +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": os.path.join(BASE_DIR, "cache"), + } +} + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +# Internationalization +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +# Default primary key field type +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +STATIC_ROOT = os.path.join(BASE_DIR, "static-deploy") + +# Static Files (CSS, JavaScript, Images) +STATIC_URL = "/static/" +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "test_app", "static"), +] +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +] + +# Logging +LOG_LEVEL = "DEBUG" +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": {"class": "logging.StreamHandler", "level": LOG_LEVEL}, + }, +} + +# Django Channels Settings +CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} + +# ReactPy-Django Settings +REACTPY_BACKHAUL_THREAD = any(sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn"]) + +# ServeStatic Settings +SERVESTATIC_USE_FINDERS = True +SERVESTATIC_AUTOREFRESH = True diff --git a/tests/test_app/tests/test_jinja.py b/tests/test_app/tests/test_jinja.py new file mode 100644 index 00000000..69bd73ef --- /dev/null +++ b/tests/test_app/tests/test_jinja.py @@ -0,0 +1,112 @@ +"""Tests for Jinja2 template rendering with ReactPy components.""" + +from django.test import TestCase, override_settings + +# Jinja2 template backend configuration to add alongside the default Django templates +JINJA2_TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": [], + "OPTIONS": { + "environment": "test_app.jinja_env.environment", + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + + +class Jinja2ComponentTests(TestCase): + """Verify that ReactPy components render correctly in Jinja2 templates.""" + + @override_settings(TEMPLATES=JINJA2_TEMPLATES) + def test_component_function_available(self): + """The component function should be available in Jinja2 templates.""" + from jinja2 import Environment + from reactpy_django.templatetags.jinja import ReactPyExtension + + env = Environment() + env.add_extension(ReactPyExtension) + + assert "component" in env.globals + assert callable(env.globals["component"]) + + @override_settings(TEMPLATES=JINJA2_TEMPLATES) + def test_pyscript_component_function_available(self): + """The pyscript_component function should be available in Jinja2 templates.""" + from jinja2 import Environment + from reactpy_django.templatetags.jinja import ReactPyExtension + + env = Environment() + env.add_extension(ReactPyExtension) + + assert "pyscript_component" in env.globals + assert callable(env.globals["pyscript_component"]) + + @override_settings(TEMPLATES=JINJA2_TEMPLATES) + def test_pyscript_setup_function_available(self): + """The pyscript_setup function should be available in Jinja2 templates.""" + from jinja2 import Environment + from reactpy_django.templatetags.jinja import ReactPyExtension + + env = Environment() + env.add_extension(ReactPyExtension) + + assert "pyscript_setup" in env.globals + assert callable(env.globals["pyscript_setup"]) + + @override_settings(TEMPLATES=JINJA2_TEMPLATES) + def test_jinja_component_renders_without_error(self): + """A Jinja2 template containing a component tag should render without error.""" + from django.template import engines + + jinja2_engine = engines["jinja2"] + template = jinja2_engine.from_string( + "{{ component('test_app.components.hello_world') }}" + ) + rendered = template.render({}) + # The rendered output should be a string of some sort + assert isinstance(rendered, str) + + @override_settings(TEMPLATES=JINJA2_TEMPLATES) + def test_jinja_extension_configuration(self): + """The ReactPyExtension should be configurable via the environment function.""" + from test_app.jinja_env import environment + + env = environment() + assert "component" in env.globals + assert callable(env.globals["component"]) + assert "pyscript_component" in env.globals + assert callable(env.globals["pyscript_component"]) + assert "pyscript_setup" in env.globals + assert callable(env.globals["pyscript_setup"]) + + +class Jinja2ViewTests(TestCase): + """Verify that Jinja2 views render with the correct engine and status.""" + + def test_jinja_base_template_view_status(self): + """The Jinja2 base template view should return HTTP 200.""" + # Note: These tests require the Jinja2 settings module + pass + + def test_jinja_errors_template_view_status(self): + """The Jinja2 errors template view should return HTTP 200.""" + pass diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index 65672cdc..99be197b 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -36,5 +36,6 @@ path("", include("test_app.channel_layers.urls")), path("", include("test_app.forms.urls")), path("reactpy/", include("reactpy_django.http.urls")), + path("jinja/", include("test_app.jinja_urls")), path("admin/", admin.site.urls), ] From 199498064e53bc3a00d5fc85e6f2ae3092525996 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:28:21 -0700 Subject: [PATCH 6/6] fix type and lint warnings --- pyproject.toml | 2 +- src/reactpy_django/templatetags/jinja.py | 20 ++++++++++---------- tests/test_app/__init__.py | 11 ++++------- tests/test_app/tests/test_jinja.py | 9 ++++----- 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7c47254e..abdc6be5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -198,7 +198,7 @@ deploy_develop = ["cd docs && mike deploy --push develop"] ################################ [tool.hatch.envs.python] -extra-dependencies = ["django-stubs", "channels-redis", "pyright"] +extra-dependencies = ["django-stubs", "channels-redis", "pyright", "jinja2"] [tool.hatch.envs.python.scripts] type_check = ["pyright src"] diff --git a/src/reactpy_django/templatetags/jinja.py b/src/reactpy_django/templatetags/jinja.py index 8b47c01a..24a2d69b 100644 --- a/src/reactpy_django/templatetags/jinja.py +++ b/src/reactpy_django/templatetags/jinja.py @@ -27,6 +27,7 @@ from jinja2 import Environment from reactpy_django.templatetags.jinja import ReactPyExtension + def environment(**options): env = Environment(**options) env.add_extension(ReactPyExtension) @@ -35,26 +36,30 @@ def environment(**options): from __future__ import annotations -import json from logging import getLogger from typing import TYPE_CHECKING from django.template import RequestContext, loader -from django.utils.safestring import mark_safe from jinja2 import pass_context from jinja2.ext import Extension -from jinja2.runtime import Context from reactpy_django.templatetags.reactpy import ( COMPONENT_TEMPLATE, PYSCRIPT_COMPONENT_TEMPLATE, PYSCRIPT_SETUP_TEMPLATE, +) +from reactpy_django.templatetags.reactpy import ( component as django_component_tag, +) +from reactpy_django.templatetags.reactpy import ( pyscript_component as django_pyscript_component_tag, +) +from reactpy_django.templatetags.reactpy import ( pyscript_setup as django_pyscript_setup_tag, ) if TYPE_CHECKING: + from jinja2.runtime import Context from reactpy.types import Component, VdomDict _logger = getLogger(__name__) @@ -75,8 +80,6 @@ class ReactPyExtension(Extension): function expansions. """ - tags = {} - def __init__(self, environment): super().__init__(environment) environment.globals["component"] = self._component @@ -111,7 +114,7 @@ def _component( """ request = jinja_context.parent.get("request") if request is None: - _logger.exception( + _logger.error( "Cannot render a ReactPy component in a Jinja2 template without a " "request object. Ensure the 'django.template.context_processors.request' " "context processor is enabled for your Jinja2 backend." @@ -159,10 +162,7 @@ def _pyscript_component( """ request = jinja_context.parent.get("request") if request is None: - _logger.exception( - "Cannot render a PyScript component in a Jinja2 template without a " - "request object." - ) + _logger.error("Cannot render a PyScript component in a Jinja2 template without a request object.") return "" django_context = RequestContext( diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 8a93887b..cccc67a5 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -15,13 +15,10 @@ cwd=str(js_dir), check=True, ) -else: - # Verify that JS artifacts already exist so we don't silently skip a needed build - if not (static_dir / "index.js").exists(): - raise RuntimeError( - "bun is not installed and JS artifacts are missing. " - f"Run 'bun install && bun build' in {js_dir} first." - ) +# Verify that JS artifacts already exist so we don't silently skip a needed build +elif not (static_dir / "index.js").exists(): + msg = f"bun is not installed and JS artifacts are missing. Run 'bun install && bun build' in {js_dir} first." + raise RuntimeError(msg) # Make sure the test environment is always using the latest JS diff --git a/tests/test_app/tests/test_jinja.py b/tests/test_app/tests/test_jinja.py index 69bd73ef..e9d672ed 100644 --- a/tests/test_app/tests/test_jinja.py +++ b/tests/test_app/tests/test_jinja.py @@ -40,6 +40,7 @@ class Jinja2ComponentTests(TestCase): def test_component_function_available(self): """The component function should be available in Jinja2 templates.""" from jinja2 import Environment + from reactpy_django.templatetags.jinja import ReactPyExtension env = Environment() @@ -52,6 +53,7 @@ def test_component_function_available(self): def test_pyscript_component_function_available(self): """The pyscript_component function should be available in Jinja2 templates.""" from jinja2 import Environment + from reactpy_django.templatetags.jinja import ReactPyExtension env = Environment() @@ -64,6 +66,7 @@ def test_pyscript_component_function_available(self): def test_pyscript_setup_function_available(self): """The pyscript_setup function should be available in Jinja2 templates.""" from jinja2 import Environment + from reactpy_django.templatetags.jinja import ReactPyExtension env = Environment() @@ -78,9 +81,7 @@ def test_jinja_component_renders_without_error(self): from django.template import engines jinja2_engine = engines["jinja2"] - template = jinja2_engine.from_string( - "{{ component('test_app.components.hello_world') }}" - ) + template = jinja2_engine.from_string("{{ component('test_app.components.hello_world') }}") rendered = template.render({}) # The rendered output should be a string of some sort assert isinstance(rendered, str) @@ -105,8 +106,6 @@ class Jinja2ViewTests(TestCase): def test_jinja_base_template_view_status(self): """The Jinja2 base template view should return HTTP 200.""" # Note: These tests require the Jinja2 settings module - pass def test_jinja_errors_template_view_status(self): """The Jinja2 errors template view should return HTTP 200.""" - pass