import dis
import functools
import re
import types
from sys import version_info
import linetable
import kajiki
from kajiki import i18n, lnotab
from kajiki.html_utils import HTML_EMPTY_ATTRS
from kajiki.ir import generate_python
from kajiki.util import flattener, literal
class _obj: # noqa: N801
def __init__(self, **kw):
for k, v in kw.items():
setattr(self, k, v)
[docs]
class _Template:
"""Base Class for all compiled Kajiki Templates.
All kajiki templates created from a :class:`kajiki.ir.TemplateNode` will
be subclasses of this class.
As the template body code runs inside ``__main__`` method of this
class, the instance of this class is always available as ``self``
inside the template code.
This class also makes available some global object inside the
template code itself:
- ``local`` which is the instance of the template
- ``defined`` which checks if the given variable is defined
inside the template scope.
- ``Markup`` which marks the passed object as markup code and
prevents escaping for its content.
- ``__kj__`` which is a special object used by generated code
providing features like keeping track of py:with stack or
or the gettext function used to translate text.
"""
__methods__ = ()
loader = None
base_globals = None
filename = None
def __init__(self, context=None):
if context is None:
context = {}
self._context = context
base_globals = self.base_globals or {}
self.__globals__ = {
"local": self,
"self": self,
"defined": lambda x: x in self.__globals__,
"literal": literal,
"Markup": literal,
"gettext": i18n.gettext,
"__builtins__": __builtins__,
"__kj__": kajiki,
}
self.__globals__.update(base_globals)
for k, v in self.__methods__:
v = v.bind_instance(self) # noqa: PLW2901
setattr(self, k, v)
self.__globals__[k] = v
self.__kj__ = _obj(
extend=self._extend,
push_switch=self._push_switch,
pop_switch=self._pop_switch,
case=self._case,
import_=self._import,
escape=self._escape,
gettext=self._gettext,
render_attrs=self._render_attrs,
push_with=self._push_with,
pop_with=self._pop_with,
collect=self._collect,
)
self._switch_stack = []
self._with_stack = []
self.__globals__.update(context)
self.__globals__["_"] = self.__globals__["gettext"]
self.__globals__["value_of"] = self.__globals__.get
def __iter__(self):
"""We convert the chunk to string because it can be of any type
-- after all, the template supports expressions such as ${x+y}.
Here, ``chunk`` can be the computed expression result.
"""
for chunk in self.__main__():
yield str(chunk)
[docs]
def render(self):
"""Render the template to a string."""
return "".join(self)
def _gettext(self, s):
"""Used by the code generated by the template to translate static text"""
return self.__globals__["gettext"](s)
def _push_with(self, locals_, vars): # noqa: A002
"""Enter a ``py:with`` block.
When a ``py:with`` block is encountered, previous values
of the variables assigned inside the ``py:with`` statement are
pushed on top of a stack by :class:`kajiki.ir.WithNode` so that
when the node is exited the previous values can be recovered.
"""
self._with_stack.append([locals_.get(k, ()) for k in vars])
def _pop_with(self):
"""Exists a ``py:with`` block.
When a ``py:with`` block is exited the values stack is popped
and the head returned to :class:`kajiki.ir.WithNode` so that
it can set any previously existing variable to its old value.
"""
return self._with_stack.pop()
def _extend(self, parent):
"""
Called when a child template extends a parent template
the first thing it does is asking the loader of the
child template to load the parent template
"""
if isinstance(parent, str):
parent = self.loader.import_(parent)
p_inst = parent(self._context)
p_globals = p_inst.__globals__
# Find overrides
for k, v in self.__globals__.items():
if k == "__main__":
continue
if not isinstance(v, TplFunc):
continue
p_globals[k] = v
# Find inherited funcs
for k, v in p_inst.__globals__.items():
if k == "__main__":
continue
if not isinstance(v, TplFunc):
continue
if k not in self.__globals__:
self.__globals__[k] = v
if not hasattr(self, k):
def _(k=k):
"""Capture the 'k' variable in a closure"""
def trampoline(*a, **kw):
global parent # noqa: PLW0602
return getattr(parent, k)(*a, **kw)
return trampoline
setattr(self, k, TplFunc(_()).bind_instance(self))
p_globals["child"] = self
p_globals["local"] = p_inst
p_globals["self"] = self.__globals__["self"]
self.__globals__["parent"] = p_inst
self.__globals__["local"] = self
return p_inst
def _push_switch(self, expr):
"""Enter a ``py:switch`` block.
Pushes provided value on the stack used
to check ``py:case`` statements against.
Calling :meth:`._pop_switch` will exit the switch block.
"""
self._switch_stack.append(expr)
def _pop_switch(self):
"""Exit current ``py:switch`` block.
Pops current value from the stack used
to check ``py:case`` statements against.
"""
self._switch_stack.pop()
def _case(self, obj):
"""Check against current ``py:switch`` value."""
return obj == self._switch_stack[-1]
def _import(self, name, alias, gbls):
# Load template as a fragment to avoid extra <DOCTYPE> in included output.
# Due to loader cache, this has the side effect that if the same
# template is both included and used as a standalone page
# it might act as a fragment or not depending on the order it was loaded.
# But usually templates meant for inclusion are not standalone pages.
# Also there is no way to set a template as a fragment once loaded.
# So we can only do it through the loader.
tpl_cls = self.loader.import_(name, is_fragment=True)
if alias is None:
alias = self.loader.default_alias_for(name)
r = gbls[alias] = tpl_cls(gbls)
return r
def _escape(self, value):
"""Returns the given HTML with ampersands, carets and quotes encoded."""
if value is None or isinstance(value, flattener):
return value
if hasattr(value, "__html__"):
return value.__html__()
uval = str(value)
if self._re_escape.search(uval): # Scan the string before working.
# stdlib escape() is inconsistent between Python 2 and Python 3.
# In 3, html.escape() translates the single quote to '''
# In 2.6 and 2.7, cgi.escape() does not touch the single quote.
# Preserve our tests and Kajiki behaviour across Python versions:
return uval.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
# .replace("'", '''))
# Above we do NOT escape the single quote; we don't need it because
# all HTML attributes are double-quoted in our output.
return uval
_re_escape = re.compile(r'&|<|>|"')
def _render_attrs(self, attrs, mode):
"""Render tag attributes in key="value" format.
A :class:`kajiki.ir.AttrsNode` will generate
code that in fact leads to this function to generate
the html for tag attributes.
"""
if hasattr(attrs, "items"):
attrs = attrs.items()
if attrs is not None:
for k, v in sorted(attrs):
if k in HTML_EMPTY_ATTRS and v in (True, False):
v = k if v else None # noqa: PLW2901
if v is None:
continue
if mode.startswith("html") and k in HTML_EMPTY_ATTRS:
yield " " + k.lower()
else:
yield f' {k}="{self._escape(v)}"'
def _collect(self, it):
result = []
for part in it:
if part is None:
continue
if isinstance(part, flattener):
result.append(str(part.accumulate_str()))
else:
result.append(str(part))
if result:
return "".join(result)
return None
@classmethod
def annotate_lnotab(cls, py_to_tpl):
for _name, meth in cls.__methods__:
meth.annotate_lnotab(cls.filename, py_to_tpl, dict(py_to_tpl))
[docs]
def defined(self, name):
"""Check if a variable was provided to the template or not"""
return name in self._context
def Template(ns): # noqa: N802
"""Creates a :class:`._Template` subclass from an entity with ``exposed`` functions.
Kajiki uses classes as containers of the exposed functions for convenience,
but any object that can have the functions as attributes works.
To be a valid template the original entity must provide at least a ``__main__``
function::
class Example:
@kajiki.expose
def __main__():
yield "Hi"
t = kajiki.Template(Example)
output = t().render()
print(output)
"Hi"
"""
dct = {}
methods = dct["__methods__"] = []
for name in dir(ns):
value = getattr(ns, name)
if getattr(value, "exposed", False):
methods.append((name, TplFunc(getattr(value, "__func__", value))))
return type(ns.__name__, (_Template,), dct)
def from_ir(ir_node, base_globals=None):
"""Creates a template class from Intermediate Representation TemplateNode.
This actually creates the class defined by the TemplateNode by executing
its code and returns a subclass of it.
The returned class is a subclass of :class:`kajiki.template._Template`.
It is possible to use `base_globals` to set context values
or replace default ones
"""
if base_globals is None:
base_globals = {}
py_lines = list(generate_python(ir_node))
py_text = "\n".join(map(str, py_lines))
py_linenos = []
last_lineno = 0
py_lineno = 1
for line in py_lines:
lno = max(last_lineno, line._lineno or 0) # noqa: SLF001
for _ in range(str(line).count("\n") + 1):
py_linenos.append((py_lineno, lno))
py_lineno += 1
last_lineno = lno
dct = {"kajiki": kajiki}
try:
exec(py_text, dct) # noqa: S102
except (SyntaxError, IndentationError) as e: # pragma no cover
raise KajikiSyntaxError(e.msg, py_text, e.filename, e.lineno, e.offset) from e
tpl = dct["template"]
tpl.base_globals = base_globals.copy()
tpl.base_globals.update(dct)
tpl.py_text = py_text
tpl.filename = ir_node.filename
tpl.annotate_lnotab(py_linenos)
return tpl
class TplFunc:
"""A template function attached to a _Template.
By default template functions (ie: __main__) depends
on variables like ``self``, ``local`` and so on which
are provided by :class:`._Template`.
This is used by :meth:`.Template` to create a new
``_Template`` with the attached functions.
"""
def __init__(self, func, inst=None):
self._func = func
self._inst = inst
self._bound_func = None
def bind_instance(self, inst):
return TplFunc(self._func, inst)
def __repr__(self): # pragma no cover
if self._inst:
return f"<bound tpl_function {self._func.__name__!r} of {self._inst!r}>"
return f"<unbound tpl_function {self._func.__name__!r}>"
def __call__(self, *args, **kwargs):
if self._bound_func is None:
self._bound_func = self._bind_globals(self._inst.__globals__)
return self._bound_func(*args, **kwargs)
def _bind_globals(self, globals): # noqa: A002
"""Return a function which has the globals dict set to 'globals'
and which flattens the result of self._func'.
"""
func = types.FunctionType(
self._func.__code__,
globals,
self._func.__name__,
self._func.__defaults__,
self._func.__closure__,
)
return functools.update_wrapper(lambda *a, **kw: flattener(func(*a, **kw)), func)
def annotate_lnotab(self, filename, py_to_tpl, py_to_tpl_dct):
if not py_to_tpl:
return
code = self._func.__code__
new_firstlineno = py_to_tpl_dct.get(code.co_firstlineno, 0)
if version_info >= (3, 11):
ltable = linetable.parse_linetable(code.co_linetable, code.co_firstlineno)
new_lnotab = linetable.generate_linetable(
(
(
length,
py_to_tpl_dct[start_line],
py_to_tpl_dct[end_line],
None,
None,
)
if start_line
else (length, None, None, None, None)
for length, start_line, end_line, *_ in ltable
),
firstlineno=new_firstlineno,
)
else:
new_lnotab_numbers = []
for bc_off, py_lno in dis.findlinestarts(code):
tpl_lno = py_to_tpl_dct[py_lno]
new_lnotab_numbers.append((bc_off, tpl_lno))
if not new_lnotab_numbers:
return
new_lnotab = lnotab.lnotab_string(new_lnotab_numbers, new_firstlineno)
new_code = patch_code_file_lines(code, filename, new_firstlineno, new_lnotab)
self._func.__code__ = new_code
return
def patch_code_file_lines(code, filename, firstlineno, lnotab):
code_args = (
code.co_argcount,
code.co_posonlyargcount,
code.co_kwonlyargcount,
code.co_nlocals,
code.co_stacksize,
code.co_flags,
code.co_code,
code.co_consts,
code.co_names,
code.co_varnames,
filename,
code.co_name,
code.co_qualname if version_info >= (3, 11) else "REMOVE",
firstlineno,
lnotab, # linetable for >=3.11 and lnotab for <3.11
code.co_exceptiontable if version_info >= (3, 11) else "REMOVE",
code.co_freevars,
code.co_cellvars,
)
return types.CodeType(*(arg for arg in code_args if arg != "REMOVE"))
class KajikiTemplateError(Exception):
def __init__(self, msg, source, filename, linen, coln):
super().__init__(f"[{filename}:{linen}] {msg}\n{self._get_source_snippet(source, linen)}")
self.filename = filename
self.linenum = linen
self.colnum = coln
def _get_source_snippet(self, source, lineno):
lines = source.splitlines()
# Lines are 1 indexed, account for that.
lineno -= 1
parts = []
for i in range(lineno - 2, lineno + 2):
if 0 <= i < len(lines):
parts.append( # noqa: PERF401
"\t {arrow} {src}\n".format(arrow="-->" if i == lineno else " ", src=lines[i])
)
return "".join(parts)
class KajikiSyntaxError(KajikiTemplateError):
pass