diff --git a/README.rst b/README.rst index a842625..0a5b322 100644 --- a/README.rst +++ b/README.rst @@ -45,6 +45,9 @@ Single-sourcing these structures by transpiling client side code from the server - Plain data define-like structures in Python classes and modules (`defines_to_js`) +Transpilation is extremely flexible and may be customized by using override blocks or extending the provided +transpilers. + `django-render-static` also formalizes the concept of a package-time or deployment-time static file rendering step. It piggybacks off the existing templating engines and configurations and should therefore be familiar to Django developers. It supports both standard Django templating diff --git a/doc/requirements.txt b/doc/requirements.txt index 24c45f8..e498a1a 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -7,4 +7,4 @@ sphinxcontrib-jsmath==1.0.1; python_version >= "3.5" sphinxcontrib-qthelp==1.0.3; python_version >= "3.5" sphinxcontrib-serializinghtml==1.1.5; python_version >= "3.5" sphinx-js==3.2.2; python_version >= "3.5" -django-render-static==2.0.3 +django-render-static==2.1.0 diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 8d083cd..fe569f0 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -2,6 +2,17 @@ Change Log ========== +v2.1.0 +====== +* Implemented `Support templating of destination paths. `_ +* Implemented `Support configurable case insensitive property mapping on enum transpilation. `_ +* Implemented `Add a pass through getter for enums_to_js transpilation. `_ +* Implemented `enum transpilation should iterate through value properties instead of hardcoding a switch statement. ` +* Implemented `Add type check and return to getter on transpiled enum classes.. `_ +* Implemented `Provide switch to turn off toString() transpilation on enums_to_js `_ +* Implemented `Allow include_properties to be a list of properties on enums_to_js `_ +* Implemented `Extension points for transpiled code. `_ + v2.0.3 ====== * Fixed `Invalid URL generation for urls with default arguments. `_ diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 830e107..8c3e47e 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -209,6 +209,10 @@ single template, if the ``dest`` parameter is not an existing directory, it will the full path including the file name where the template will be rendered. When rendering in batch mode, ``dest`` will be treated as a directory and created if it does not exist. +The ``dest`` parameter may include template variables that will be replaced with the value of the +variable in the context. For example, if ``dest`` is ``'js/{{ app_name }}.js'`` and the context +contains ``{'app_name': 'my_app'}`` then the template will be rendered to ``js/my_app.js``. + ``context`` ~~~~~~~~~~~ diff --git a/doc/source/reference.rst b/doc/source/reference.rst index 02b4f18..2357f68 100644 --- a/doc/source/reference.rst +++ b/doc/source/reference.rst @@ -120,6 +120,7 @@ transpilers .. autofunction:: to_js_datetime .. autoclass:: CodeWriter .. autoclass:: Transpiler + .. autoproperty:: Transpiler.context .. _transpilers_defines_to_js: @@ -131,6 +132,7 @@ transpilers.defines_to_js .. automodule:: render_static.transpilers.defines_to_js .. autoclass:: DefaultDefineTranspiler + .. autoproperty:: DefaultDefineTranspiler.context .. _transpilers_urls_to_js: @@ -141,8 +143,11 @@ transpilers.urls_to_js .. automodule:: render_static.transpilers.urls_to_js .. autoclass:: URLTreeVisitor + .. autoproperty:: URLTreeVisitor.context .. autoclass:: SimpleURLWriter + .. autoproperty:: SimpleURLWriter.context .. autoclass:: ClassURLWriter + .. autoproperty:: ClassURLWriter.context .. autoclass:: Substitute .. autofunction:: normalize_ns .. autofunction:: build_tree @@ -155,8 +160,10 @@ transpilers.enums_to_js .. automodule:: render_static.transpilers.enums_to_js + .. autoclass:: UnrecognizedBehavior .. autoclass:: EnumTranspiler .. autoclass:: EnumClassWriter + .. autoproperty:: EnumClassWriter.context .. _context: diff --git a/doc/source/templatetags.rst b/doc/source/templatetags.rst index 24d892b..69d8ac8 100644 --- a/doc/source/templatetags.rst +++ b/doc/source/templatetags.rst @@ -144,6 +144,25 @@ The generated source would look like: parent classes and add them to the JavaScript. +Overrides +********* + +The ``DefaultDefineTranspiler`` supports the :ref:`override` block. The context available to +override blocks is detailed here: +:py:attr:`render_static.transpilers.defines_to_js.DefaultDefineTranspiler.context`. More code +can be added to define variables or specific defines can be overridden by using their python +path: + +.. code-block:: js+django + + {% defines_to_js defines='myapp' %} + + {% override 'myapp.defines.TestDefines.DEFINE1' %} + "OVERRIDE" + {% endoverride %} + + {% enddefines_to_js %} + .. _urls_to_js: ``urls_to_js`` @@ -265,15 +284,36 @@ Placeholders are the price paid for that reliability. Common default placeholder after all registered placeholders fail, and all of Django's native path converters are supported. This should allow most urls to work out of the box. +Overrides +********* + +Both the ``ClassURLWriter`` and ``SimpleURLWriter`` transpilers support the :ref:`override` +block. The contexts available to override blocks for each transpiler are detailed here: + + - :py:attr:`render_static.transpilers.urls_to_js.SimpleURLWriter.context` + - :py:attr:`render_static.transpilers.urls_to_js.ClassURLWriter.context` + +Any function on ``ClassURLWriter`` including the constructor can be overridden and both +transpilers allow adding to the class or object and overriding the reversal code for +specific url names. For instance: + +.. code-block:: js+django + + {% urls_to_js transpiler='render_static.SimpleURLWriter' %} + + {% override 'namespace:path_name' %} + return "/an/overridden/path"; + {% endoverride %} + + {% endurls_to_js %} `ClassURLWriter` (default) ************************** A transpiler class that produces ES6 JavaScript class is now included. As of version 2 This -class is used by default. It is the preferred transpiler for larger, more complex URL trees -because it minifies better than the ``SimpleURLWriter`` and it handles default kwargs -appropriately. **The** ``ClassURLWriter`` **is guaranteed to produce output identical to Django's -reverse function**. If it does not please report a bug. To use the class writer: +class is used by default. **The** ``ClassURLWriter`` **is guaranteed to produce output +identical to Django's reverse function**. If it does not please report a bug. To use the +class writer: .. code-block:: htmldjango @@ -532,13 +572,14 @@ like this: } static get(value) { - switch(value) { - case "R": - return Color.RED; - case "G": - return Color.GREEN; - case "B": - return Color.BLUE; + if (value instanceof this) { + return value; + } + + for (const en of this) { + if (en.value === value) { + return en; + } } throw new TypeError(`No Color enumeration maps to value ${value}`); } @@ -556,3 +597,138 @@ We can now use our enumeration like so: for (const color of Color) { console.log(color); } + +Overrides +********* + +You may add additional code to the class or :ref:`override` the following functions: + + - constructor + - toString + - get + - ciCompare + - [Symbol.iterator] + +See :py:attr:`render_static.transpilers.enums_to_js.EnumClassWriter.context` for the +context made available by the transpiler to override blocks. + +.. _override: + +``override`` +~~~~~~~~~~~~ + +All of the transpilation tags accept child override blocks to override default transpilation +of functions or objects or be used to add additional code to an object block or class. For +example, if we wanted to override the default transpilation of the Color class above to allow +instantiation off a cmyk value we could do so by adapting the get function and adding a new +static utility function called cmykToRgb. We would do so like this: + + +.. code:: js+django + + {% enums_to_js enums="examples.models.ExampleModel.Color" %} + + {# to override a function we must pass its name as the argument #} + {% override 'get' %} + static get(value) { + if (Array.isArray(value) && value.length === 4) { + value = Color.cmykToRgb(...value); + } + + if (Array.isArray(value) && value.length === 3) { + for (const en of this) { + let i = 0; + for (; i < 3; i++) { + if (en.rgb[i] !== value[i]) break; + } + if (i === 3) return en; + } + } + {{ default_impl }} + } + {% endoverride %} + + {# additions do not require a name argument #} + {% override %} + static cmykToRgb(c, m, y, k) { + + let r = 255 * (1 - c / 100) * (1 - k / 100); + let g = 255 * (1 - m / 100) * (1 - k / 100); + let b = 255 * (1 - y / 100) * (1 - k / 100); + + return [Math.round(r), Math.round(g), Math.round(b)] + } + {% endoverride %} + {% endenums_to_js %} + +When a function is overridden, the default implementation is available in the template context +as the ``default_impl`` variable. This allows you to add the default implementation from +code to your override. The context available to an override block varies depending on the +transpiler. See the individual tag sections for details. + +The above example will generate code that looks like this: + +.. code:: javascript + + class Color { + + static RED = new Color("R", "RED", "Red", [1, 0, 0], "ff0000"); + static GREEN = new Color("G", "GREEN", "Green", [0, 1, 0], "00ff00"); + static BLUE = new Color("B", "BLUE", "Blue", [0, 0, 1], "0000ff"); + + constructor (value, name, label, rgb, hex) { + this.value = value; + this.name = name; + this.label = label; + this.rgb = rgb; + this.hex = hex; + } + + toString() { + return this.value; + } + + static get(value) { + if (Array.isArray(value) && value.length === 4) { + value = Color.cmykToRgb(...value); + } + + if (Array.isArray(value) && value.length === 3) { + for (const en of this) { + let i = 0; + for (; i < 3; i++) { + if (en.rgb[i] !== value[i]) break; + } + if (i === 3) return en; + } + } + if (value instanceof this) { + return value; + } + + for (const en of this) { + if (en.value === value) { + return en; + } + } + throw new TypeError(`No Color enumeration maps to value ${value}`); + } + + static [Symbol.iterator]() { + return [Color.RED, Color.GREEN, Color.BLUE][Symbol.iterator](); + } + + static cmykToRgb(c, m, y, k) { + + let r = (1 - c / 100) * (1 - k / 100); + let g = (1 - m / 100) * (1 - k / 100); + let b = (1 - y / 100) * (1 - k / 100); + + return [Math.round(r), Math.round(g), Math.round(b)] + } + } + + +.. note:: + + The Jinja2 tags do not currently support overrides. diff --git a/pyproject.toml b/pyproject.toml index 8c8e5c3..a536efe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-render-static" -version = "2.0.3" +version = "2.1.0" description = "Use Django's template engine to render static files at deployment or package time. Includes transpilers for extending Django's url reversal and enums to JavaScript." authors = ["Brian Kohan "] license = "MIT" diff --git a/render_static/__init__.py b/render_static/__init__.py index 41fe668..0392bd9 100755 --- a/render_static/__init__.py +++ b/render_static/__init__.py @@ -14,7 +14,7 @@ from .transpilers.enums_to_js import EnumClassWriter from .transpilers.urls_to_js import ClassURLWriter, SimpleURLWriter -VERSION = (2, 0, 3) +VERSION = (2, 1, 0) __title__ = 'Django Render Static' __version__ = '.'.join(str(i) for i in VERSION) diff --git a/render_static/backends.py b/render_static/backends.py index e43130f..c4dfaa0 100755 --- a/render_static/backends.py +++ b/render_static/backends.py @@ -120,7 +120,7 @@ def default_env(**options): env = Environment(**options) env.globals.update(render_static.register.filters) env.globals.update({ - name: tag.__wrapped__ + name: getattr(tag, '__wrapped__', tag) for name, tag in render_static.register.tags.items() }) return env @@ -211,7 +211,6 @@ def select_templates( loader specific and documented on the loader. :return: The list of resolved template names """ - template_names = set() if callable(getattr(self.env.loader, 'select_templates', None)): for templates in self.env.loader.select_templates(selector): @@ -223,6 +222,7 @@ def select_templates( else: self.get_template(selector) template_names.add(selector) + if first_loader and template_names: return list(template_names) diff --git a/render_static/engine.py b/render_static/engine.py index 078a08a..28ea942 100644 --- a/render_static/engine.py +++ b/render_static/engine.py @@ -7,6 +7,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from django.template import Context, Template from django.template.backends.django import Template as DjangoTemplate from django.template.exceptions import TemplateDoesNotExist from django.template.utils import InvalidTemplateEngineError @@ -46,6 +47,17 @@ def __str__(self) -> str: f'{self.destination}' return f'{self.template.origin.template_name} -> {self.destination}' + @property + def is_dir(self) -> bool: + """ + True if the destination is a directory, false otherwise. + """ + return getattr( + getattr(self.template, 'template', None), + 'is_dir', + False + ) + def _resolve_context( context: Optional[Union[Dict, Callable, str, Path]], @@ -449,8 +461,6 @@ def resolve_destination( elif batch or Path(dest).is_dir(): dest = Path(dest) / template.template.name - os.makedirs(str(Path(dest if dest else '').parent), exist_ok=True) - return Path(dest if dest else '') def render_to_disk( # pylint: disable=R0913 @@ -576,15 +586,16 @@ def render_each( # pylint: disable=R0914 ctx = render.config.context.copy() if context is not None: ctx.update(context) - with open( - str(render.destination), 'w', encoding='UTF-8' - ) as temp_out: - temp_out.write( - render.template.render({ - **self.context, - **ctx - }) - ) + + r_ctx = {**self.context, **ctx} + dest = Template(render.destination).render(Context(r_ctx)) + + if render.is_dir: + os.makedirs(str(dest), exist_ok=True) + else: + os.makedirs(Path(dest or '').parent, exist_ok=True) + with open(str(dest), 'w', encoding='UTF-8') as out: + out.write(render.template.render(r_ctx)) yield render def resolve_renderings( diff --git a/render_static/loaders/django.py b/render_static/loaders/django.py index a1135e9..4a5fd2e 100644 --- a/render_static/loaders/django.py +++ b/render_static/loaders/django.py @@ -6,14 +6,16 @@ Templates in the future. """ +from collections.abc import Container from glob import glob from os.path import relpath from pathlib import Path -from typing import Generator, List, Tuple, Union +from typing import Generator, List, Optional, Tuple, Union from django.apps import apps from django.apps.config import AppConfig from django.core.exceptions import SuspiciousFileOperation +from django.template import Template from django.template.loaders.app_directories import Loader as AppDirLoader from django.template.loaders.filesystem import Loader as FilesystemLoader from django.template.loaders.filesystem import safe_join @@ -30,7 +32,53 @@ ] -class StaticFilesystemLoader(FilesystemLoader): +class DirectorySupport(FilesystemLoader): + """ + A mixin that allows directories to be templates. The templates resolved + when this mixin is used that are directories will have an is_dir + attribute that is set to True. + """ + is_dir = False + + def get_template( + self, + template_name: str, + skip: Optional[Container] = None + ) -> Template: + """ + Wrap the super class's get_template method and set our is_dir + flag depending on if get_contents raises an IsADirectoryError. + + :param template_name: The name of the template to load + :param skip: A container of origins to skip + :return: The loaded template + :raises TemplateDoesNotExist: If the template does not exist + """ + self.is_dir = False + template = super().get_template(template_name, skip=skip) + template.is_dir = self.is_dir + return template + + def get_contents(self, origin: AppOrigin) -> str: + """ + We wrap the super class's get_contents implementation and + set the is_dir flag if the origin is a directory. This is + alight touch approach that avoids touching any of the loader + internals and should be robust to future changes. + + :param origin: The origin of the template to load + :return: The contents of the template + :raises TemplateDoesNotExist: If the template does not + exist + """ + try: + return super().get_contents(origin) + except IsADirectoryError: + self.is_dir = True + return '' + + +class StaticFilesystemLoader(DirectorySupport): """ Simple extension of ``django.template.loaders.filesystem.Loader`` """ @@ -52,7 +100,7 @@ class StaticLocMemLoader(LocMemLoader): """ -class StaticAppDirectoriesLoader(AppDirLoader): +class StaticAppDirectoriesLoader(DirectorySupport, AppDirLoader): """ Extension of ``django.template.loaders.app_directories.Loader`` diff --git a/render_static/loaders/jinja2.py b/render_static/loaders/jinja2.py index 79bdfe2..2a8d26c 100644 --- a/render_static/loaders/jinja2.py +++ b/render_static/loaders/jinja2.py @@ -7,10 +7,22 @@ https://jinja.palletsprojects.com/en/3.0.x/api/#loaders """ +from os.path import normpath +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Callable, + MutableMapping, + Optional, + Tuple, +) + from render_static import Jinja2DependencyNeeded from render_static.loaders.mixins import BatchLoaderMixin try: + from jinja2.exceptions import TemplateNotFound from jinja2.loaders import ( ChoiceLoader, DictLoader, @@ -20,6 +32,9 @@ PackageLoader, PrefixLoader, ) + if TYPE_CHECKING: + from jinja2 import Environment, Template # pragma: no cover + except ImportError: # pragma: no cover ChoiceLoader = Jinja2DependencyNeeded # type: ignore DictLoader = Jinja2DependencyNeeded # type: ignore @@ -44,10 +59,51 @@ class StaticFileSystemLoader(FileSystemLoader): # pylint: disable=R0903 """ https://jinja.palletsprojects.com/en/3.0.x/api/#jinja2.FileSystemLoader - """ - -class StaticFileSystemBatchLoader(FileSystemLoader, BatchLoaderMixin): + We adapt the base loader to support loading directories as templates. + """ + is_dir: bool = False + + def load( + self, + environment: "Environment", + name: str, + globals: Optional[ # pylint: disable=redefined-builtin + MutableMapping[str, Any] + ] = None, + ) -> "Template": + """ + Wrap load so we can tag directory templates with is_dir. + """ + tmpl = super().load(environment, name, globals) + setattr(tmpl, 'is_dir', self.is_dir) + return tmpl + + def get_source( + self, environment: "Environment", template: str + ) -> Tuple[str, str, Callable[[], bool]]: + """ + Wrap get_source and handle the case where the template is + a directory. + """ + try: + self.is_dir = False + return super().get_source(environment, template) + except TemplateNotFound: + for search_path in self.searchpath: + pth = Path(search_path) / template + if pth.is_dir(): + self.is_dir = True + # code cov bug here, ignore it + return ( # pragma: no cover + '', + normpath(pth), + lambda: True + ) + raise + + +class StaticFileSystemBatchLoader(StaticFileSystemLoader, BatchLoaderMixin): """ This loader extends the basic StaticFileSystemLoader to work with batch selectors. Use this loader if you want to be able to use wildcards to load diff --git a/render_static/templatetags/render_static.py b/render_static/templatetags/render_static.py index 3a8de51..f2db6d0 100755 --- a/render_static/templatetags/render_static.py +++ b/render_static/templatetags/render_static.py @@ -2,12 +2,29 @@ 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, Collection, Iterable, List, Optional, Type, Union +from typing import ( + Any, + Callable, + Collection, + Dict, + Generator, + Iterable, + List, + Optional, + Type, + Union, +) 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 from django.utils.module_loading import import_string from django.utils.safestring import SafeString from render_static.transpilers import ( @@ -19,8 +36,6 @@ from render_static.transpilers.enums_to_js import EnumClassWriter from render_static.transpilers.urls_to_js import ClassURLWriter -register = template.Library() - __all__ = [ 'split', 'defines_to_js', @@ -28,6 +43,217 @@ 'enums_to_js' ] +Targets = Union[TranspilerTargets, TranspilerTarget] +TranspilerType = Union[Type[Transpiler], str] + + +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 = ( + import_string(transpiler) + if isinstance(transpiler, str) else transpiler + ) + return SafeString(transpiler(**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[str], 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() + for line in lines: + yield line + + + +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'] = { + 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[Node] = 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, False, 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') + @register.filter(name='split') def split(to_split: str, sep: Optional[str] = None) -> List[str]: @@ -44,12 +270,8 @@ def split(to_split: str, sep: Optional[str] = None) -> List[str]: return to_split.split() -@register.simple_tag -def transpile( - targets: Union[TranspilerTargets, TranspilerTarget], - transpiler: Union[Type[Transpiler], str], - **kwargs -) -> str: +@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. @@ -61,24 +283,16 @@ class to use. :param kwargs: Any kwargs that the transpiler takes. :return: """ - if isinstance(transpiler, str): - # mypy doesn't pick up this switch from str to class, import_string - # probably untyped - transpiler = import_string(transpiler) - - if isinstance(targets, (type, str)) or not isinstance(targets, Collection): - targets = [targets] - - return SafeString( - transpiler( # type: ignore - **kwargs - ).transpile(targets) + return do_transpile( + targets=targets, + transpiler=transpiler, + kwargs=kwargs ) -@register.simple_tag +@transpiler_tag(targets='url_conf') def urls_to_js( # pylint: disable=R0913,R0915 - transpiler: Union[Type[Transpiler], str] = ClassURLWriter, + transpiler: TranspilerType = ClassURLWriter, url_conf: Optional[Union[ModuleType, str]] = None, indent: str = '\t', depth: int = 0, @@ -194,20 +408,20 @@ def urls_to_js( # pylint: disable=R0913,R0915 :return: A javascript object containing functions that generate urls with and without parameters """ - - kwargs['depth'] = depth - kwargs['indent'] = indent - kwargs['include'] = include - kwargs['exclude'] = exclude - - return transpile( - targets=(url_conf if url_conf else settings.ROOT_URLCONF), + return do_transpile( + targets=url_conf or settings.ROOT_URLCONF, transpiler=transpiler, - **kwargs + kwargs={ + 'depth': depth, + 'indent': indent, + 'include': include, + 'exclude': exclude, + **kwargs + } ) -@register.simple_tag +@transpiler_tag def defines_to_js( defines: Union[ ModuleType, @@ -215,7 +429,7 @@ def defines_to_js( str, Collection[Union[ModuleType, Type[Any], str]] ], - transpiler: Union[Type[Transpiler], str] = DefaultDefineTranspiler, + transpiler: TranspilerType = DefaultDefineTranspiler, indent: str = '\t', depth: int = 0, **kwargs @@ -232,16 +446,14 @@ class that will perform the conversion, :param kwargs: Any other kwargs to pass to the transpiler. :return: SafeString of rendered transpiled code. """ - return transpile( + return do_transpile( targets=defines, transpiler=transpiler, - indent=indent, - depth=depth, - **kwargs + kwargs={'indent': indent, 'depth': depth, **kwargs} ) -@register.simple_tag +@transpiler_tag def enums_to_js( enums: Union[ ModuleType, @@ -249,7 +461,7 @@ def enums_to_js( str, Collection[Union[ModuleType, Type[Enum], str]] ], - transpiler: Union[Type[Transpiler], str] = EnumClassWriter, + transpiler: TranspilerType = EnumClassWriter, indent: str = '\t', depth: int = 0, **kwargs @@ -267,10 +479,23 @@ class to use for the transpilation. See transpiler docs for details. :return: SafeString of rendered transpiled code. """ - return transpile( + return do_transpile( targets=enums, transpiler=transpiler, - indent=indent, - depth=depth, - **kwargs + 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'], [], [], [], + [], {}, False, 'override', ) + name = p_args[0] if p_args else None + return OverrideNode(name, nodelist) diff --git a/render_static/tests/app1/static_jinja2/batch_test/__init__.py b/render_static/tests/app1/static_jinja2/batch_test/__init__.py new file mode 100644 index 0000000..ced40d6 --- /dev/null +++ b/render_static/tests/app1/static_jinja2/batch_test/__init__.py @@ -0,0 +1 @@ +{{ variable }} diff --git a/render_static/tests/app1/static_jinja2/batch_test/{{ site_name }}/__init__.py b/render_static/tests/app1/static_jinja2/batch_test/{{ site_name }}/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/render_static/tests/app1/static_jinja2/batch_test/{{ site_name }}/file1.py b/render_static/tests/app1/static_jinja2/batch_test/{{ site_name }}/file1.py new file mode 100644 index 0000000..8e6e6b0 --- /dev/null +++ b/render_static/tests/app1/static_jinja2/batch_test/{{ site_name }}/file1.py @@ -0,0 +1 @@ +{{ variable1 }} diff --git a/render_static/tests/app1/static_jinja2/batch_test/{{ site_name }}/file2.html b/render_static/tests/app1/static_jinja2/batch_test/{{ site_name }}/file2.html new file mode 100644 index 0000000..25e0e29 --- /dev/null +++ b/render_static/tests/app1/static_jinja2/batch_test/{{ site_name }}/file2.html @@ -0,0 +1 @@ +{{ variable2 }} diff --git a/render_static/tests/app1/static_jinja2/batch_test/{{ site_name }}/{{ sub_dir }}/.gitkeep b/render_static/tests/app1/static_jinja2/batch_test/{{ site_name }}/{{ sub_dir }}/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/render_static/tests/app1/static_templates/batch_test/__init__.py b/render_static/tests/app1/static_templates/batch_test/__init__.py new file mode 100644 index 0000000..ced40d6 --- /dev/null +++ b/render_static/tests/app1/static_templates/batch_test/__init__.py @@ -0,0 +1 @@ +{{ variable }} diff --git a/render_static/tests/app1/static_templates/batch_test/{{ site_name }}/__init__.py b/render_static/tests/app1/static_templates/batch_test/{{ site_name }}/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/render_static/tests/app1/static_templates/batch_test/{{ site_name }}/file1.py b/render_static/tests/app1/static_templates/batch_test/{{ site_name }}/file1.py new file mode 100644 index 0000000..8e6e6b0 --- /dev/null +++ b/render_static/tests/app1/static_templates/batch_test/{{ site_name }}/file1.py @@ -0,0 +1 @@ +{{ variable1 }} diff --git a/render_static/tests/app1/static_templates/batch_test/{{ site_name }}/file2.html b/render_static/tests/app1/static_templates/batch_test/{{ site_name }}/file2.html new file mode 100644 index 0000000..25e0e29 --- /dev/null +++ b/render_static/tests/app1/static_templates/batch_test/{{ site_name }}/file2.html @@ -0,0 +1 @@ +{{ variable2 }} diff --git a/render_static/tests/app1/static_templates/batch_test/{{ site_name }}/{{ sub_dir }}/.gitkeep b/render_static/tests/app1/static_templates/batch_test/{{ site_name }}/{{ sub_dir }}/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/render_static/tests/enum_app/static_templates/enum_app/test.js b/render_static/tests/enum_app/static_templates/enum_app/test.js index 4e9b429..f13def2 100644 --- a/render_static/tests/enum_app/static_templates/enum_app/test.js +++ b/render_static/tests/enum_app/static_templates/enum_app/test.js @@ -1,6 +1,6 @@ {% load enum_test %} {% block transpile_enums %} -{% enums_to_js enums=enums include_properties=include_properties exclude_properties=exclude_properties class_properties=class_properties properties=properties symmetric_properties=symmetric_properties %} +{% enums_to_js enums=enums include_properties=include_properties exclude_properties=exclude_properties class_properties=class_properties properties=properties symmetric_properties=symmetric_properties isymmetric_properties=isymmetric_properties to_string=to_string|default_bool:True %} {% endblock %} -{% enum_tests enums=test_enums|default:enums|enum_list class_properties=class_properties properties=test_properties|default:properties symmetric_properties=test_symmetric_properties|default:symmetric_properties class_name_map=class_name_map %} +{% enum_tests enums=test_enums|default:enums|enum_list class_properties=class_properties properties=test_properties|default:properties symmetric_properties=test_symmetric_properties|default:symmetric_properties class_name_map=class_name_map to_string=to_string|default_bool:True %} diff --git a/render_static/tests/enum_app/templatetags/enum_test.py b/render_static/tests/enum_app/templatetags/enum_test.py index 3c462e9..5e40422 100644 --- a/render_static/tests/enum_app/templatetags/enum_test.py +++ b/render_static/tests/enum_app/templatetags/enum_test.py @@ -37,6 +37,7 @@ def __init__( properties=True, symmetric_properties=None, class_name_map=None, + to_string=True, **kwargs ): self.name_map = name_map @@ -45,6 +46,7 @@ def __init__( if symmetric_properties: self.symmetric_properties_ = symmetric_properties or [] self.class_name_map_ = class_name_map or self.class_name_map_ + self.to_string_ = to_string super().__init__(*args, **kwargs) def start_visitation(self): @@ -76,7 +78,8 @@ def visit(self, enum, is_bool, final): yield f'enums.{enum.__name__} = {{' self.indent() - yield 'strings: {},' + if self.to_string_: + yield 'strings: {},' for prop in properties: yield f'{prop}: [],' yield 'getCheck: false' @@ -86,7 +89,9 @@ def visit(self, enum, is_bool, final): self.indent() for prop in properties: yield f'enums.{enum.__name__}.{prop}.push(en.{prop});' - yield f'enums.{enum.__name__}.strings[en.value] = en.toString();' + + if self.to_string_: + yield f'enums.{enum.__name__}.strings[en.value] = en.toString();' if class_properties: yield f'enums.{enum.__name__}.class_props = {{' @@ -127,7 +132,8 @@ def enum_tests( class_properties=True, properties=True, symmetric_properties=False, - class_name_map=None + class_name_map=None, + to_string=True, ): if name_map is None: name_map = {en: en.__name__ for en in enums} @@ -137,6 +143,14 @@ def enum_tests( class_properties=class_properties, properties=properties, symmetric_properties=symmetric_properties, - class_name_map=class_name_map + class_name_map=class_name_map, + to_string=to_string ).transpile(enums) ) + + +@register.filter(name='default_bool') +def default_bool(value, default): + if value in [None, '']: + return default + return value diff --git a/render_static/tests/examples/static_templates/examples/enums.js b/render_static/tests/examples/static_templates/examples/enums.js index 99bd729..ee8475b 100644 --- a/render_static/tests/examples/static_templates/examples/enums.js +++ b/render_static/tests/examples/static_templates/examples/enums.js @@ -1,4 +1,4 @@ -{% enums_to_js enums="render_static.tests.examples.models" %} +{% enums_to_js "render_static.tests.examples.models" %} console.log(Color.BLUE === Color.get('B')); for (const color of Color) { diff --git a/render_static/tests/examples_tests.py b/render_static/tests/examples_tests.py index 97a061a..d450685 100644 --- a/render_static/tests/examples_tests.py +++ b/render_static/tests/examples_tests.py @@ -12,6 +12,7 @@ from django.test import override_settings from django.urls import reverse from render_static.tests.js_tests import ( + GLOBAL_STATIC_DIR, EnumComparator, StructureDiff, URLJavascriptMixin, @@ -135,6 +136,70 @@ def test_readme_enums(self): "'Blue',\n rgb: [ 0, 0, 1 ],\n hex: '0000ff'\n}" ) +@override_settings( + STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'color.js': """ +{% enums_to_js enums="render_static.tests.examples.models.ExampleModel.Color" %} + {# to override a function we must pass its name as the argument #} + {% override 'get' %} +static get(value) { + if (Array.isArray(value) && value.length === 4) { + value = Color.cmykToRgb(...value); + } + + if (Array.isArray(value) && value.length === 3) { + for (const en of this) { + let i = 0; + for (; i < 3; i++) { + if (en.rgb[i] !== value[i]) break; + } + if (i === 3) return en; + } + } + {{ default_impl }} +} + {% endoverride %} + + {# additions do not require a name argument #} + {% override %} +static cmykToRgb(c, m, y, k) { + + let r = (1 - c / 100) * (1 - k / 100); + let g = (1 - m / 100) * (1 - k / 100); + let b = (1 - y / 100) * (1 - k / 100); + + return [Math.round(r), Math.round(g), Math.round(b)] +} + {% endoverride %} +{% endenums_to_js %} +console.log(Color.get([0, 100, 100, 0]).label); +""" + }), + 'render_static.loaders.StaticAppDirectoriesBatchLoader' + ] + }, + }] + } +) +class TestEnumOverrideExample(BaseTestCase): + + to_remove = [ + GLOBAL_STATIC_DIR + ] + + def tearDown(self): + pass + + def test_override_example(self): + call_command('renderstatic', 'color.js') + result = run_js_file(GLOBAL_STATIC_DIR / 'color.js') + self.assertEqual(result, 'Red') + @override_settings( ROOT_URLCONF='render_static.tests.examples.urls', diff --git a/render_static/tests/jinja2_tests.py b/render_static/tests/jinja2_tests.py index fd7e7a4..60ac7af 100644 --- a/render_static/tests/jinja2_tests.py +++ b/render_static/tests/jinja2_tests.py @@ -23,11 +23,13 @@ from render_static.tests.tests import ( APP1_STATIC_DIR, APP2_STATIC_DIR, + BATCH_RENDER_TEMPLATES, EXPECTED_DIR, GLOBAL_STATIC_DIR, STATIC_TEMP_DIR, STATIC_TEMP_DIR2, BaseTestCase, + BatchRenderTestCase, TemplatePreferenceFSTestCase, empty_or_dne, generate_context1, @@ -624,3 +626,35 @@ def test_wildcards2(self): ), ]: self.assertTrue(filecmp.cmp(source, dest, shallow=False)) + + +@override_settings(STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticJinja2Templates', + 'APP_DIRS': True, + 'OPTIONS': { + 'autoescape': False + } + }], + 'templates': BATCH_RENDER_TEMPLATES +}) +class Jinja2BatchRenderTestCase(BatchRenderTestCase): + + def tearDown(self): + pass + + @override_settings(STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticJinja2Templates', + 'APP_DIRS': True, + 'OPTIONS': { + 'autoescape': False + } + }], + 'templates': [ + ('batch_test/{{ dne }}', {}) + ] + }) + def test_batch_render_not_found(self): + with self.assertRaises(CommandError): + call_command('renderstatic', 'batch_test/{{ dne }}') diff --git a/render_static/tests/js_tests.py b/render_static/tests/js_tests.py index 1cd79f7..f27bc04 100644 --- a/render_static/tests/js_tests.py +++ b/render_static/tests/js_tests.py @@ -282,8 +282,6 @@ def test_modules_to_js(self): self.assertFalse(jf.readline().startswith(' ')) def test_empty_module_to_js(self): - # import ipdb - # ipdb.set_trace() call_command('renderstatic', 'empty_defines.js') self.assertEqual( self.diff_modules( @@ -385,12 +383,171 @@ def test_split(self): # pass + +@override_settings(STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'app_dir': 'custom_templates', + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'defines1.js':""" +{% defines_to_js defines=classes indent=" " %} +{% override %} +EXTRA_DEFINE: { + 'a': 1, + 'b': 2 +} +{% endoverride %} +{% override "ExtendedDefines.DICTIONARY" %} +null +{% endoverride %} +{% enddefines_to_js %} +console.log(JSON.stringify(defines)); +""" + }) + ], + 'builtins': ['render_static.templatetags.render_static'] + }, + }], + 'templates': { + 'defines1.js': { + 'dest': GLOBAL_STATIC_DIR / 'defines1.js', + 'context': { + 'classes': [ + defines.MoreDefines, + 'render_static.tests.defines.ExtendedDefines' + ] + } + } + } +}) +class DefinesToJavascriptOverrideTest(StructureDiff, BaseTestCase): + + def tearDown(self): + pass + + def test_define_overrides(self): + call_command('renderstatic', 'defines1.js') + js_dict = self.get_js_structure(GLOBAL_STATIC_DIR / 'defines1.js') + self.assertEqual( + js_dict, + { + 'EXTRA_DEFINE': {'a': 1, 'b': 2}, + 'ExtendedDefines': { + 'DEFINE1': 'D1', + 'DEFINE2': 'D2', + 'DEFINE3': 'D3', + 'DEFINE4': 'D4', + 'DEFINES': [['D1', 'Define 1'], + ['D2', 'Define 2'], + ['D3', 'Define 3'], + ['D4', 'Define 4']], + 'DICTIONARY': None + }, + 'MoreDefines': { + 'MDEF1': 'MD1', + 'MDEF2': 'MD2', + 'MDEF3': 'MD3', + 'MDEFS': [ + ['MD1', 'MDefine 1'], + ['MD2', 'MDefine 2'], + ['MD3', 'MDefine 3'] + ] + } + } + ) + + @override_settings(STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'app_dir': 'custom_templates', + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'defines1.js':""" +{% defines_to_js defines=classes indent=" " %} +{% override %} +EXTRA_DEFINE: { + const_name: "{{ const_name }}" +} +{% endoverride %} +{% override "ExtendedDefines.DICTIONARY" %} +{ + const_name: "{{ const_name }}" +} +{% endoverride %} +{% enddefines_to_js %} +console.log(JSON.stringify(defines)); +""" + }) + ], + 'builtins': ['render_static.templatetags.render_static'] + }, + }], + 'templates': { + 'defines1.js': { + 'dest': GLOBAL_STATIC_DIR / 'defines1.js', + 'context': { + 'classes': [ + defines.MoreDefines, + 'render_static.tests.defines.ExtendedDefines' + ], + 'context_keys': [ + + ] + } + } + } + }) + def test_define_override_context(self): + call_command('renderstatic', 'defines1.js') + js_dict = self.get_js_structure(GLOBAL_STATIC_DIR / 'defines1.js') + + self.assertEqual( + js_dict, + { + 'EXTRA_DEFINE': { + 'const_name': 'defines' + }, + 'ExtendedDefines': { + 'DEFINE1': 'D1', + 'DEFINE2': 'D2', + 'DEFINE3': 'D3', + 'DEFINE4': 'D4', + 'DEFINES': [['D1', 'Define 1'], + ['D2', 'Define 2'], + ['D3', 'Define 3'], + ['D4', 'Define 4']], + 'DICTIONARY': { + 'const_name': 'defines' + } + }, + 'MoreDefines': { + 'MDEF1': 'MD1', + 'MDEF2': 'MD2', + 'MDEF3': 'MD3', + 'MDEFS': [ + ['MD1', 'MDefine 1'], + ['MD2', 'MDefine 2'], + ['MD3', 'MDefine 3'] + ] + } + } + ) + class URLJavascriptMixin: url_js = None class_mode = None legacy_args = False + def get_js_structure(self, js_file): # pragma: no cover + json_structure = run_js_file(js_file) + if json_structure: + return json.loads(json_structure) + return None + def clear_placeholder_registries(self): from importlib import reload reload(placeholders) @@ -561,8 +718,8 @@ def convert_idx_to_type(arr, idx, typ): }) class URLSToJavascriptTest(URLJavascriptMixin, BaseTestCase): - # def tearDown(self): - # pass + def tearDown(self): + pass def setUp(self): self.clear_placeholder_registries() @@ -709,7 +866,7 @@ def test_full_url_dump_class_legacy_args(self): 'OPTIONS': { 'loaders': [ ('render_static.loaders.StaticLocMemLoader', { - 'urls.js': 'var urls = {\n{% urls_to_js transpiler="render_static.SimpleURLWriter" %}};' + 'urls.js': 'const urls = {\n{% urls_to_js transpiler="render_static.SimpleURLWriter" %}};' }) ], 'builtins': ['render_static.templatetags.render_static'] @@ -722,6 +879,215 @@ def test_full_url_dump_es6(self): """ self.test_full_url_dump() + @override_settings(STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'urls.js': """ +const urls = { +{% urls_to_js transpiler="render_static.SimpleURLWriter" include="path_tst,re_path_mixed,app2:app1"|split:"," exclude="admin"|split raise_on_not_found=False%} + {% override "re_path_mixed" %} +return { + 'qname': '{{qname}}', + 'raise_on_not_found': {% if raise_on_not_found %}true{% else %}false{% endif %}, + 'include': [{% for inc in include %}'{{ inc }}',{% endfor %}], + 'exclude': [{% for exc in exclude %}'{{ exc }}',{% endfor %}], +}; + {% endoverride %} + {% override "app2:app1:re_path_unnamed" %} +return { + 'qname': '{{qname}}', + 'raise_on_not_found': {% if raise_on_not_found %}true{% else %}false{% endif %}, + 'include': [{% for inc in include %}'{{ inc }}',{% endfor %}], + 'exclude': [{% for exc in exclude %}'{{ exc }}',{% endfor %}], +}; + {% endoverride %} + {% override %} + extra: () => { + return JSON.stringify({ + 're_path_mixed': urls['re_path_mixed'](), + 'app2:app1:re_path_unnamed': urls.app2.app1.re_path_unnamed(), + 'path_tst': urls['path_tst']() + }) + } + {% endoverride %} +{% endurls_to_js %} +}; +console.log(urls.extra()); +""" + }) + ], + 'builtins': ['render_static.templatetags.render_static'] + }, + }], + }) + def test_simple_url_overrides(self): + """ + Check that SimpleURLWriter qname overrides work as expected. + """ + self.url_js = None + call_command('renderstatic', 'urls.js') + js_dict = self.get_js_structure(GLOBAL_STATIC_DIR / 'urls.js') + + expected_context = { + 'exclude': ['admin'], + 'include': ['path_tst','re_path_mixed','app2:app1'], + 'raise_on_not_found': False + } + for key, value in {'qname': 're_path_mixed', **expected_context}.items(): + self.assertEqual(js_dict['re_path_mixed'][key], value) + + for key, value in {'qname': 'app2:app1:re_path_unnamed', **expected_context}.items(): + self.assertEqual(js_dict['app2:app1:re_path_unnamed'][key], value) + + self.assertEqual(js_dict['path_tst'], '/test/simple/') + + + @override_settings(STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'urls.js': """ +{% urls_to_js transpiler="render_static.ClassURLWriter" include="path_tst,re_path_mixed,app2:app1"|split:"," exclude="admin"|split raise_on_not_found=False%} + + {% override "constructor" %} + +constructor(options=null) { + this.constructor_ctx = { + 'raise_on_not_found': {% if raise_on_not_found %}true{% else %}false{% endif %}, + 'include': [{% for inc in include %}'{{ inc }}',{% endfor %}], + 'exclude': [{% for exc in exclude %}'{{ exc }}',{% endfor %}], + }; + {{ default_impl }} +} + {% endoverride %} + + {% override "#match" %} + +#match(kwargs, args, expected, defaults={}) { + this.match_ctx = { + 'raise_on_not_found': {% if raise_on_not_found %}true{% else %}false{% endif %}, + 'include': [{% for inc in include %}'{{ inc }}',{% endfor %}], + 'exclude': [{% for exc in exclude %}'{{ exc }}',{% endfor %}], + }; + {{ default_impl }} +} + {% endoverride %} + + {% override "deepEqual" %} + +deepEqual(object1, object2) { + this.deepEqual_ctx = { + 'raise_on_not_found': {% if raise_on_not_found %}true{% else %}false{% endif %}, + 'include': [{% for inc in include %}'{{ inc }}',{% endfor %}], + 'exclude': [{% for exc in exclude %}'{{ exc }}',{% endfor %}], + }; + {{ default_impl }} +} + {% endoverride %} + + {% override "isObject" %} + +isObject(object) { + this.isObject_ctx = { + 'raise_on_not_found': {% if raise_on_not_found %}true{% else %}false{% endif %}, + 'include': [{% for inc in include %}'{{ inc }}',{% endfor %}], + 'exclude': [{% for exc in exclude %}'{{ exc }}',{% endfor %}], + }; + {{ default_impl }} +} + {% endoverride %} + + {% override "reverse" %} + +reverse(qname, options={}) { + {{ default_impl }} + this.reverse_ctx = { + 'raise_on_not_found': {% if raise_on_not_found %}true{% else %}false{% endif %}, + 'include': [{% for inc in include %}'{{ inc }}',{% endfor %}], + 'exclude': [{% for exc in exclude %}'{{ exc }}',{% endfor %}], + }; + return url(kwargs, args); +} + {% endoverride %} + + {% override "re_path_mixed" %} +return { + 'qname': '{{qname}}', + 'raise_on_not_found': {% if raise_on_not_found %}true{% else %}false{% endif %}, + 'include': [{% for inc in include %}'{{ inc }}',{% endfor %}], + 'exclude': [{% for exc in exclude %}'{{ exc }}',{% endfor %}], +}; + {% endoverride %} + {% override "app2:app1:re_path_unnamed" %} +return { + 'qname': '{{qname}}', + 'raise_on_not_found': {% if raise_on_not_found %}true{% else %}false{% endif %}, + 'include': [{% for inc in include %}'{{ inc }}',{% endfor %}], + 'exclude': [{% for exc in exclude %}'{{ exc }}',{% endfor %}], +}; + {% endoverride %} + {% override %} +extra() { + return JSON.stringify({ + 're_path_mixed': urls.reverse('re_path_mixed'), + 'app2:app1:re_path_unnamed': urls.reverse('app2:app1:re_path_unnamed'), + 'path_tst': urls.reverse('path_tst', {kwargs: {arg1: 1, arg2: 'xo'}}), + 'deepEqual': this.deepEqual({a: 1}, {a: 1}), + 'extra_ctx': { + 'raise_on_not_found': {% if raise_on_not_found %}true{% else %}false{% endif %}, + 'include': [{% for inc in include %}'{{ inc }}',{% endfor %}], + 'exclude': [{% for exc in exclude %}'{{ exc }}',{% endfor %}], + }, + 'constructor_ctx': this.constructor_ctx, + 'match_ctx': this.match_ctx, + 'deepEqual_ctx': this.deepEqual_ctx, + 'isObject_ctx': this.isObject_ctx, + 'reverse_ctx': this.reverse_ctx + }) +} + {% endoverride %} +{% endurls_to_js %} +urls = new URLResolver(); +console.log(urls.extra()); +""" + }) + ], + 'builtins': ['render_static.templatetags.render_static'] + }, + }], + }) + def test_class_url_overrides(self): + """ + Check that SimpleURLWriter qname overrides work as expected. + """ + self.url_js = None + call_command('renderstatic', 'urls.js') + js_dict = self.get_js_structure(GLOBAL_STATIC_DIR / 'urls.js') + + expected_context = { + 'exclude': ['admin'], + 'include': ['path_tst','re_path_mixed','app2:app1'], + 'raise_on_not_found': False + } + for key, value in {'qname': 're_path_mixed', **expected_context}.items(): + self.assertEqual(js_dict['re_path_mixed'][key], value) + + for key, value in {'qname': 'app2:app1:re_path_unnamed', **expected_context}.items(): + self.assertEqual(js_dict['app2:app1:re_path_unnamed'][key], value) + + self.assertEqual(js_dict['path_tst'], '/test/different/1/xo') + self.assertTrue(js_dict['deepEqual']) + + for func in ['constructor', 'match', 'deepEqual', 'isObject', 'reverse']: + for key, value in expected_context.items(): + self.assertEqual(js_dict[func + '_ctx'][key], value) + + @override_settings(STATIC_TEMPLATES={ 'ENGINES': [{ 'BACKEND': 'render_static.backends.StaticDjangoTemplates', @@ -1937,8 +2303,8 @@ def test_class_parameters_es6(self): self.compare('path_tst', {'arg1': 12, 'arg2': 'xo'}, js_generator=generator, url_path=up3_exprt) # uncomment to not delete generated js - # def tearDown(self): - # pass + def tearDown(self): + pass @override_settings( @@ -2278,14 +2644,16 @@ def enums_compare( js_file, enum_classes, class_properties=True, - properties=True + properties=True, + to_string=True ): for enum in enum_classes: self.enum_compare( js_file, enum, class_properties=class_properties, - properties=properties + properties=properties, + to_string=to_string ) def enum_compare( @@ -2293,7 +2661,8 @@ def enum_compare( js_file, cls, class_properties=True, - properties=True + properties=True, + to_string=True ): """ Given a javascript file and a list of classes, evaluate the javascript @@ -2331,10 +2700,11 @@ def to_js_test(value): return value enum_dict = { - 'strings': { + **({'strings': { en.value.isoformat() if isinstance(en.value, date) - else str(en.value): str(en) for en in cls}, + else str(en.value): str(en) for en in cls + }} if to_string else {}), **{ prop: [ to_js_test(getattr(en, prop)) @@ -2558,6 +2928,53 @@ def test_class_prop_option(self): 'class_name', get_content(ENUM_STATIC_DIR / 'enum_app/test.js') ) + + @override_settings( + STATIC_TEMPLATES={ + 'context': { + 'include_properties': True, + 'class_properties': False, + 'properties': True, + 'symmetric_properties': False, + 'to_string': False + }, + 'templates': [ + ('enum_app/test.js', { + 'context': { + 'enums': EnumTester.MapBoxStyle + } + }), + ] + } + ) + def test_disable_to_string(self): + call_command('renderstatic', 'enum_app/test.js') + self.enums_compare( + js_file=ENUM_STATIC_DIR / 'enum_app/test.js', + enum_classes=[EnumTester.MapBoxStyle], + class_properties=False, + to_string=False + ) + self.assertNotIn( + 'class_name', + get_content(ENUM_STATIC_DIR / 'enum_app/test.js') + ) + self.assertNotIn( + 'toString', + get_content(ENUM_STATIC_DIR / 'enum_app/test.js') + ) + self.assertNotIn( + 'this.str', + get_content(ENUM_STATIC_DIR / 'enum_app/test.js') + ) + self.assertNotIn( + ', str) {', + get_content(ENUM_STATIC_DIR / 'enum_app/test.js') + ) + self.assertNotIn( + ', "1");', + get_content(ENUM_STATIC_DIR / 'enum_app/test.js') + ) @override_settings( STATIC_TEMPLATES={ @@ -2666,6 +3083,47 @@ def test_exclude_props_param(self): self.assertIn('this.slug = ', contents) self.assertIn('this.label = ', contents) + + @override_settings( + STATIC_TEMPLATES={ + 'context': { + 'include_properties': ['slug', 'label'], + 'properties': True, + 'test_properties': [ + 'slug', + 'label', + 'value' + ], + 'symmetric_properties': False + }, + 'templates': [ + ('enum_app/test.js', { + 'context': { + 'enums': [ + EnumTester.MapBoxStyle + ] + } + }), + ] + } + ) + def test_include_props_list(self): + call_command('renderstatic', 'enum_app/test.js') + self.enums_compare( + js_file=ENUM_STATIC_DIR / 'enum_app/test.js', + enum_classes=[EnumTester.MapBoxStyle], + class_properties=False, + properties=['slug', 'label', 'value'] + ) + contents = get_content(ENUM_STATIC_DIR / 'enum_app/test.js') + self.assertNotIn('uri', contents) + self.assertNotIn('version', contents) + self.assertNotIn('name', contents) + self.assertIn('this.value = ', contents) + self.assertIn('this.slug = ', contents) + self.assertIn('this.label = ', contents) + + @override_settings( STATIC_TEMPLATES={ 'ENGINES': [{ @@ -2811,7 +3269,7 @@ def test_symmetric_props(self): self.assertIn('=== MapBoxStyle.get("Satellite Streets");', content) self.assertIn('=== MapBoxStyle.get("satellite-streets");', content) self.assertIn('=== MapBoxStyle.get(6);', content) - self.assertEqual(content.count('switch(value)'), 4) + self.assertEqual(content.count('for (const en of this)'), 4) @override_settings( STATIC_TEMPLATES={ @@ -2819,6 +3277,7 @@ def test_symmetric_props(self): 'include_properties': True, 'properties': True, 'symmetric_properties': True, + 'isymmetric_properties': [], 'test_symmetric_properties': [ 'name', 'slug', @@ -2851,7 +3310,68 @@ def test_resolve_symmetric_props(self): self.assertIn('=== MapBoxStyle.get("satellite-streets");', content) self.assertIn('=== MapBoxStyle.get("mapbox://styles/mapbox/satellite-streets-v11");', content) self.assertIn('=== MapBoxStyle.get(6);', content) - self.assertEqual(content.count('switch(value)'), 5) + self.assertEqual(content.count('for (const en of this)'), 5) + self.assertNotIn('ciCompare', content) + + + @override_settings( + STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test.js': '{% enums_to_js enums=enums symmetric_properties=True on_unrecognized="RETURN_NULL" %}\n' + 'console.log(JSON.stringify({' + 'StReEtS: MapBoxStyle.get("StReEtS").label,' + f'"{EnumTester.MapBoxStyle.NAVIGATION_DAY.uri.upper()}": MapBoxStyle.get("{EnumTester.MapBoxStyle.NAVIGATION_DAY.uri.upper()}")' + '}));' + }), + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test2.js': '{% enums_to_js enums=enums symmetric_properties=True isymmetric_properties="uri"|split on_unrecognized="RETURN_NULL" %}\n' + 'console.log(JSON.stringify({' + 'StReEtS: MapBoxStyle.get("StReEtS"),' + f'"{EnumTester.MapBoxStyle.NAVIGATION_DAY.uri.upper()}": MapBoxStyle.get("{EnumTester.MapBoxStyle.NAVIGATION_DAY.uri.upper()}").label' + '}));' + }) + ], + 'builtins': [ + 'render_static.templatetags.render_static' + ] + }, + }], + 'templates': [ + ('enum_app/test.js', { + 'context': { + 'enums': [ + EnumTester.MapBoxStyle + ] + } + }), + ('enum_app/test2.js', { + 'context': { + 'enums': [ + EnumTester.MapBoxStyle + ] + } + }), + ] + } + ) + def test_resolve_isymmetric_props(self): + call_command('renderstatic', ['enum_app/test.js', 'enum_app/test2.js']) + content = get_content(GLOBAL_STATIC_DIR / 'enum_app/test.js') + self.assertIn('static ciCompare', content) + js_dict = self.get_js_structure(GLOBAL_STATIC_DIR / 'enum_app/test.js') + self.assertEqual(js_dict['StReEtS'], 'Streets') + self.assertEqual(js_dict[EnumTester.MapBoxStyle.NAVIGATION_DAY.uri.upper()], None) + + content = get_content(GLOBAL_STATIC_DIR / 'enum_app/test2.js') + self.assertIn('static ciCompare', content) + js_dict = self.get_js_structure(GLOBAL_STATIC_DIR / 'enum_app/test2.js') + self.assertEqual(js_dict['StReEtS'], None) + self.assertEqual(js_dict[EnumTester.MapBoxStyle.NAVIGATION_DAY.uri.upper()], EnumTester.MapBoxStyle.NAVIGATION_DAY.label) + @override_settings( STATIC_TEMPLATES={ @@ -2860,7 +3380,7 @@ def test_resolve_symmetric_props(self): 'OPTIONS': { 'loaders': [ ('render_static.loaders.StaticLocMemLoader', { - 'enum_app/test.js': '{% enums_to_js enums=enums summetric_properties=True raise_on_not_found=False %}\n' + 'enum_app/test.js': '{% enums_to_js enums=enums symmetric_properties=True on_unrecognized="RETURN_NULL" %}\n' 'console.log(JSON.stringify({not_found: AddressRoute.get("Aly")}));' }) ], @@ -2892,7 +3412,107 @@ def test_no_raise_on_not_found(self): 'OPTIONS': { 'loaders': [ ('render_static.loaders.StaticLocMemLoader', { - 'enum_app/test.js': '{% enums_to_js enums=enums summetric_properties=True raise_on_not_found=True %}\n' + 'enum_app/test.js': '{% enums_to_js enums=enums symmetric_properties=True raise_on_not_found=False %}\n' + 'console.log(JSON.stringify({not_found: AddressRoute.get("Aly")}));' + }) + ], + 'builtins': [ + 'render_static.templatetags.render_static' + ] + }, + }], + 'templates': [ + ('enum_app/test.js', { + 'context': { + 'enums': [ + EnumTester.AddressRoute + ] + } + }), + ] + } + ) + def test_raise_on_not_found_false_deprecated(self): + with self.assertWarns(DeprecationWarning): + call_command('renderstatic', 'enum_app/test.js') + js_dict = self.get_js_structure(GLOBAL_STATIC_DIR / 'enum_app/test.js') + self.assertDictEqual(js_dict, {'not_found': None}) + + @override_settings( + STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test.js': '{% enums_to_js enums=enums symmetric_properties=True raise_on_not_found=True %}\n' + 'console.log(JSON.stringify({not_found: AddressRoute.get("Aly")}));' + }) + ], + 'builtins': [ + 'render_static.templatetags.render_static' + ] + }, + }], + 'templates': [ + ('enum_app/test.js', { + 'context': { + 'enums': [ + EnumTester.AddressRoute + ] + } + }), + ] + } + ) + def test_raise_on_not_found_true_deprecated(self): + with self.assertWarns(DeprecationWarning): + call_command('renderstatic', 'enum_app/test.js') + self.assertIn( + 'TypeError: No AddressRoute enumeration maps to value Aly', + run_js_file(GLOBAL_STATIC_DIR / 'enum_app/test.js') + ) + + @override_settings( + STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test.js': '{% enums_to_js enums=enums on_unrecognized="RETURN_INPUT" %}\n' + 'console.log(JSON.stringify({not_found: AddressRoute.get("Aly")}));' + }) + ], + 'builtins': [ + 'render_static.templatetags.render_static' + ] + }, + }], + 'templates': [ + ('enum_app/test.js', { + 'context': { + 'enums': [ + EnumTester.AddressRoute + ] + } + }), + ] + } + ) + def test_return_input_on_unrecognized(self): + call_command('renderstatic', 'enum_app/test.js') + js_dict = self.get_js_structure(GLOBAL_STATIC_DIR / 'enum_app/test.js') + self.assertDictEqual(js_dict, {'not_found': 'Aly'}) + + @override_settings( + STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test.js': '{% enums_to_js enums=enums symmetric_properties=True on_unrecognized="THROW_EXCEPTION" %}\n' 'try { AddressRoute.get("Aly") } catch (e) {console.log(JSON.stringify({not_found: e instanceof TypeError ? "TypeError" : "Unknown"}));}' }) ], @@ -2924,7 +3544,7 @@ def test_raise_on_not_found(self): 'OPTIONS': { 'loaders': [ ('render_static.loaders.StaticLocMemLoader', { - 'enum_app/test.js': '{% enums_to_js enums=enums summetric_properties=True transpiler="render_static.EnumClassWriter" %}\n' + 'enum_app/test.js': '{% enums_to_js enums=enums symmetric_properties=True transpiler="render_static.EnumClassWriter" %}\n' 'try { AddressRoute.get("Aly") } catch (e) {console.log(JSON.stringify({not_found: e instanceof TypeError ? "TypeError" : "Unknown"}));}' }) ], @@ -2947,6 +3567,38 @@ def test_raise_on_not_found(self): def test_default_raise_on_not_found(self): return self.test_raise_on_not_found.__wrapped__(self) + @override_settings( + STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test.js': '{% enums_to_js enums=enums %}\n' + 'console.log(JSON.stringify({found: AddressRoute.get(AddressRoute.AVENUE)}));' + }) + ], + 'builtins': [ + 'render_static.templatetags.render_static' + ] + }, + }], + 'templates': [ + ('enum_app/test.js', { + 'context': { + 'enums': [ + EnumTester.AddressRoute + ] + } + }), + ] + } + ) + def test_return_instance(self): + call_command('renderstatic', 'enum_app/test.js') + js_dict = self.get_js_structure(GLOBAL_STATIC_DIR / 'enum_app/test.js') + self.assertEqual(js_dict['found']['value'], 'AVE') + @override_settings( STATIC_TEMPLATES={ 'templates': [ @@ -3045,8 +3697,271 @@ def test_chained_enum_values(self): self.assertIn('static VALUE1 = new DependentEnum(1, "VALUE1", IndependentEnum.VALUE1, "DependentEnum.VALUE1");', contents) self.assertIn('static VALUE2 = new DependentEnum(2, "VALUE2", IndependentEnum.VALUE0, "DependentEnum.VALUE2");', contents) - #def tearDown(self): - # pass + def tearDown(self): + pass + + @override_settings( + STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test.js': """ +{% enums_to_js enums=enums symmetric_properties=True %} +{% override "get" %} +/** + * A comment. + */ +static get(value) { + if (value === null) { + return { + 'enum': {% autoescape off %}"{{enum}}"{% endautoescape %}, + 'class_name': "{{class_name}}", + 'properties': [{% for prop in properties %}"{{prop}}",{% endfor %}], + 'class_properties': [{% for prop in class_properties %}"{{prop}}",{% endfor %}], + 'symmetric_properties': [{% for prop in symmetric_properties %}"{{prop}}",{% endfor %}], + 'to_string': {% if to_string %}true{% else %}false{% endif %}, + 'str_prop': "{{ str_prop }}" + }; + } + {{ default_impl }}} +{% endoverride %} + +'enum': self.enum, +'class_name': self.class_name, +'properties': self.properties, +'str_prop': self.str_prop, +'class_properties': self.class_properties, +'symmetric_properties': self.symmetric_properties, +'to_string': self.to_string_ + +{% override "constructor" %} +constructor (value, name, label, alt, str, str0) { + {{ default_impl }} + this.constructor_context = { + 'enum': {% autoescape off %}"{{enum}}"{% endautoescape %}, + 'class_name': "{{class_name}}", + 'properties': [{% for prop in properties %}"{{prop}}",{% endfor %}], + 'class_properties': [{% for prop in class_properties %}"{{prop}}",{% endfor %}], + 'symmetric_properties': [{% for prop in symmetric_properties %}"{{prop}}",{% endfor %}], + 'to_string': {% if to_string %}true{% else %}false{% endif %}, + 'str_prop': "{{ str_prop }}" + }; +} +{% endoverride %} + +{% override "toString" %} +toString() { + return { + 'enum': {% autoescape off %}"{{enum}}"{% endautoescape %}, + 'class_name': "{{class_name}}", + 'properties': [{% for prop in properties %}"{{prop}}",{% endfor %}], + 'class_properties': [{% for prop in class_properties %}"{{prop}}",{% endfor %}], + 'symmetric_properties': [{% for prop in symmetric_properties %}"{{prop}}",{% endfor %}], + 'to_string': {% if to_string %}true{% else %}false{% endif %}, + 'str_prop': "{{ str_prop }}" + }; +} +{% endoverride %} + +{% override "ciCompare" %} +static ciCompare(a, b) { + {{ class_name}}.ci_compare_context = { + 'enum': {% autoescape off %}"{{enum}}"{% endautoescape %}, + 'class_name': "{{class_name}}", + 'properties': [{% for prop in properties %}"{{prop}}",{% endfor %}], + 'class_properties': [{% for prop in class_properties %}"{{prop}}",{% endfor %}], + 'symmetric_properties': [{% for prop in symmetric_properties %}"{{prop}}",{% endfor %}], + 'to_string': {% if to_string %}true{% else %}false{% endif %}, + 'str_prop': "{{ str_prop }}" + }; + {{ default_impl }} +} +{% endoverride %} + + +{% override "[Symbol.iterator]" %} +static [Symbol.iterator]() { + {{ class_name }}.iterator_context = { + 'enum': {% autoescape off %}"{{enum}}"{% endautoescape %}, + 'class_name': "{{class_name}}", + 'properties': [{% for prop in properties %}"{{prop}}",{% endfor %}], + 'class_properties': [{% for prop in class_properties %}"{{prop}}",{% endfor %}], + 'symmetric_properties': [{% for prop in symmetric_properties %}"{{prop}}",{% endfor %}], + 'to_string': {% if to_string %}true{% else %}false{% endif %}, + 'str_prop': "{{ str_prop }}" + }; + {{ default_impl }} +} +{% endoverride %} + +{% override "testContext" %} +static testContext() { + return { + 'enum': {% autoescape off %}"{{enum}}"{% endautoescape %}, + 'class_name': "{{class_name}}", + 'properties': [{% for prop in properties %}"{{prop}}",{% endfor %}], + 'class_properties': [{% for prop in class_properties %}"{{prop}}",{% endfor %}], + 'symmetric_properties': [{% for prop in symmetric_properties %}"{{prop}}",{% endfor %}], + 'to_string': {% if to_string %}true{% else %}false{% endif %}, + 'str_prop': "{{ str_prop }}" + }; +} +{% endoverride %} + +{% endenums_to_js %} +console.log(JSON.stringify( + { + getNull: AddressRoute.get(null), + getAve: AddressRoute.get(AddressRoute.AVENUE).label, + getAve2: AddressRoute.get('AvEnUe').label, + testContext: AddressRoute.testContext(), + toString: AddressRoute.AVENUE.toString(), + constructor_context: AddressRoute.AVENUE.constructor_context, + iterator_context: AddressRoute.iterator_context, + ci_compare_context: AddressRoute.ci_compare_context, + } +)); +"""}) + ], + 'builtins': [ + 'render_static.templatetags.render_static' + ] + }, + }], + 'templates': [ + ('enum_app/test.js', { + 'context': { + 'enums': [ + EnumTester.AddressRoute + ] + } + }), + ] + } + ) + def test_overrides(self): + call_command('renderstatic', 'enum_app/test.js') + js_dict = self.get_js_structure(GLOBAL_STATIC_DIR / 'enum_app/test.js') + + expected_context = { + 'enum': "", + 'class_name': "AddressRoute", + 'properties': ['value', 'name', 'label', 'alt', 'str'], + 'class_properties': ['class_name'], + 'symmetric_properties': ['name', 'label'], + 'to_string': True, + 'str_prop': 'str0' + } + self.assertEqual(js_dict['testContext'], expected_context) + self.assertEqual(js_dict['getNull'], expected_context) + self.assertEqual(js_dict['getAve'], 'Avenue') + self.assertEqual(js_dict['getAve2'], 'Avenue') + self.assertEqual(js_dict['toString'], expected_context) + self.assertEqual(js_dict['constructor_context'], expected_context) + self.assertEqual(js_dict['iterator_context'], expected_context) + self.assertEqual(js_dict['ci_compare_context'], expected_context) + + + @override_settings( + STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test.js': """ +{% enums_to_js enums="render_static.tests.enum_app.models.EnumTester.MapBoxStyle" %} +{% enums_to_js enums="render_static.tests.enum_app.models.EnumTester.AddressRoute" symmetric_properties=True %} + +{% override "testContext" %} +static testContext() { + return { + 'enum': {% autoescape off %}"{{enum}}"{% endautoescape %}, + 'class_name': "{{class_name}}", + 'properties': [{% for prop in properties %}"{{prop}}",{% endfor %}], + 'class_properties': [{% for prop in class_properties %}"{{prop}}",{% endfor %}], + 'symmetric_properties': [{% for prop in symmetric_properties %}"{{prop}}",{% endfor %}], + 'to_string': {% if to_string %}true{% else %}false{% endif %}, + 'str_prop': "{{ str_prop }}" + }; +} +{% endoverride %} +{% endenums_to_js %} +{% transpile "render_static.tests.enum_app.models.EnumTester.Color" "render_static.EnumClassWriter" %} +console.log(JSON.stringify( + { + testContext: AddressRoute.testContext(), + mapbox: MapBoxStyle.get(1).label, + red: Color.RED.hex, + } +)); +"""}) + ], + 'builtins': [ + 'render_static.templatetags.render_static' + ] + }, + }], + } + ) + def test_multi_block(self): + call_command('renderstatic', 'enum_app/test.js') + js_dict = self.get_js_structure(GLOBAL_STATIC_DIR / 'enum_app/test.js') + expected_context = { + 'enum': "", + 'class_name': "AddressRoute", + 'properties': ['value', 'name', 'label', 'alt', 'str'], + 'class_properties': ['class_name'], + 'symmetric_properties': ['name', 'label'], + 'to_string': True, + 'str_prop': 'str0' + } + self.assertEqual(js_dict['testContext'], expected_context) + self.assertEqual(js_dict['mapbox'], 'Streets') + self.assertEqual(js_dict['red'], 'ff0000') + + + @override_settings( + STATIC_TEMPLATES={ + 'ENGINES': [{ + 'BACKEND': 'render_static.backends.StaticDjangoTemplates', + 'OPTIONS': { + 'loaders': [ + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test.js': + "{% enums_to_js enums='render_static.tests.enum_app.models.EnumTester.Nope' %}" + }), + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test2.js': + "{% enums_to_js enums='does_not_exist' %}" + }), + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test3.js': + "{% enums_to_js enums='render_static.does_not_exist' %}" + }), + ('render_static.loaders.StaticLocMemLoader', { + 'enum_app/test4.js': + "{% enums_to_js enums='render_static.tests.enum_app.models.DNE' %}" + }) + ], + 'builtins': [ + 'render_static.templatetags.render_static' + ] + }, + }], + } + ) + def test_import_error(self): + with self.assertRaises(CommandError): + call_command('renderstatic', 'enum_app/test.js') + with self.assertRaises(CommandError): + call_command('renderstatic', 'enum_app/test2.js') + with self.assertRaises(CommandError): + call_command('renderstatic', 'enum_app/test3.js') + with self.assertRaises(CommandError): + call_command('renderstatic', 'enum_app/test4.js') @override_settings( diff --git a/render_static/tests/tests.py b/render_static/tests/tests.py index b547532..3009997 100755 --- a/render_static/tests/tests.py +++ b/render_static/tests/tests.py @@ -1346,3 +1346,90 @@ def test_mixed_list(self): # def tearDown(self): # pass + + +class TranspilerTagTestCase(BaseTestCase): + + def test_invalid_args(self): + from render_static.templatetags.render_static import transpiler_tag + with self.assertRaises(ValueError): + @transpiler_tag(func=True) + def transpiler1(): # pragma: no cover + pass # pragma: no cover + + +BATCH_RENDER_TEMPLATES = [ + ( + 'batch_test/**/*', + { + 'context': { + 'site_name': 'my_site', + 'variable1': 'var1 value', + 'variable2': 2, + 'sub_dir': 'resources' + }, + 'dest': GLOBAL_STATIC_DIR + } + ), + ( + 'batch_test/{{ site_name }}', + { + 'context': {'site_name': 'my_site'}, + 'dest': GLOBAL_STATIC_DIR / 'batch_test' / '{{ site_name }}' + } + ), + ( + 'batch_test/{{ site_name }}/{{ sub_dir }}', + { + 'context': {'site_name': 'my_site', 'sub_dir': 'resources'}, + 'dest': GLOBAL_STATIC_DIR / 'batch_test' / '{{ site_name }}' / '{{ sub_dir }}' + } + ) +] +@override_settings(STATIC_TEMPLATES={ + 'templates': BATCH_RENDER_TEMPLATES +}) +class BatchRenderTestCase(BaseTestCase): + """ + Tests that batches of files can be rendered to paths that + are also templates. + """ + def test_render_empty_dir_template(self): + call_command('renderstatic', 'batch_test/{{ site_name }}') + batch_test = GLOBAL_STATIC_DIR / 'batch_test' + my_site = batch_test / 'my_site' + self.assertTrue(batch_test.is_dir()) + self.assertTrue(my_site.is_dir()) + + def test_render_empty_dir_template_multi_level(self): + call_command('renderstatic', 'batch_test/{{ site_name }}/{{ sub_dir }}') + batch_test = GLOBAL_STATIC_DIR / 'batch_test' + my_site = batch_test / 'my_site' + resources = my_site / 'resources' + self.assertTrue(batch_test.is_dir()) + self.assertTrue(my_site.is_dir()) + self.assertTrue(resources.is_dir()) + + def test_batch_render_path_templates(self): + call_command('renderstatic', 'batch_test/**/*') + batch_test = GLOBAL_STATIC_DIR / 'batch_test' + my_site = batch_test / 'my_site' + resources = my_site / 'resources' + + self.assertTrue(batch_test.is_dir()) + self.assertTrue((batch_test / '__init__.py').is_file()) + self.assertTrue(my_site.is_dir()) + self.assertTrue((my_site / '__init__.py').is_file()) + self.assertTrue(resources.is_dir()) + + file1 = my_site / 'file1.py' + file2 = my_site / 'file2.html' + + self.assertTrue(file1.is_file()) + self.assertTrue(file2.is_file()) + + self.assertEqual(file1.read_text().strip(), 'var1 value') + self.assertEqual(file2.read_text().strip(), '2') + + # def tearDown(self): + # pass diff --git a/render_static/transpilers/__init__.py b/render_static/transpilers/__init__.py index e6f016d..201a2a2 100644 --- a/render_static/transpilers/__init__.py +++ b/render_static/transpilers/__init__.py @@ -10,9 +10,11 @@ from enum import Enum from types import ModuleType from typing import ( + TYPE_CHECKING, Any, Callable, Collection, + Dict, Generator, List, Optional, @@ -25,6 +27,13 @@ from django.apps import apps from django.apps.config import AppConfig from django.utils.module_loading import import_module, import_string +from django.utils.safestring import SafeString + +if TYPE_CHECKING: + from render_static.templatetags.render_static import ( + OverrideNode, # pragma: no cover + ) + __all__ = [ 'to_js', @@ -126,7 +135,7 @@ class CodeWriter: rendered_: str level_: int = 0 prefix_: str = '' - indent_: str = '\t' + indent_: str = ' '*4 nl_: str = '\n' def __init__( @@ -142,6 +151,12 @@ def __init__( self.prefix_ = prefix or '' self.nl_ = self.nl_ if self.indent_ else '' # pylint: disable=C0103 + def get_line(self, line: Optional[str]) -> str: + """ + Returns a line with indentation and newline. + """ + return f'{self.prefix_}{self.indent_ * self.level_}{line}{self.nl_}' + def write_line(self, line: Optional[str]) -> None: """ Writes a line to the rendered code file, respecting @@ -151,8 +166,7 @@ def write_line(self, line: Optional[str]) -> None: :return: """ if line is not None: - self.rendered_ += f'{self.prefix_}{self.indent_ * self.level_}' \ - f'{line}{self.nl_}' + self.rendered_ += self.get_line(line) def indent(self, incr: int = 1) -> None: """ @@ -195,6 +209,8 @@ class Transpiler(CodeWriter, metaclass=ABCMeta): parents_: List[Union[ModuleType, Type[Any]]] target_: ResolvedTranspilerTarget + overrides_: Dict[str, 'OverrideNode'] + @property def target(self): """The python artifact that is the target to transpile.""" @@ -212,9 +228,21 @@ def parents(self): if parent is not self.target ] + @property + def context(self): + """ + The base template render context passed to overrides. Includes: + + - **transpiler**: The transpiler instance + """ + return { + 'transpiler': self + } + def __init__( self, to_javascript: Union[str, Callable] = to_javascript_, + overrides: Optional[Dict[str, 'OverrideNode']] = None, **kwargs ) -> None: super().__init__(**kwargs) @@ -223,9 +251,44 @@ def __init__( if callable(to_javascript) else import_string(to_javascript) ) + self.overrides_ = overrides or {} self.parents_ = [] assert callable(self.to_javascript), 'To_javascript is not callable!' + def transpile_override( + self, + override: str, + default_impl: Union[str, Generator[Optional[str], None, None]], + context: Optional[Dict[str, Any]] = None + ) -> Generator[str, None, None]: + """ + Returns a string of lines from a generator with the indentation and + newlines added. This is meant to be used in place during overrides, + so the first newline has no indents. So it will have the line prefix + of {{ default_impl }}. + + :param override: The name of the override to transpile + :param default_impl: The default implementation to use if the override + is not present. May be a single line string or a generator of + lines. + :param context: Any additional context to pass to the override render + """ + d_impl = default_impl + if isinstance(default_impl, Generator): + d_impl = '' + for idx, line in enumerate(default_impl): + if idx == 0: + d_impl += f'{line}{self.nl_}' + else: + d_impl += self.get_line(line) + d_impl.rstrip(self.nl_) + + yield from self.overrides_.pop(override).transpile({ + **self.context, + 'default_impl': SafeString(d_impl), + **(context or {}) + }) + @abstractmethod def include_target(self, target: TranspilerTarget): """ @@ -238,7 +301,7 @@ def include_target(self, target: TranspilerTarget): """ return True - def transpile( # pylint: disable=too-many-branches + def transpile( # pylint: disable=too-many-branches, disable=too-many-statements self, targets: TranspilerTargets ) -> str: @@ -288,12 +351,31 @@ def walk_class(cls: _TargetTreeNode): try: target = apps.get_app_config(target) except LookupError: - try: - target = import_string(target) - except ImportError: - # this is needed when there is no __init__ file in - # the same directory - target = import_module(target) + parts = target.split('.') + tries = 0 + while True: + try: + tries += 1 + target = import_string( + '.'.join( + parts[0: + None if tries == 1 + else -(tries-1) + ]) + ) + if tries > 1: + for attr in parts[-(tries-1):]: + target = getattr(target, attr) + break + except (ImportError, AttributeError): + if tries == 1: + try: + target = import_module('.'.join(parts)) + except (ImportError, ModuleNotFoundError): + if len(parts) == 1: + raise + elif tries == len(parts): + raise node = _TargetTreeNode(target, self.include_target(target)) diff --git a/render_static/transpilers/defines_to_js.py b/render_static/transpilers/defines_to_js.py index b5ef058..d314b41 100644 --- a/render_static/transpilers/defines_to_js.py +++ b/render_static/transpilers/defines_to_js.py @@ -81,6 +81,8 @@ class MyModel(models.Model): members_: Dict[str, Any] + object_path_: str = '' + @property def members(self) -> Dict[str, Any]: """ @@ -107,6 +109,20 @@ def include_target(self, target: ResolvedTranspilerTarget): return len(self.members) > 0 return False + @property + def context(self) -> Dict[str, Any]: + """ + The template render context passed to overrides. In addition to + :attr:`render_static.transpilers.Transpiler.context`. + This includes: + + - **const_name**: The name of the const variable + """ + return { + **Transpiler.context.fget(self), # type: ignore + 'const_name': self.const_name_ + } + def __init__( self, include_member: Callable[[Any], bool] = include_member_, @@ -150,6 +166,8 @@ def end_visitation(self) -> Generator[Optional[str], None, None]: """ Lay down the closing brace for the const variable declaration. """ + for _, override in self.overrides_.items(): + yield from override.transpile(self.context) self.outdent() yield '};' @@ -196,6 +214,9 @@ def enter_class( self.members = cls # type: ignore yield f'{cls.__name__}: {{' self.indent() + self.object_path_ += ( + f'{"." if self.object_path_ else ""}{cls.__name__}' + ) def exit_class( self, @@ -214,6 +235,7 @@ def exit_class( """ self.outdent() yield '},' + self.object_path_ = '.'.join(self.object_path_.split('.')[:-1]) def visit_member( self, @@ -232,4 +254,18 @@ def visit_member( at all :yield: Transpiled javascript for the member. """ - yield f'{name}: {self.to_js(member)},' + self.object_path_ += f'{"." if self.object_path_ else ""}{name}' + if self.object_path_ in self.overrides_: + first = True + for line in self.transpile_override( + self.object_path_, + self.to_js(member) + ): + if first: + yield f'{name}: {line}' + first = False + else: + yield line + else: + yield f'{name}: {self.to_js(member)},' + self.object_path_ = '.'.join(self.object_path_.split('.')[:-1]) diff --git a/render_static/transpilers/enums_to_js.py b/render_static/transpilers/enums_to_js.py index a06ae62..5c66f39 100644 --- a/render_static/transpilers/enums_to_js.py +++ b/render_static/transpilers/enums_to_js.py @@ -2,8 +2,9 @@ Transpiler tools for PEP 435 style python enumeration classes. """ import sys +import warnings from abc import abstractmethod -from enum import Enum, Flag, IntEnum, IntFlag +from enum import Enum, Flag, IntEnum, IntFlag, auto from typing import ( Any, Collection, @@ -31,6 +32,20 @@ IGNORED_ENUMS.update({FlagBoundary, ReprEnum, StrEnum, EnumCheck}) +class UnrecognizedBehavior(Enum): + """ + Enumeration of behaviors when a value cannot be mapped to an enum instance: + + * THROW_EXCEPTION - throw a TypeError + * RETURN_NULL - return null + * RETURN_INPUT - return the input value + """ + + THROW_EXCEPTION = auto() + RETURN_NULL = auto() + RETURN_INPUT = auto() + + class EnumTranspiler(Transpiler): """ The base javascript transpiler for python PEP 435 Enums. Extend from this @@ -83,12 +98,15 @@ class EnumClassWriter(EnumTranspiler): # pylint: disable=R0902 :param class_name: A pattern to use to generate class names. This should be a string that will be formatted with the class name of each enum. The default string '{}' will resolve to the python class name. - :param raise_on_not_found: If true, in the transpiled javascript throw a - TypeError if an Enum instance cannot be mapped to the given value. - If false, return null + :param on_unrecognized: If the given value cannot be mapped to an + enum instance, either "THROW_EXCEPTION", "RETURN_NULL", or + "RETURN_INPUT". See + :py:class:`render_static.transpilers.enums_to_js.UnrecognizedBehavior`. :param export: If true the classes will be exported - Default: False :param include_properties: If true, any python properties present on the - enums will be included in the transpiled javascript enums. + enums will be included in the transpiled javascript enums. May also + be an iterable of property names to include. `value` will always + be included. :param symmetric_properties: If true, properties that the enums may be instantiated from will be automatically determined and included in the get() function. If False (default), enums will not be instantiable @@ -99,30 +117,42 @@ class EnumClassWriter(EnumTranspiler): # pylint: disable=R0902 :param class_properties: If true, include all Django classproperties as static members on the transpiled Enum class. May also be an iterable of specific property names to include. + :param to_string: If true (default) include a toString() method that + returns a string representation of the enum. + :param isymmetric_properties: If provided, case insensitive symmetric + properties will be limited to those listed. If not provided, case + insensitive properties will be dynamically determined. Provide + an empty list to disable case insensitive properties. :param kwargs: additional kwargs for the base transpiler classes. """ + enum_: Type[Enum] + class_name_pattern_: str = '{}' class_name_: str class_name_map_: Dict[Type[Enum], str] = {} - raise_on_not_found_: bool = True + on_unrecognized_: UnrecognizedBehavior = \ + UnrecognizedBehavior.THROW_EXCEPTION export_: bool = False symmetric_properties_kwarg_: Union[bool, Collection[str]] = False symmetric_properties_: List[str] = [] + find_ci_: bool = True + isymmetric_properties_: List[str] = [] class_properties_kwarg_: Union[bool, Collection[str]] = True class_properties_: List[str] = [] - include_properties_: bool = True + include_properties_: Union[Collection[str], bool] = True builtins_: List[str] = ['value', 'name'] properties_: List[str] = [] exclude_properties_: Set[str] str_prop_: Optional[str] = None str_is_prop_: bool = False + to_string_: bool = True def to_js(self, value: Any): """ @@ -172,7 +202,7 @@ def properties(self, enum: Type[Enum]): bltin for bltin in self.builtins_ if bltin not in self.exclude_properties_ ] - if self.include_properties_: + if self.include_properties_ is True: if ( hasattr(list(enum)[0], 'label') and 'label' not in builtins and @@ -198,6 +228,8 @@ def properties(self, enum: Type[Enum]): and prop not in builtins and prop not in props_on_class ] ] + elif self.include_properties_: + self.properties_ = list({*self.include_properties_, 'value'}) else: self.properties_ = builtins @@ -220,19 +252,35 @@ def symmetric_properties(self, enum: Type[Enum]): """ if self.symmetric_properties_kwarg_ is True: self.symmetric_properties_ = [] + self.isymmetric_properties_ = ( + [] if self.find_ci_ else + self.isymmetric_properties_ + ) + def ever_other_case(test_str: str) -> str: + return ''.join( + c.upper() if i % 2 == 0 else c.lower() + for i, c in enumerate(test_str) + ) for prop in self.properties: if prop == 'value': continue count = 0 + i_count = 0 for enm in enum: try: - if enum(getattr(enm, prop)) is enm: - count += 1 + e_prop = getattr(enm, prop) + count += int(enum(e_prop) is enm) + i_count += int( + self.find_ci_ and isinstance(e_prop, str) + and enum(e_prop.swapcase()) is enm + and enum(ever_other_case(e_prop)) is enm + ) except (TypeError, ValueError): pass if count == len(enum): self.symmetric_properties_.append(prop) - + if self.find_ci_ and i_count == count: + self.isymmetric_properties_.append(prop) elif self.symmetric_properties_kwarg_ is False: self.symmetric_properties_ = [] else: @@ -307,28 +355,106 @@ def str_prop(self, enum: Type[Enum]): idx += 1 self.str_prop_ = candidate + @property + def enum(self): + """The enum class being transpiled""" + return self.enum_ + + @enum.setter + def enum(self, enum: Type[Enum]): + """ + Set the enum class being transpiled + + :param enum: The enum class being transpiled + """ + self.enum_ = enum + self.class_name = enum + self.properties = enum + self.str_prop = enum + self.class_properties = enum + self.symmetric_properties = enum + + @property + def context(self): + """ + The template render context passed to overrides. In addition to + :attr:`render_static.transpilers.Transpiler.context`. + This includes: + + - **enum**: The enum class being transpiled + - **class_name**: The name of the transpiled class + - **properties**: A list of property names of the enum to transpile + - **str_prop**: The name of the string property of the enum + - **class_properties**: A list of the class property names of + the enum to transpile + - **symmetric_properties**: The list of property names that the + enum can be instantiated from + - **to_string**: Boolean, True if the enum should have a + toString() method + """ + return { + **EnumTranspiler.context.fget(self), # type: ignore + 'enum': self.enum, + 'class_name': self.class_name, + 'properties': self.properties, + 'str_prop': self.str_prop, + 'class_properties': self.class_properties, + 'symmetric_properties': self.symmetric_properties, + 'to_string': self.to_string_ + } + def __init__( # pylint: disable=R0913 - self, - class_name: str = class_name_pattern_, - raise_on_not_found: bool = raise_on_not_found_, - export: bool = export_, - include_properties: bool = include_properties_, - symmetric_properties: Union[ - bool, - Collection[str] - ] = symmetric_properties_kwarg_, - exclude_properties: Optional[Collection[str]] = None, - class_properties: Union[ - bool, - Collection[str] - ] = class_properties_kwarg_, - **kwargs + self, + class_name: str = class_name_pattern_, + on_unrecognized: Union[ + str, + UnrecognizedBehavior + ] = on_unrecognized_, + export: bool = export_, + include_properties: Union[ + bool, + Collection[str] + ] = include_properties_, + symmetric_properties: Union[ + bool, + Collection[str] + ] = symmetric_properties_kwarg_, + exclude_properties: Optional[Collection[str]] = None, + class_properties: Union[ + bool, + Collection[str] + ] = class_properties_kwarg_, + to_string: bool = to_string_, + isymmetric_properties: Optional[Union[Collection[str], bool]] = None, + **kwargs ) -> None: super().__init__(**kwargs) self.class_name_pattern_ = class_name - self.raise_on_not_found_ = raise_on_not_found + raise_on_not_found = kwargs.pop('raise_on_not_found', None) + self.on_unrecognized_ = ( + UnrecognizedBehavior[on_unrecognized] + if isinstance(on_unrecognized, str) else + on_unrecognized + ) + if raise_on_not_found is not None: + warnings.warn( + 'raise_on_not_found is deprecated, use on_unrecognized ' + 'instead.', + DeprecationWarning, + stacklevel=2 + ) + self.on_unrecognized_ = ( + UnrecognizedBehavior.THROW_EXCEPTION + if raise_on_not_found else + UnrecognizedBehavior.RETURN_NULL + ) self.export_ = export self.include_properties_ = include_properties + self.include_properties_ = ( + set(include_properties) + if isinstance(include_properties, Collection) else + include_properties + ) self.symmetric_properties_kwarg_ = symmetric_properties self.exclude_properties_ = ( set(exclude_properties) @@ -336,6 +462,12 @@ def __init__( # pylint: disable=R0913 ) self.class_properties_kwarg_ = class_properties self.class_name_map_ = {} + self.to_string_ = to_string + self.find_ci_ = isymmetric_properties in [True, None] + self.isymmetric_properties_ = ( + list(isymmetric_properties or []) # type: ignore + if isymmetric_properties not in [True, None] else [] + ) def visit( self, @@ -353,11 +485,7 @@ def visit( :param is_final: True if this is the last enum to be transpiled at all. :yield: transpiled javascript lines """ - self.class_name = enum - self.properties = enum - self.str_prop = enum - self.class_properties = enum - self.symmetric_properties = enum + self.enum = enum yield from self.declaration(enum) self.indent() yield '' @@ -368,11 +496,20 @@ def visit( yield '' yield from self.constructor(enum) yield '' - yield from self.to_string(enum) - yield '' - yield from self.getter(enum) + if self.isymmetric_properties_: + yield from self.ci_compare() + yield '' + if self.to_string_: + yield from self.to_string(enum) + yield '' + if 'get' in self.overrides_: + yield from self.transpile_override('get', self.getter_impl()) + else: + yield from self.getter() yield '' yield from self.iterator(enum) + for _, override in self.overrides_.items(): + yield from override.transpile(self.context) self.outdent() yield '}' @@ -402,7 +539,7 @@ def enumerations( values = [ self.to_js(getattr(enm, prop)) for prop in self.properties ] - if not self.str_is_prop_: + if not self.str_is_prop_ and self.to_string_: values.append(self.to_js(str(enm))) yield ( f'static {enm.name} = new {self.class_name}' @@ -434,16 +571,46 @@ def constructor( # pylint: disable=W0613 """ props = [ *self.properties, - *([] if self.str_is_prop_ else [self.str_prop]) + *( + [] if self.str_is_prop_ or not self.to_string_ + else [self.str_prop] + ) ] - yield f'constructor ({", ".join(props)}) {{' - self.indent() - for prop in self.properties: - yield f'this.{prop} = {prop};' - if not self.str_is_prop_: - yield f'this.{self.str_prop} = {self.str_prop};' - self.outdent() - yield '}' + def constructor_impl() -> Generator[str, None, None]: + for prop in self.properties: + yield f'this.{prop} = {prop};' + if not self.str_is_prop_ and self.to_string_: + yield f'this.{self.str_prop} = {self.str_prop};' + + if 'constructor' in self.overrides_: + yield from self.transpile_override( + 'constructor', + constructor_impl() + ) + else: + yield f'constructor ({", ".join(props)}) {{' + self.indent() + yield from constructor_impl() + self.outdent() + yield '}' + + def ci_compare(self) -> Generator[Optional[str], None, None]: + """ + Transpile a case-insensitive string comparison function. + """ + impl = ( + "return typeof a === 'string' && typeof b === 'string' ? " + "a.localeCompare(b, undefined, { sensitivity: 'accent' }) " + "=== 0 : a === b;" + ) + if 'ciCompare' in self.overrides_: + yield from self.transpile_override('ciCompare', impl) + else: + yield 'static ciCompare(a, b) {' + self.indent() + yield impl + self.outdent() + yield '}' def to_string( # pylint: disable=W0613 self, @@ -456,13 +623,17 @@ def to_string( # pylint: disable=W0613 :param enum: The enum class being transpiled :yield: transpiled javascript lines """ - yield 'toString() {' - self.indent() - yield f'return this.{self.str_prop};' - self.outdent() - yield '}' + impl = f'return this.{self.str_prop};' + if 'toString' in self.overrides_: + yield from self.transpile_override('toString', impl) + else: + yield 'toString() {' + self.indent() + yield impl + self.outdent() + yield '}' - def getter(self, enum: Type[Enum]) -> Generator[Optional[str], None, None]: + def getter(self) -> Generator[Optional[str], None, None]: """ Transpile the get() method that converts values and properties into instances of the Enum type. @@ -472,23 +643,34 @@ def getter(self, enum: Type[Enum]) -> Generator[Optional[str], None, None]: """ yield 'static get(value) {' self.indent() + yield from self.getter_impl() + self.outdent() + yield '}' + + def getter_impl(self) -> Generator[Optional[str], None, None]: + """ + Transpile the default implementation of get() that converts values and + properties into instances of the Enum type. + """ + yield 'if (value instanceof this) {' + self.indent() + yield 'return value;' + self.outdent() + yield '}' + yield '' for prop in ['value'] + self.symmetric_properties: - yield from self.prop_getter(enum, prop) + yield from self.prop_getter(prop) - if self.raise_on_not_found_: + if self.on_unrecognized_ is UnrecognizedBehavior.RETURN_INPUT: + yield 'return value;' + elif self.on_unrecognized_ is UnrecognizedBehavior.RETURN_NULL: + yield 'return null;' + else: yield f'throw new TypeError(`No {self.class_name} ' \ f'enumeration maps to value ${{value}}`);' - else: - yield 'return null;' - self.outdent() - yield '}' - def prop_getter( - self, - enum: Type[Enum], - prop: str - ) -> Generator[Optional[str], None, None]: + def prop_getter(self, prop: str) -> Generator[Optional[str], None, None]: """ Transpile the switch statement to map values of the given property to enumeration instance values. @@ -497,13 +679,16 @@ def prop_getter( :param prop: :yield: transpiled javascript lines """ - yield 'switch(value) {' + yield 'for (const en of this) {' self.indent() - for enm in enum: - yield f'case {self.to_js(getattr(enm, prop))}:' - self.indent() - yield f'return {self.class_name}.{enm.name};' - self.outdent() + if prop in (self.isymmetric_properties_ or []): + yield f'if (this.ciCompare(en.{prop}, value)) {{' + else: + yield f'if (en.{prop} === value) {{' + self.indent() + yield 'return en;' + self.outdent() + yield '}' self.outdent() yield '}' @@ -518,8 +703,12 @@ def iterator( :yield: transpiled javascript lines """ enums = [f'{self.class_name}.{enm.name}' for enm in enum] - yield 'static [Symbol.iterator]() {' - self.indent() - yield f'return [{", ".join(enums)}][Symbol.iterator]();' - self.outdent() - yield '}' + impl = f'return [{", ".join(enums)}][Symbol.iterator]();' + if '[Symbol.iterator]' in self.overrides_: + yield from self.transpile_override('[Symbol.iterator]', impl) + else: + yield 'static [Symbol.iterator]() {' + self.indent() + yield impl + self.outdent() + yield '}' diff --git a/render_static/transpilers/urls_to_js.py b/render_static/transpilers/urls_to_js.py index 2d96de4..2035e73 100644 --- a/render_static/transpilers/urls_to_js.py +++ b/render_static/transpilers/urls_to_js.py @@ -345,6 +345,22 @@ class URLTreeVisitor(BaseURLTranspiler): include_: Optional[Iterable[str]] = None exclude_: Optional[Iterable[str]] = None + @property + def context(self): + """ + The template render context passed to overrides. In addition to + :attr:`render_static.transpilers.Transpiler.context`. + This includes: + + - **include**: The list of include pattern strings + - **exclude**: The list of exclude pattern strings + """ + return { + **BaseURLTranspiler.context.fget(self), # type: ignore + 'include': self.include_, + 'exclude': self.exclude_, + } + def __init__( self, include: Optional[Iterable[str]] = include_, @@ -566,7 +582,8 @@ def get_params(pattern: Union[RoutePattern, RegexPattern]) -> Dict[ path.append(placeholder_url[url_idx:]) yield from self.visit_path( - path, list(kwargs.keys()), + path, + list(kwargs.keys()), endpoint.default_args if num_patterns > 1 else None ) @@ -694,10 +711,32 @@ def visit_path_group( arg inputs """ yield from self.enter_path_group(qname) - for pattern in reversed(nodes): - yield from self.visit_pattern( - pattern, qname, app_name, route or [], num_patterns=len(nodes) + + def impl() -> Generator[Optional[str], None, None]: + for pattern in reversed(nodes): + yield from self.visit_pattern( + pattern, + qname, + app_name, + route or [], + num_patterns=len(nodes) + ) + + if qname in self.overrides_: + yield from self.transpile_override( + qname, + impl(), + { + 'qname': qname, + 'app_name': app_name, + 'route': route, + 'patterns': nodes, + 'num_patterns': len(nodes) + } ) + else: + yield from impl() + yield from self.exit_path_group(qname) def visit_branch( @@ -805,7 +844,7 @@ class SimpleURLWriter(URLTreeVisitor): .. code-block:: js+django - var urls = { + const urls = { {% urls_to_js raise_on_not_found=False %} }; @@ -815,10 +854,8 @@ class SimpleURLWriter(URLTreeVisitor): urls.namespace.path_name({'arg1': 1, 'arg2': 'a'}); - The classes generated by this visitor, both ES5 and ES6 minimize - significantly worse than the `ClassURLWriter`. - - The configuration parameters that control the JavaScript output include: + In addition to the base parameters the configuration parameters that + control the JavaScript output include: * *raise_on_not_found* Raise a TypeError if no reversal for a url pattern is found, @@ -830,6 +867,21 @@ class SimpleURLWriter(URLTreeVisitor): raise_on_not_found_ = True + @property + def context(self): + """ + The template render context passed to overrides. In addition to + :attr:`render_static.transpilers.urls_to_js.URLTreeVisitor.context`. + This includes: + + - **raise_on_not_found**: Boolean, True if an exception should be + raised when no reversal is found, default: True + """ + return { + **URLTreeVisitor.context.fget(self), # type: ignore + 'raise_on_not_found': self.raise_on_not_found_, + } + def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.raise_on_not_found_ = kwargs.pop( @@ -851,7 +903,8 @@ def close_visit(self) -> Generator[Optional[str], None, None]: :yield: nothing """ - yield None + for _, override in self.overrides_.items(): + yield from override.transpile(self.context) def enter_namespace( self, @@ -985,10 +1038,8 @@ class ClassURLWriter(URLTreeVisitor): const urls = new URLResolver(); urls.reverse('namespace:path_name', {'arg1': 1, 'arg2': 'a'}); - The classes generated by this visitor, both ES5 and ES6 minimize - significantly better than the default `SimpleURLWriter`. - - The configuration parameters that control the JavaScript output include: + In addition to the base parameters the configuration parameters that + control the JavaScript output include: * *class_name* The name of the JavaScript class to use: default: URLResolver @@ -1008,6 +1059,23 @@ class ClassURLWriter(URLTreeVisitor): raise_on_not_found_ = True export_ = False + @property + def context(self): + """ + The template render context passed to overrides. In addition to + :attr:`render_static.transpilers.urls_to_js.URLTreeVisitor.context`. + This includes: + + - **class_name**: The name of the JavaScript class + - **raise_on_not_found**: Boolean, True if an exception should be + raised when no reversal is found, default: True + """ + return { + **URLTreeVisitor.context.fget(self), # type: ignore + 'class_name': self.class_name_, + 'raise_on_not_found': self.raise_on_not_found_, + } + def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.class_name_ = kwargs.pop('class_name', self.class_name_) @@ -1098,70 +1166,257 @@ def reverse_jdoc(self) -> Generator[Optional[str], None, None]: */""".split('\n'): yield comment_line[8:] + def constructor(self) -> Generator[Optional[str], None, None]: + """ + The constructor() function. + :yield: The JavaScript jdoc comment lines and constructor() function. + """ + def impl() -> Generator[str, None, None]: + """constructor default implementation""" + yield 'this.options = options || {};' + yield 'if (this.options.hasOwnProperty("namespace")) {' + self.indent() + yield 'this.namespace = this.options.namespace;' + yield 'if (!this.namespace.endsWith(":")) {' + self.indent() + yield 'this.namespace += ":";' + self.outdent() + yield '}' + self.outdent() + yield '} else {' + self.indent() + yield 'this.namespace = "";' + self.outdent() + yield '}' + + if 'constructor' in self.overrides_: + yield from self.transpile_override('constructor', impl()) + else: + yield from self.constructor_jdoc() + yield 'constructor(options=null) {' + self.indent() + yield from impl() + self.outdent() + yield '}' def deep_equal(self) -> Generator[Optional[str], None, None]: """ The recursive deepEqual function. :yield: The JavaScript jdoc comment lines and deepEqual function. """ - for comment_line in """ - /** - * Given two values, do a deep equality comparison. If the values are - * objects, all keys and values are recursively compared. - * - * @param {Object} object1 - The first object to compare. - * @param {Object} object2 - The second object to compare. - */""".split('\n'): - yield comment_line[8:] - yield 'deepEqual(object1, object2) {' - self.indent() - yield 'if (!(this.isObject(object1) && this.isObject(object2))) {' - self.indent() - yield 'return object1 === object2;' - self.outdent() - yield '}' - yield 'const keys1 = Object.keys(object1);' - yield 'const keys2 = Object.keys(object2);' - yield 'if (keys1.length !== keys2.length) {' - self.indent() - yield 'return false;' - self.outdent() - yield '}' - yield 'for (let key of keys1) {' - self.indent() - yield 'const val1 = object1[key];' - yield 'const val2 = object2[key];' - yield 'const areObjects = this.isObject(val1) && this.isObject(val2);' - yield 'if (' - self.indent() - yield '(areObjects && !deepEqual(val1, val2)) ||' - yield '(!areObjects && val1 !== val2)' - yield ') { return false; }' - self.outdent() - yield '}' - self.outdent() - yield 'return true;' - self.outdent() - yield '}' + + def impl() -> Generator[str, None, None]: + """deepEqual default implementation""" + yield 'if (!(this.isObject(object1) && this.isObject(object2))) {' + self.indent() + yield 'return object1 === object2;' + self.outdent() + yield '}' + yield 'const keys1 = Object.keys(object1);' + yield 'const keys2 = Object.keys(object2);' + yield 'if (keys1.length !== keys2.length) {' + self.indent() + yield 'return false;' + self.outdent() + yield '}' + yield 'for (let key of keys1) {' + self.indent() + yield 'const val1 = object1[key];' + yield 'const val2 = object2[key];' + yield ( + 'const areObjects = this.isObject(val1) && ' + 'this.isObject(val2);' + ) + yield 'if (' + self.indent() + yield '(areObjects && !deepEqual(val1, val2)) ||' + yield '(!areObjects && val1 !== val2)' + yield ') { return false; }' + self.outdent() + yield '}' + self.outdent() + yield 'return true;' + + if 'deepEqual' in self.overrides_: + yield from self.transpile_override('deepEqual', impl()) + else: + for comment_line in """ + /** + * Given two values, do a deep equality comparison. If the values are + * objects, all keys and values are recursively compared. + * + * @param {Object} object1 - The first object to compare. + * @param {Object} object2 - The second object to compare. + */""".split('\n'): + yield comment_line[12:] + yield 'deepEqual(object1, object2) {' + self.indent() + yield from impl() + self.outdent() + yield '}' def is_object(self) -> Generator[Optional[str], None, None]: """ The isObject() function. :yield: The JavaScript jdoc comment lines and isObject function. """ - for comment_line in """ - /** - * Given a variable, return true if it is an object. - * - * @param {Object} object - The variable to check. - */""".split('\n'): - yield comment_line[8:] - yield 'isObject(object) {' - self.indent() - yield 'return object != null && typeof object === "object";' - self.outdent() - yield '}' + impl = 'return object != null && typeof object === "object";' + if 'isObject' in self.overrides_: + yield from self.transpile_override('isObject', impl) + else: + for comment_line in """ + /** + * Given a variable, return true if it is an object. + * + * @param {Object} object - The variable to check. + */""".split('\n'): + yield comment_line[12:] + yield 'isObject(object) {' + self.indent() + yield impl + self.outdent() + yield '}' + def match(self) -> Generator[Optional[str], None, None]: + """ + The #match() function. + :yield: The JavaScript jdoc comment lines and #match() function. + """ + def impl() -> Generator[str, None, None]: + """match default implementation""" + yield 'if (defaults) {' + self.indent() + yield 'kwargs = Object.assign({}, kwargs);' + yield 'for (const [key, val] of Object.entries(defaults)) {' + self.indent() + yield 'if (kwargs.hasOwnProperty(key)) {' + self.indent() + if ( + DJANGO_VERSION[0] >= 4 and DJANGO_VERSION[1] >= 1 + ): # pragma: no cover + # there was a change in Django 4.1 that seems to coerce kwargs + # given to the default kwarg type of the same name if one + # exists for the purposes of reversal. Thus 1 will == '1' + # In javascript we attempt string conversion and hope for the + # best. In 4.1 given kwargs will also override default kwargs + # for kwargs the reversal is expecting. This seems to have + # been a byproduct of the differentiation of captured_kwargs + # and extra_kwargs - that this wasn't caught in Django's CI is + # evidence that previous behavior wasn't considered spec. + yield ( + 'if (kwargs[key] !== val && ' + 'kwargs[key].toString() !== val.toString() ' + '&& !expected.includes(key)) ' + '{ return false; }' + ) + else: # pragma: no cover + yield ( + 'if (!this.deepEqual(kwargs[key], val)) { return false; }' + ) + yield ( + 'if (!expected.includes(key)) ' + '{ delete kwargs[key]; }' + ) + self.outdent() + yield '}' + self.outdent() + yield '}' + self.outdent() + yield '}' + yield 'if (Array.isArray(expected)) {' + self.indent() + yield ( + 'return Object.keys(kwargs).length === expected.length && ' + 'expected.every(value => kwargs.hasOwnProperty(value));' + ) + self.outdent() + yield '} else if (expected) {' + self.indent() + yield 'return args.length === expected;' + self.outdent() + yield '} else {' + self.indent() + yield 'return Object.keys(kwargs).length === 0 && ' \ + 'args.length === 0;' + self.outdent() + yield '}' + + if '#match' in self.overrides_: + yield from self.transpile_override('#match', impl()) + else: + yield from self.match_jdoc() + yield '#match(kwargs, args, expected, defaults={}) {' + self.indent() + yield from impl() + self.outdent() + yield '}' + + def reverse(self) -> Generator[Optional[str], None, None]: + """ + The reverse() function. + :yield: The JavaScript jdoc comment lines and reverse() function. + """ + def impl() -> Generator[str, None, None]: + """reverse default implementation""" + yield 'if (this.namespace) {' + self.indent() + yield ( + 'qname = `${this.namespace}' + '${qname.replace(this.namespace, "")}`;' + ) + self.outdent() + yield '}' + yield 'const kwargs = options.kwargs || {};' + yield 'const args = options.args || [];' + yield 'const query = options.query || {};' + yield 'let url = this.urls;' + yield "for (const ns of qname.split(':')) {" + self.indent() + yield 'if (ns && url) { url = url.hasOwnProperty(ns) ? ' \ + 'url[ns] : null; }' + self.outdent() + yield '}' + yield 'if (url) {' + self.indent() + yield 'let pth = url(kwargs, args);' + yield 'if (typeof pth === "string") {' + self.indent() + yield 'if (Object.keys(query).length !== 0) {' + self.indent() + yield 'const params = new URLSearchParams();' + yield 'for (const [key, value] of Object.entries(query)) {' + self.indent() + yield "if (value === null || value === '') continue;" + yield 'if (Array.isArray(value)) value.forEach(element => ' \ + 'params.append(key, element));' + yield 'else params.append(key, value);' + self.outdent() + yield '}' + yield 'const qryStr = params.toString();' + yield r"if (qryStr) return `${pth.replace(/\/+$/, '')}?${qryStr}`;" + self.outdent() + yield '}' + yield 'return pth;' + self.outdent() + yield '}' + self.outdent() + yield '}' + if self.raise_on_not_found_: + yield ( + 'throw new TypeError(' + '`No reversal available for parameters at path: ' + '${qname}`);' + ) + + if 'reverse' in self.overrides_: + yield from self.transpile_override('reverse', impl()) + else: + yield from self.reverse_jdoc() + yield 'reverse(qname, options={}) {' + self.indent() + yield from impl() + self.outdent() + yield '}' def init_visit( # pylint: disable=R0915 self @@ -1179,145 +1434,15 @@ class code. ) self.indent() yield '' - yield from self.constructor_jdoc() - yield 'constructor(options=null) {' - self.indent() - yield 'this.options = options || {};' - yield 'if (this.options.hasOwnProperty("namespace")) {' - self.indent() - yield 'this.namespace = this.options.namespace;' - yield 'if (!this.namespace.endsWith(":")) {' - self.indent() - yield 'this.namespace += ":";' - self.outdent() - yield '}' - self.outdent() - yield '} else {' - self.indent() - yield 'this.namespace = "";' - self.outdent() - yield '}' - self.outdent() - yield '}' + yield from self.constructor() yield '' - yield from self.match_jdoc() - yield '#match(kwargs, args, expected, defaults={}) {' - self.indent() - yield 'if (defaults) {' - self.indent() - yield 'kwargs = Object.assign({}, kwargs);' - yield 'for (const [key, val] of Object.entries(defaults)) {' - self.indent() - yield 'if (kwargs.hasOwnProperty(key)) {' - self.indent() - if ( - DJANGO_VERSION[0] >= 4 and DJANGO_VERSION[1] >= 1 - ): # pragma: no cover - # there was a change in Django 4.1 that seems to coerce kwargs - # given to the default kwarg type of the same name if one - # exists for the purposes of reversal. Thus 1 will == '1' - # In javascript we attempt string conversion and hope for the - # best. In 4.1 given kwargs will also override default kwargs - # for kwargs the reversal is expecting. This seems to have - # been a byproduct of the differentiation of captured_kwargs - # and extra_kwargs - that this wasn't caught in Django's CI is - # evidence that previous behavior wasn't considered spec. - yield ( - 'if (kwargs[key] !== val && ' - 'kwargs[key].toString() !== val.toString() ' - '&& !expected.includes(key)) ' - '{ return false; }' - ) - else: # pragma: no cover - yield 'if (!this.deepEqual(kwargs[key], val)) { return false; }' - yield ( - 'if (!expected.includes(key)) ' - '{ delete kwargs[key]; }' - ) - self.outdent() - yield '}' - self.outdent() - yield '}' - self.outdent() - yield '}' - yield 'if (Array.isArray(expected)) {' - self.indent() - yield ( - 'return Object.keys(kwargs).length === expected.length && ' - 'expected.every(value => kwargs.hasOwnProperty(value));' - ) - self.outdent() - yield '} else if (expected) {' - self.indent() - yield 'return args.length === expected;' - self.outdent() - yield '} else {' - self.indent() - yield 'return Object.keys(kwargs).length === 0 && ' \ - 'args.length === 0;' - self.outdent() - yield '}' - self.outdent() - yield '}' + yield from self.match() yield '' yield from self.deep_equal() yield '' yield from self.is_object() yield '' - yield from self.reverse_jdoc() - yield 'reverse(qname, options={}) {' - self.indent() - yield 'if (this.namespace) {' - self.indent() - yield ( - 'qname = `${this.namespace}' - '${qname.replace(this.namespace, "")}`;' - ) - self.outdent() - yield '}' - yield 'const kwargs = options.kwargs || {};' - yield 'const args = options.args || [];' - yield 'const query = options.query || {};' - yield 'let url = this.urls;' - yield "for (const ns of qname.split(':')) {" - self.indent() - yield 'if (ns && url) { url = url.hasOwnProperty(ns) ? ' \ - 'url[ns] : null; }' - self.outdent() - yield '}' - yield 'if (url) {' - self.indent() - yield 'let pth = url(kwargs, args);' - yield 'if (typeof pth === "string") {' - self.indent() - yield 'if (Object.keys(query).length !== 0) {' - self.indent() - yield 'const params = new URLSearchParams();' - yield 'for (const [key, value] of Object.entries(query)) {' - self.indent() - yield "if (value === null || value === '') continue;" - yield 'if (Array.isArray(value)) value.forEach(element => ' \ - 'params.append(key, element));' - yield 'else params.append(key, value);' - self.outdent() - yield '}' - yield 'const qryStr = params.toString();' - yield r"if (qryStr) return `${pth.replace(/\/+$/, '')}?${qryStr}`;" - self.outdent() - yield '}' - yield 'return pth;' - self.outdent() - yield '}' - self.outdent() - yield '}' - if self.raise_on_not_found_: - yield ( - 'throw new TypeError(' - '`No reversal available for parameters at path: ' - '${qname}`);' - ) - self.outdent() - yield '}' + yield from self.reverse() yield '' yield 'urls = {' @@ -1328,6 +1453,8 @@ def close_visit(self) -> Generator[Optional[str], None, None]: :yield: Trailing JavaScript LoC """ yield '}' + for _, override in self.overrides_.items(): + yield from override.transpile(self.context) self.outdent() yield '};' diff --git a/setup.cfg b/setup.cfg index f5f6e9b..acef0c7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,11 @@ addopts = #log_cli = true #log_cli_level = INFO +[coverage:run] +# dont exempt tests from coverage - useful to make sure they're being run +omit = + render_static/tests/app1/static_jinja2/batch_test/**/* + [mypy] # The mypy configurations: http://bit.ly/2zEl9WI allow_redefinition = False