Source code for render_static.templatetags.render_static

"""
Template tags and filters available when the render_static app is installed.
"""

import functools
from copy import copy
from enum import Enum
from inspect import getfullargspec, unwrap
from types import ModuleType
from typing import (
    Any,
    Callable,
    Collection,
    Dict,
    Generator,
    Iterable,
    List,
    Optional,
    Type,
    Union,
    cast,
)

import django
from django import template
from django.conf import settings
from django.template import Node, NodeList
from django.template.context import Context
from django.template.library import parse_bits as _library_parse_bits
from django.utils.module_loading import import_string
from django.utils.safestring import SafeString

from render_static.transpilers.base import (
    Transpiler,
    TranspilerTarget,
    TranspilerTargets,
)
from render_static.transpilers.defines_to_js import DefaultDefineTranspiler
from render_static.transpilers.enums_to_js import EnumClassWriter
from render_static.transpilers.urls_to_js import ClassURLWriter

__all__ = ["split", "defines_to_js", "urls_to_js", "enums_to_js"]

Targets = Union[TranspilerTargets, TranspilerTarget]
TranspilerType = Union[Type[Transpiler], str]


# the signature of parse_bits is version dependent, so avoid the stubs
_parse_bits = cast(Any, _library_parse_bits)

if django.VERSION[:2] >= (6, 1):
    parse_bits = _parse_bits
else:

    def parse_bits(
        parser, bits, params, varargs, varkw, defaults, kwonly, kwonly_defaults, name
    ):
        # Django < 6.1 requires a takes_context argument before name
        return _parse_bits(
            parser,
            bits,
            params,
            varargs,
            varkw,
            defaults,
            kwonly,
            kwonly_defaults,
            False,
            name,
        )


def do_transpile(
    targets: Targets, transpiler: TranspilerType, kwargs: Dict[Any, Any]
) -> str:
    """
    Transpile the given target(s) using the given transpiler and
    parameters for the transpiler.

    :param targets: A list or single transpiler target which can be an import
        string a module type or a class.
    :param transpiler: The transpiler class or import string for the transpiler
        class to use.
    :param kwargs: Any kwargs that the transpiler takes.
    :return: SafeString of rendered transpiled code.
    """
    if isinstance(targets, (type, str)) or not isinstance(targets, Collection):
        targets = [targets]

    transpiler_cls = (
        import_string(transpiler) if isinstance(transpiler, str) else transpiler
    )
    return SafeString(transpiler_cls(**kwargs).transpile(targets))


class OverrideNode(Node):
    """
    A block node that holds a block override. Works with all transpilers.

    :param override_name: The name of the override (i.e. function name).
    :param nodelist: The child nodes for this node. Should be empty
    """

    def __init__(
        self,
        override_name: Optional[Union[str, template.base.FilterExpression]],
        nodelist: NodeList,
    ):
        self.override_name = override_name or f"_{id(self)}"
        self.nodelist = nodelist
        self.context = Context()

    def bind(self, context: Context) -> str:
        """
        Bind the override to the given base context. The same override
        may be transpiled multiple times to extensions of this context.
        See transpile.

        :param context: The context to bind the override to.
        :return: The name of the override.
        """
        self.context = copy(context)
        return (
            self.override_name.resolve(context)
            if isinstance(
                self.override_name,
                (template.base.Variable, template.base.FilterExpression),
            )
            else self.override_name
        )

    def transpile(self, context: Context) -> Generator[str, None, None]:
        """
        Render the override in the given context, yielding each line.

        :param context: The context to render the override in.
        :return: A generator of lines of rendered override.
        """
        self.context.update(context)
        lines = self.nodelist.render(self.context).splitlines()
        yield from lines


class TranspilerNode(Node):
    """
    A block node holding a transpilation and associated parameters.
    Works with all transpilers.

    :param nodelist: The child nodes for this node. Should be empty
        or only contain overrides.
    :param transpiler: The transpiler class or import string.
    :param targets: The index or the targets positional argument or the
        name of the keyword argument that contains the targets.
    :param kwargs: The keyword arguments to pass to the transpiler
    """

    def __init__(
        self,
        func: Callable,
        targets: Optional[str],
        kwargs: Dict[str, Any],
        nodelist: Optional[NodeList] = None,
    ):
        self.func = func
        self.targets = targets
        self.kwargs = kwargs
        self.nodelist = nodelist or NodeList()

    def get_resolved_arguments(self, context: Context) -> Dict[str, Any]:
        """
        Resolve the arguments to the transpiler.

        :param context: The context of the template being rendered.
        :return: A dictionary of resolved arguments.
        """
        resolved_kwargs = {
            k: v.resolve(context)
            if isinstance(v, (template.base.Variable, template.base.FilterExpression))
            else v
            for k, v in self.kwargs.items()
        }
        overrides = self.get_nodes_by_type(OverrideNode)
        if overrides:
            resolved_kwargs["overrides"] = {
                cast(OverrideNode, override).bind(context): override
                for override in overrides
            }
        return resolved_kwargs

    def render(self, context: Context) -> str:
        """
        Transpile the given target(s).

        :param context: The context of the template being rendered.
        :return: SafeString of rendered transpiled code.
        """
        return self.func(**self.get_resolved_arguments(context))


register = template.Library()


def transpiler_tag(
    func: Optional[Callable] = None,
    targets: Union[int, str] = 0,
    name: Optional[str] = None,
    node: Type[TranspilerNode] = TranspilerNode,
):
    """
    Register a callable as a transpiler tag. This decorator is similar
    to simple_tag but also passes the parser and token to the decorated
    function.
    """

    def dec(func: Callable):
        (
            pos_args,
            varargs,
            varkw,
            defaults,
            kwonly,
            kwonly_defaults,
            _,
        ) = getfullargspec(unwrap(func))
        function_name = name or getattr(func, "_decorated_function", func).__name__

        assert "transpiler" in pos_args or "transpiler" in kwonly, (
            f"{function_name} must accept a transpiler argument."
        )

        param_defaults = {
            pos_args[len(pos_args or []) - len(defaults or []) + idx]: default
            for idx, default in enumerate(defaults or [])
        }

        @functools.wraps(func)
        def compile_func(parser, token):
            # we have to lookahead to see if there is an end tag because parse
            # will error out if we ask it to parse_until and there isn't one.
            is_block = False
            nodelist = None
            for lookahead in reversed(parser.tokens):
                if lookahead.token_type == template.base.TokenType.BLOCK:
                    command = lookahead.contents.split()[0]
                    if command == f"end{function_name}":
                        is_block = True
                        break
                    if command == f"{function_name}":
                        break
            if is_block:
                nodelist = parser.parse(parse_until=(f"end{function_name}",))
                parser.delete_first_token()

            bits = token.split_contents()[1:]
            pargs, pkwargs = parse_bits(
                parser,
                bits,
                pos_args,
                varargs,
                varkw,
                defaults,
                kwonly,
                kwonly_defaults,
                function_name,
            )
            # we rearrange everything here to turn all arguments into
            # keyword arguments b/c while this eliminates variadic positional
            # arguments it does make this code more robust to custom
            # transpiler constructor signatures
            for idx, parg in enumerate(pargs):
                pkwargs[pos_args[idx]] = parg

            return node(
                func,
                pos_args[targets] if isinstance(targets, int) else targets,
                {**(kwonly_defaults or {}), **param_defaults, **pkwargs},
                nodelist,
            )

        register.tag(function_name, compile_func)
        return func

    if func is None:
        # @register.transpile_tag(...)
        return dec
    if callable(func):
        # @register.transpile_tag
        return dec(func)
    raise ValueError("Invalid arguments provided to transpiler_tag")


[docs] @register.filter(name="split") def split(to_split: str, sep: Optional[str] = None) -> List[str]: """ Django template for python's standard split function. Splits a string into a list of strings around a separator. :param to_split: The string to split :param sep: The separator characters to use as split markers. :return: A list of strings """ if sep: return to_split.split(sep) return to_split.split()
@transpiler_tag def transpile(targets: Targets, transpiler: TranspilerType, **kwargs) -> str: """ Run the given transpiler on the given targets and write the generated javascript in-place. :param targets: A list or single transpiler target which can be an import string a module type or a class. :param transpiler: The transpiler class or import string for the transpiler class to use. :param kwargs: Any kwargs that the transpiler takes. :return: """ return do_transpile(targets=targets, transpiler=transpiler, kwargs=kwargs)
[docs] @transpiler_tag(targets="url_conf") def urls_to_js( transpiler: TranspilerType = ClassURLWriter, url_conf: Optional[Union[ModuleType, str]] = None, indent: str = "\t", depth: int = 0, include: Optional[Iterable[str]] = None, exclude: Optional[Iterable[str]] = ("admin",), **kwargs, ) -> str: """ Dump reversible URLs to javascript. The javascript generated provides functions for each fully qualified URL name that perform the same service as Django's URL :func:`~django.urls.reverse` function. The javascript output by this tag isn't standalone. It is up to the caller to embed it in another object. For instance, given the following urls.py: .. code-block:: from django.urls import include, path from views import MyView urlpatterns = [ path('my/url/', MyView.as_view(), name='my_url'), path('url/with/arg/<int:arg1>', MyView.as_view(), name='my_url'), path('sub/', include('other_app.urls', namespace='sub')), ] And the other app's urls.py: .. code-block:: from django.urls import path from views import MyView urlpatterns = [ path('detail/<uuid:id>', MyView.as_view(), name='detail'), ] And the following template: .. code-block:: {% urls_to_js %} You can now reverse code in the javascript like this: .. code-block:: const urls = URLResolver(): # /my/url/ console.log(urls.reverse('my_url)); # /url/with/arg/143 console.log(urls.reverse('my_url', {kwargs: {'arg1': 143}})); # /sub/detail/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa console.log( urls.reverse( 'other:detail', {kwargs: {'id': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'}} ) ); .. note:: Care has been taken to harden this process to changes to the Django url resolution source and to ensure that it just works with minimal intervention. The general strategy of this process is two staged. First a tree structure is generated by walking the urlpatterns structure that contains the url patterns and resolves all their fully qualified names including all parent namespaces. URLs and namespaces are included and excluded at this stage based on the include/exclude parameters. The branches of the tree are namespaces and the leaves are fully qualified URL names containing lists of corresponding URLPatterns. The second stage recursively walks the tree, writing javascript as it enters namespaces and encounters URLPatterns. Multiple URLs may be registered against the same fully qualified name, but may be distinguished by the named parameters they accept. One javascript function is generated for each fully qualified URL name, that will select the correct URL reversal based on the names of the parameters passed in and map those parameter values to the correct placeholders in the URL. To ensure the outputs of the javascript match Django's :func:`~django.urls.reverse` the strategy is to use the results of the :func:`~django.urls.reverse` call for the fully qualified name. Placeholder values are passed into :func:`~django.urls.reverse` and then overwritten with javascript substitution code based on the regex grouping information. This strategy avoids as much error prone regex/string processing as possible. The concession here is that placeholder values must be supplied by the user wherever we cant infer them. When using path instead of re_path we can use default placeholders for all the known converters. When using re_path or custom path converters users must register placeholders by parameter name, converter type, or app_name. Libraries exist for generating string patterns that match regex's but none seem reliable or stable enough to include as a dependency. :param transpiler: The transpiler class that will generate the JavaScript, as either a class or an import string. May be one of the built-ins or a user defined transpiler. :param url_conf: The root url module to dump urls from, default: :setting:`ROOT_URLCONF` :param indent: string to use for indentation in javascript, default: ' ' :param depth: the starting indentation depth, default: 0 :param include: A list of path names to include, namespaces without path names will be treated as every path under the namespace. Default: include everything :param exclude: A list of path names to exclude, namespaces without path names will be treated as every path under the namespace. Default: exclude nothing :param kwargs: Extra kwargs that will be passed to the visitor class on construction. All visitors are passed indent, and depth. :return: A javascript object containing functions that generate urls with and without parameters """ return do_transpile( targets=url_conf or settings.ROOT_URLCONF, transpiler=transpiler, kwargs={ "depth": depth, "indent": indent, "include": include, "exclude": exclude, **kwargs, }, )
[docs] @transpiler_tag def defines_to_js( defines: Union[ ModuleType, Type[Any], str, Collection[Union[ModuleType, Type[Any], str]] ], transpiler: TranspilerType = DefaultDefineTranspiler, indent: str = "\t", depth: int = 0, **kwargs, ) -> str: """ Transpile defines from the given modules or classes into javascript. :param defines: A module, class or import string to either. :param transpiler: The transpiler class or import string for the transpiler class that will perform the conversion, default: DefaultDefineTranspiler :param indent: The indent string to use :param depth: The depth of the initial indent :param kwargs: Any other kwargs to pass to the transpiler. :return: SafeString of rendered transpiled code. """ return do_transpile( targets=defines, transpiler=transpiler, kwargs={"indent": indent, "depth": depth, **kwargs}, )
[docs] @transpiler_tag def enums_to_js( enums: Union[ ModuleType, Type[Enum], str, Collection[Union[ModuleType, Type[Enum], str]] ], transpiler: TranspilerType = EnumClassWriter, indent: str = "\t", depth: int = 0, **kwargs, ) -> str: """ Transpile the given enumeration(s). :param enums: An enum class or import string or a collection of either to transpile. :param transpiler: A transpiler class or import string of the transpiler class to use for the transpilation. :param indent: The indent string to use :param depth: The depth of the initial indent :param kwargs: Any other parameters to pass to the configured transpiler. See transpiler docs for details. :return: SafeString of rendered transpiled code. """ return do_transpile( targets=enums, transpiler=transpiler, kwargs={"indent": indent, "depth": depth, **kwargs}, )
@register.tag(name="override") def override(parser, token): """ Override a function in the parent transpilation. """ nodelist = parser.parse(parse_until=("endoverride",)) parser.delete_first_token() p_args, _ = parse_bits( parser, token.split_contents()[1:], ["override"], None, None, [], [], {}, "override", ) name = p_args[0] if p_args else None return OverrideNode(name, nodelist)