"""
Base transpiler components.
"""
import json
import numbers
from abc import ABCMeta, abstractmethod
from collections.abc import Hashable
from datetime import date, datetime
from enum import Enum
from importlib import import_module
from types import ModuleType
from typing import (
TYPE_CHECKING,
Any,
Callable,
Collection,
Dict,
Generator,
List,
Optional,
Set,
Type,
Union,
cast,
)
from django.apps import apps
from django.apps.config import AppConfig
from django.template.context import Context
from django.utils.module_loading import import_string
from django.utils.safestring import SafeString
if TYPE_CHECKING: # pragma: no cover
from render_static.templatetags.render_static import OverrideNode
__all__ = [
"CodeWriter",
"Transpiler",
"TranspilerTargets",
"TranspilerTarget",
"ResolvedTranspilerTarget",
]
ResolvedTranspilerTarget = Union[Type, ModuleType, AppConfig]
TranspilerTarget = Union[ResolvedTranspilerTarget, str]
TranspilerTargets = Collection[TranspilerTarget]
[docs]
def to_js(value: Any) -> str:
"""
Default javascript transpilation function for values. Simply adds quotes
if it's a string and falls back on json.dumps() for non-strings and non-
numerics.
:param value: The value to transpile
:return: Valid javascript code that represents the value
"""
if isinstance(value, Enum):
value = value.value
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, numbers.Number):
return str(value)
if isinstance(value, str):
return f'"{value}"'
try:
return json.dumps(value)
except TypeError:
if isinstance(value, datetime):
return f'"{value.isoformat()}"'
return f'"{str(value)}"'
[docs]
def to_js_datetime(value: Any) -> str:
"""
A javascript value transpilation function that transpiles python dates and
datetimes to javascript Date objects instead of strings. To use this
function in any of the transpilation routines pass it to the to_javascript
parameter on any of the template tags::
{% ... to_javascript="render_static.transpilers.to_js_datetime" %}
:param value: The value to transpile
:return: Valid javascript code that represents the value
"""
if isinstance(value, date):
return f'new Date("{value.isoformat()}")'
return to_js(value)
class _TargetTreeNode:
"""
Simple tree node for tracking python target hierarchy.
"""
target: Optional[ResolvedTranspilerTarget]
children: List["_TargetTreeNode"]
transpile = False
def __init__(
self, target: Optional[ResolvedTranspilerTarget] = None, transpile: bool = False
):
"""
:param target: The target at this node
"""
self.target = target
self.children = []
self.transpile = transpile
def append(self, child: "_TargetTreeNode"):
"""
Only appends children that are to be transpiled or that have children.
:param child: The child node
"""
if child.transpile or child.children:
self.children.append(child)
[docs]
class CodeWriter:
"""
A base class that provides basic code writing functionality. This class
implements a simple indentation/newline scheme that deriving classes may
use.
"""
rendered_: str
level_: int = 0
prefix_: str = ""
indent_: str = " " * 4
nl_: str = "\n"
[docs]
def __init__(
self,
level: int = level_,
indent: Optional[str] = indent_,
prefix: str = prefix_,
**kwargs,
) -> None:
"""
:param level: The level to start indentation at
:param indent: The indent string to use
:param prefix: A prefix string to add to each line
:param kwargs: Any additional configuration parameters
"""
self.rendered_ = ""
self.level_ = level
self.indent_ = indent or ""
self.prefix_ = prefix or ""
self.nl_ = self.nl_ if self.indent_ else ""
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
indentation/newline configuration for this generator.
:param line: The code line to write
:return:
"""
if line is not None:
self.rendered_ += self.get_line(line)
def indent(self, incr: int = 1) -> None:
"""
Step in one or more indentation levels.
:param incr: The number of indentation levels to step into. Default: 1
:return:
"""
self.level_ += incr
def outdent(self, decr: int = 1) -> None:
"""
Step out one or more indentation levels.
:param decr: The number of indentation levels to step out. Default: 1
:return:
"""
self.level_ -= decr
self.level_ = max(0, self.level_)
[docs]
class Transpiler(CodeWriter, metaclass=ABCMeta):
"""
An abstract base class for JavaScript generator types. This class defines a
basic generation API, and implements configurable indentation/newline
behavior. It also offers a toggle for ES5/ES6 mode that deriving classes
may use.
To use this class derive from it and implement include_target() and
visit().
"""
to_javascript_: Callable = to_js
parents_: List[Union[ModuleType, Type]]
target_: ResolvedTranspilerTarget
overrides_: Dict[str, "OverrideNode"]
@property
def target(self):
"""The python artifact that is the target to transpile."""
return self.target_
@property
def parents(self):
"""
When in visit() this returns the parents (modules and classes) of the
visited target.
"""
return [parent for parent in self.parents_ 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}
[docs]
def __init__(
self,
to_javascript: Union[str, Callable] = to_javascript_,
overrides: Optional[Dict[str, "OverrideNode"]] = None,
**kwargs,
) -> None:
"""
:param to_javascript: A callable that accepts a python artifact and returns
a transpiled object or primitive instantiation.
:param kwargs: A set of configuration parameters for the generator, see
above.
"""
super().__init__(**kwargs)
self.to_javascript = (
to_javascript 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(
Context(
{**self.context, "default_impl": SafeString(d_impl), **(context or {})}
)
)
@abstractmethod
def include_target(self, target: ResolvedTranspilerTarget) -> bool:
"""
Deriving transpilers must implement this method to filter targets
(modules or classes) in and out of transpilation. Transpilers are
expected to walk module trees and pick out supported python artifacts.
:param target: The python artifact to filter in or out
:return: True if the target can be transpiled
"""
return True
def transpile(self, targets: TranspilerTargets) -> str:
"""
Generate and return javascript as a string given the targets. This
method iterates over the list of given targets, imports any strings
and builds a tree from targets the deriving transpiler filters
in via `include_target`. It then does a depth first traversal through
the tree to any leaf target nodes that were included and visits them
where any deriving class transpilation takes place.
:param targets: The python targets to transpile
:return: The rendered JavaScript string
"""
root = _TargetTreeNode()
deduplicate_set: Set[Hashable] = set()
def walk_class(cls: _TargetTreeNode):
for name, cls_member in vars(cls.target).items():
if name.startswith("_"):
continue
if isinstance(cls_member, type) and cls_member not in deduplicate_set:
deduplicate_set.add(cls_member)
cls.append(
walk_class(
_TargetTreeNode(cls_member, self.include_target(cls_member))
)
)
return cls
for target in targets:
# do this instead of isinstance b/c types that inherit from strings
# may be targets
if isinstance(target, str):
if apps.is_installed(target):
target = cast(
AppConfig,
{
app_config.name: app_config
for app_config in apps.get_app_configs()
}.get(target),
)
else:
try:
target = apps.get_app_config(target)
except LookupError:
assert isinstance(target, str)
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, ValueError) as err:
if tries == 1:
try:
target = import_module(".".join(parts))
break
except (ImportError, ModuleNotFoundError):
if len(parts) == 1:
raise ImportError(
f"Unable to import {target}"
) from err
elif tries == len(parts):
raise ImportError(
f"Unable to import {target}"
) from err
target = cast(ResolvedTranspilerTarget, target)
node = _TargetTreeNode(target, self.include_target(target))
if node.target in deduplicate_set:
continue
if isinstance(target, Hashable):
deduplicate_set.add(target)
if isinstance(target, type):
root.append(walk_class(node))
elif isinstance(target, ModuleType):
for _, member in vars(target).items():
if isinstance(member, type):
node.append(
walk_class(
_TargetTreeNode(member, self.include_target(member))
)
)
root.append(node)
elif isinstance(target, AppConfig):
root.append(node)
if not root.transpile and not root.children:
raise ValueError(f"No targets were transpilable: {targets}")
def visit_depth_first(
branch: _TargetTreeNode, is_last: bool = False, final: bool = True
):
is_final = final and not branch.children
if branch.target and not isinstance(branch.target, AppConfig):
for stm in self.enter_parent(branch.target, is_last, is_final):
self.write_line(stm)
if branch.transpile and branch.target:
self.target_ = branch.target
for stm in self.visit(branch.target, is_last, is_final):
self.write_line(stm)
if branch.children:
for idx, child in enumerate(branch.children):
visit_depth_first(
child,
idx == len(branch.children) - 1,
(idx == len(branch.children) - 1) and final,
)
if branch.target and not isinstance(branch.target, AppConfig):
for stm in self.exit_parent(branch.target, is_last, is_final):
self.write_line(stm)
for line in self.start_visitation():
self.write_line(line)
visit_depth_first(root)
for line in self.end_visitation():
self.write_line(line)
return self.rendered_
def enter_parent(
self, parent: Union[ModuleType, Type], is_last: bool, is_final: bool
) -> Generator[Optional[str], None, None]:
"""
Enter and visit a target, pushing it onto the parent stack.
:param parent: The target, class or module to transpile.
:param is_last: True if this is the last target that will be visited at
this level.
:param is_final: False if this is the last target that will be visited
at all.
:yield: javascript lines, writes nothing by default
"""
self.parents_.append(parent)
if isinstance(parent, ModuleType):
yield from self.enter_module(parent, is_last, is_final)
else:
yield from self.enter_class(parent, is_last, is_final)
def exit_parent(
self, parent: Union[ModuleType, Type], is_last: bool, is_final: bool
) -> Generator[Optional[str], None, None]:
"""
Exit a target, removing it from the parent stack.
:param parent: The target, class or module that was just transpiled.
:param is_last: True if this is the last target that will be visited at
this level.
:param is_final: False if this is the last target that will be visited
at all.
:yield: javascript lines, writes nothing by default
"""
del self.parents_[-1]
if isinstance(parent, ModuleType):
yield from self.exit_module(parent, is_last, is_final)
else:
yield from self.exit_class(parent, is_last, is_final)
def enter_module(
self,
module: ModuleType,
is_last: bool,
is_final: bool,
) -> Generator[Optional[str], None, None]:
"""
Transpile a module.
:param module: The module to transpile
:param is_last: True if this is the last target at this level to
transpile.
:param is_final: True if this is the last target at all to transpile.
:yield: javascript lines, writes nothing by default
"""
yield None
def exit_module(
self,
module: ModuleType,
is_last: bool,
is_final: bool,
) -> Generator[Optional[str], None, None]:
"""
Close transpilation of a module.
:param module: The module that was just transpiled
:param is_last: True if this is the last target at this level to
transpile.
:param is_final: True if this is the last target at all to transpile.
:yield: javascript lines, writes nothing by default
"""
yield None
def enter_class(
self,
cls: Type[Any],
is_last: bool,
is_final: bool,
) -> Generator[Optional[str], None, None]:
"""
Transpile a class.
:param cls: The class to transpile
:param is_last: True if this is the last target at this level to
transpile.
:param is_final: True if this is the last target at all to transpile.
:yield: javascript lines, writes nothing by default
"""
yield None
def exit_class(
self,
cls: Type[Any],
is_last: bool,
is_final: bool,
) -> Generator[Optional[str], None, None]:
"""
Close transpilation of a class.
:param cls: The class that was just transpiled
:param is_last: True if this is the last target at this level to
transpile.
:param is_final: True if this is the last target at all to transpile.
:yield: javascript lines, writes nothing by default
"""
yield None
def start_visitation(self) -> Generator[Optional[str], None, None]:
"""
Begin transpilation - called before visit(). Override this function
to do any initial code generation.
:yield: javascript lines, writes nothing by default
"""
yield None
def end_visitation(self) -> Generator[Optional[str], None, None]:
"""
End transpilation - called after all visit() calls have completed.
Override this function to do any wrap up code generation.
:yield: javascript lines, writes nothing by default
"""
yield None
@abstractmethod
def visit(
self, target: ResolvedTranspilerTarget, is_last: bool, is_final: bool
) -> Generator[Optional[str], None, None]:
"""
Deriving transpilers must implement this method.
:param target: The python target to transpile, will be either a class
a module or an installed Django app.
:param is_last: True if this is the last target that will be visited at
this level.
:param is_final: True if this is the last target that will be visited
at all.
:yield: lines of javascript
"""
def to_js(self, value: Any):
"""
Return the javascript transpilation of the given value.
:param value: The value to transpile
:return: A valid javascript code that represents the value
"""
return self.to_javascript(value)