"""
mstache module.
This module alone implements the entire mstache library, including its
minimal command line interface.
The main functions are considered to be :func:`cli`, :func:`render` and
:func:`stream`, and they expose different approaches to template rendering:
command line interface, buffered and streaming rendering API, respectively.
Other functionality will be considered **advanced**, exposing some
implementation-specific complexity and potentially non-standard behavior
which could reduce compatibility with other mustache implementations and
future major mstache revisions.
"""
"""
mstache, Mustache for Python
============================
See `README.md`_, `project documentation`_ and `project repository`_.
.. _README.md: https://mstache.readthedocs.io/en/latest/README.html
.. _project documentation: https://mstache.readthedocs.io
.. _project repository: https://gitlab.com/ergoithz/mstache
License
-------
Copyright (c) 2021-2024, Felipe A Hernandez.
MIT License (see `LICENSE`_).
.. _LICENSE: https://gitlab.com/ergoithz/mstache/-/blob/master/LICENSE
"""
import codecs
import collections
import collections.abc
import math
import re
import types
import typing
__author__ = 'Felipe A Hernandez'
__email__ = 'ergoithz@gmail.com'
__license__ = 'MIT'
__version__ = '0.3.0'
__all__ = (
# api
'tokenize',
'stream',
'render',
'cli',
# exceptions
'TokenException',
'ClosingTokenException',
'SectionException',
'UnopenedSectionException',
'UnclosedSectionException',
'UnclosedTokenException',
'DelimiterTokenException',
'TemplateRecursionError',
# defaults
'default_recursion_limit',
'default_resolver',
'default_getter',
'default_strict_getter',
'default_getter_tuple',
'default_stringify',
'default_escape',
'default_lambda_render',
'default_tags',
'default_cache',
'default_cache_make_key',
'default_virtuals',
# types
'Buffer',
'TString',
'PartialResolver',
'PropertyGetter',
'PropertyGetterTuple',
'StringifyFunction',
'EscapeFunction',
'LambdaRenderFunctionConstructor',
'VirtualPropertyFunction',
'VirtualPropertyMapping',
'TagsTuple',
'TagsByteTuple',
'CompiledToken',
'CompiledTemplate',
'CompiledTemplateCache',
'CacheMakeKeyFunction',
)
T = typing.TypeVar('T')
"""Generic."""
D = typing.TypeVar('D')
"""Generic."""
Buffer: typing.TypeAlias = str | bytes
"""
String/bytes, string or bytes depending on text or binary mode.
.. versionadded:: 0.3.0
"""
TString = typing.TypeVar('TString', bound=Buffer)
"""String/bytes generic, string or bytes depending on text or binary mode."""
PartialResolver: typing.TypeAlias = collections.abc.Callable[
[Buffer],
Buffer | None,
]
"""
Template partial resolver function interface.
.. seealso::
:func:`mstache.default_resolver`
Default partial resolver stub implementation.
"""
PropertyGetter: typing.TypeAlias = collections.abc.Callable[
[typing.Any, collections.abc.Sequence, Buffer, T],
typing.Any | T,
]
"""
Template property getter function interface.
.. seealso::
:func:`mstache.default_getter`
Default non-strict getter implementation.
:func:`mstache.default_strict_getter`
Default strict getter implementation.
"""
PropertyGetterTuple: typing.TypeAlias = (
tuple[PropertyGetter, PropertyGetter]
| collections.abc.Sequence[PropertyGetter]
)
"""
Tuple of property getter functions: non-strict and strict.
.. seealso::
:attr:`mstache.PropertyGetter`
Property getter function interface.
:func:`mstache.default_getter`
Default non-strict getter implementation.
:func:`mstache.default_strict_getter`
Default strict getter implementation.
.. versionaddedd:: 0.3.0
"""
StringifyFunction: typing.TypeAlias = collections.abc.Callable[
[typing.Any, bool],
bytes,
]
"""
Template variable general stringification function interface.
.. seealso::
:func:`mstache.default_stringify`
Default stringify-function implementation.
"""
EscapeFunction: typing.TypeAlias = collections.abc.Callable[[bytes], bytes]
"""Template variable value escape function interface."""
LambdaRenderFunctionConstructor: typing.TypeAlias = collections.abc.Callable[
...,
collections.abc.Callable[..., Buffer],
] | None
"""
Lambda render function constructor or :type:`None` for implicit rendering.
Options:
- :type:`collections.abc.Callable` returning another callable which to be sent
via ``render`` parameter to explicit (chevron-style) lambdas expecting
both template content and render parameters.
- :type:`None` enables implicit rendering of lambda return values (spec-style)
with lambdas only receving the template content as argument.
.. seealso::
:func:`mstache.default_lambda_render`
Default explicit lambda render constructor.
.. versionchanged:: 0.3.0
:type:`None` for implicit return value render.
"""
LambdaRenderFunctionFactory = LambdaRenderFunctionConstructor
"""
.. deprecated:: 0.1.3
Use :attr:`LambdaRenderFunctionConstructor` instead.
"""
VirtualPropertyFunction: typing.TypeAlias = collections.abc.Callable[
[typing.Any],
typing.Any,
]
"""Virtual property implementation callable interface."""
VirtualPropertyMapping: typing.TypeAlias = collections.abc.Mapping[
str,
VirtualPropertyFunction,
]
"""
Virtual property mapping interface.
.. seealso::
:attr:`mstache.default_virtuals`
Default mapping of virtual property functions.
"""
TagsTuple: typing.TypeAlias = (
tuple[Buffer, Buffer]
| collections.abc.Sequence[Buffer]
)
"""
Mustache tag tuple interface.
.. seealso::
:attr:`mstache.default_tags`
Default tuple of mustache template tags.
"""
TagsByteTuple: typing.TypeAlias = (
tuple[bytes, bytes]
| collections.abc.Sequence[bytes]
)
"""
Mustache tag byte tuple interface.
.. seealso::
:attr:`mstache.default_tags`
Default tuple of mustache template tags.
"""
CompiledToken: typing.TypeAlias = tuple[bool, bool, bool, bytes, bytes, int]
"""
Compiled template token.
Tokens are tuples containing a renderer decision path, key, content and flags.
``a: bool``
Decision for rendering path node ``a``.
``b: bool``
Decision for rendering path node ``b``.
``c: bool``
Decision for rendering path node ``c``.
``key: bytes``
Template substring with token scope key.
``content: bytes``
Template substring with token content data.
``flags: int``
Token flags.
- Unused: ``-1`` (default)
- Variable flags:
- ``0`` - escaped
- ``1`` - unescaped
- Block start flags:
- ``0`` - falsy
- ``1`` - truthy
- Block end value: block content index.
"""
CompiledTemplate: typing.TypeAlias = tuple[CompiledToken, ...]
"""
Compiled template interface.
.. seealso::
:attr:`mstache.CompiledToken`
Item type.
:attr:`mstache.CompiledTemplateCache`
Interface exposing this type.
"""
[docs]
class CompiledTemplateCache(typing.Protocol):
"""
Cache object protocol.
.. seealso::
:attr:`mstache.CompiledTemplate`
Item type.
:attr:`mstache.CacheMakeKeyFunction`
Cache key function interface.
:attr:`mstache.default_cache`
Default cache instance.
"""
[docs]
def get(self, key: typing.Any) -> CompiledTemplate | None:
"""Get compiled template from key, if any."""
[docs]
def __setitem__(self, key: typing.Any, value: CompiledTemplate) -> None:
"""Assign compiled template to key."""
CacheMakeKeyFunction: typing.TypeAlias = collections.abc.Callable[
[tuple[bytes, bytes, bytes, bool, bool, bool]],
typing.Any,
]
"""
Cache mapping key function interface.
.. seealso::
:attr:`mstache.CompiledTemplateCache`
Interface exposing this type.
:func:`mstache.default_cache_make_key`
Default implementation of cache make-key function.
"""
class LRUCache(collections.OrderedDict, typing.Generic[T]):
"""Capped mapping discarding least recently used elements."""
def __init__(self, maxsize: int, *args, **kwargs) -> None:
"""
Initialize.
:param maxsize: maximum number of elements will be kept
Any parameter excess will be passed straight to dict constructor.
"""
self.maxsize = maxsize
super().__init__(*args, **kwargs)
def get(self, key: collections.abc.Hashable, default: D = None) -> T | D:
"""
Get value for given key or default if not present.
:param key: hashable
:param default: value will be returned if key is not present
:returns: value if key is present, default if not
"""
try:
return self[key]
except KeyError:
return default # type: ignore
def __getitem__(self, key: collections.abc.Hashable) -> T:
"""
Get value for given key.
:param key: hashable
:returns: value if key is present
:raises KeyError: if key is not present
"""
self.move_to_end(key)
return super().__getitem__(key)
def __setitem__(self, key: collections.abc.Hashable, value: T) -> None:
"""
Set value for given key.
:param key: hashable will be used to retrieve values later on
:param value: value for given key
"""
super().__setitem__(key, value)
try:
self.move_to_end(key)
while len(self) > self.maxsize:
self.popitem(last=False)
except KeyError: # race condition
pass
default_recursion_limit = 1024
"""
Default number of maximum nested renders.
A nested render happens whenener the lambda render parameter is used or when
a partial template is injected.
Meant to avoid recursion bombs on templates.
"""
default_tags = b'{{', b'}}'
"""Tuple of default mustache tags as in ``tuple[bytes, bytes]``."""
default_cache: CompiledTemplateCache = LRUCache(1024)
"""
Default template cache mapping, keeping the 1024 most recently used
compiled templates (LRU expiration).
"""
default_cache_make_key: CacheMakeKeyFunction = tuple
"""
Defaut template cache key function, :class:`tuple`, so keys would be
as in ``tuple[bytes, bytes, bytes, bool]`` containing the relevant
:func:`mstache.tokenizer` parameters.
"""
def virtual_length(ref: collections.abc.Sized) -> int:
"""
Resolve virtual length property.
:param ref: any non-mapping object implementing ``__len__``
:returns: number of items
:raises TypeError: if ref is mapping or has no ``__len__`` method
"""
if isinstance(ref, collections.abc.Mapping):
raise TypeError
return len(ref)
default_virtuals: VirtualPropertyMapping = types.MappingProxyType({
'length': virtual_length,
})
"""
Immutable mapping with default virtual properties.
The following virtual properties are implemented:
- **length**, for non-mapping sized objects, returning ``len(ref)``.
"""
TOKEN_TYPES = tuple({
**dict.fromkeys(range(0x100), (True, False, True, False, False)),
0x21: (False, False, False, False, False), # '!'
0x23: (False, True, True, False, False), # '#'
0x26: (True, True, True, True, False), # '&'
0x2F: (False, True, False, False, False), # '/'
0x3D: (False, False, False, True, True), # '='
0x3E: (False, False, True, False, False), # '>'
0x5E: (False, True, True, True, False), # '^'
0x7B: (True, True, False, True, True), # '{'
}.values())
"""
ASCII-indexed tokenizer decision matrix.
``a: bool``
Decision for tokenization path node ``a``.
``b: bool``
Decision for tokenization path node ``b``.
``c: bool``
Decision for tokenization path node ``c``.
``d: bool``
Decision for tokenization path node ``d``.
``e: bool``
Three-keyed variable / tag switch for next text start logic.
"""
COMMON_FALSY: tuple = None, False, 0, '', b'', (), [], frozenset(), set()
NON_LOOPING_TYPES: tuple = str, bytes, collections.abc.Mapping
MISSING = object()
RE_TAGS = re.compile(br'[^ ]+')
RE_STANDALONE = re.compile(br'[^\S\r\n]*(\n|\r\n?|$)')
RE_INDENT = re.compile(br'^(?!$)', re.MULTILINE)
[docs]
class TokenException(SyntaxError):
"""Invalid token found during tokenization."""
_fmt = 'Invalid tag {tag!r} at line {row} column {column}'
[docs]
@classmethod
def from_template(
cls,
template: bytes,
start: int,
end: int | None = None,
) -> 'TokenException':
"""
Create exception instance from parsing data.
:param template: template bytes
:param start: character position where the offending tag starts at
:param end: character position where the offending tag ends at
:returns: exception instance
"""
tag = template[start:end].decode(errors='replace')
row = 1 + template[:start].count(b'\n')
column = 1 + start - max(0, template.rfind(b'\n', 0, start))
return cls(cls._fmt.format(tag=tag, row=row, column=column))
[docs]
class SectionException(TokenException):
"""
Section error found during tokenization.
.. versionadded:: 0.3.0
"""
_fmt = 'Non-matching tag {tag!r} at line {row} column {column}'
ClosingTokenException = SectionException
"""
.. deprecated:: 0.3.0
Use :attr:`SectionException` instead.
"""
[docs]
class UnopenedSectionException(SectionException):
"""
Closing unknown section during tokenization.
.. versionadded:: 0.3.0
"""
_fmt = 'Unopened section {tag!r} at line {row} column {column}'
[docs]
class UnclosedSectionException(SectionException):
"""
Section left unclosed during tokenization.
.. versionadded:: 0.3.0
"""
_fmt = 'Unclosed section {tag!r} at line {row} column {column}'
[docs]
class UnclosedTokenException(TokenException):
"""Token left unclosed during tokenization."""
_fmt = 'Unclosed token {tag!r} at line {row} column {column}'
[docs]
class DelimiterTokenException(TokenException):
"""
Invalid delimiters token found during tokenization.
.. versionadded:: 0.1.1
"""
_fmt = 'Invalid delimiters {tag!r} at line {row} column {column}'
[docs]
class TemplateRecursionError(RecursionError):
"""Template rendering exceeded maximum render recursion."""
[docs]
def default_stringify(data: typing.Any, text: bool) -> bytes:
"""
Convert arbitrary data to bytes.
:param data: value will be serialized
:param text: whether running in text mode or not (bytes mode)
:returns: template bytes
.. versionchanged:: 0.3.0
``None`` to emit empty bytes
"""
return (
b'' if data is None else
str(data).encode() if text or not isinstance(data, bytes) else
data
)
[docs]
def default_escape(data: bytes) -> bytes:
"""
Convert bytes conflicting with HTML to their escape sequences.
:param data: bytes containing text
:returns: escaped text bytes
"""
return (
data
.replace(b'&', b'&')
.replace(b'<', b'<')
.replace(b'>', b'>')
.replace(b'"', b'"')
.replace(b"'", b'`')
.replace(b'`', b'=')
)
[docs]
def default_resolver(name: typing.Any) -> None:
"""
Mustache partial resolver function (stub).
:param name: partial template name
:returns: None
"""
def getter_resolve_binary(
scope: typing.Any,
components: collections.abc.Sequence[bytes],
virtuals: VirtualPropertyMapping,
default: typing.Any = None,
) -> tuple[bool, bool, typing.Any]:
"""
Resolve binary component sequence from scope object.
:param scope: current object scope to resolve from
:param component: sequence of reference path components
:param virtuals: mapping if virtual property getters
:param default: value will be used as default when missing
:returns: tuple with success (bool), recursed (bool) and result
.. versionadded:: 0.3.0
"""
recursed = False
for name in components:
try:
scope = scope[name]
recursed = True
continue
except (KeyError, TypeError, AttributeError):
pass
try:
name = name.decode() # type: ignore
except UnicodeDecodeError:
return False, recursed, default
try:
scope = scope[name]
recursed = True
continue
except (KeyError, TypeError, AttributeError):
pass
if name.isdigit():
try:
scope = scope[int(name)]
recursed = True
continue
except (TypeError, KeyError, IndexError):
pass
else:
try:
scope = getattr(scope, name) # type: ignore
recursed = True
continue
except AttributeError:
pass
try:
scope = virtuals[name](scope) # type: ignore
recursed = True
continue
except (KeyError, TypeError, AttributeError):
pass
return False, recursed, default
return True, True, scope
def getter_resolve(
scope: typing.Any,
components: collections.abc.Sequence[str],
virtuals: VirtualPropertyMapping,
default: typing.Any = None,
) -> tuple[bool, bool, typing.Any]:
"""
Resolve component sequence from scope object.
:param scope: current object scope to resolve from
:param component: sequence of reference path components
:param virtuals: mapping if virtual property getters
:param default: value will be used as default when missing
:returns: tuple with success (bool), recursed (bool) and result
.. versionadded:: 0.3.0
"""
recursed = False
for name in components:
try:
scope = scope[name]
recursed = True
continue
except (KeyError, TypeError, AttributeError):
pass
if name.isdigit():
try:
scope = scope[int(name)]
recursed = True
continue
except (TypeError, KeyError, IndexError):
pass
else:
try:
scope = getattr(scope, name) # type: ignore
recursed = True
continue
except AttributeError:
pass
try:
scope = virtuals[name](scope) # type: ignore
recursed = True
continue
except (KeyError, TypeError, AttributeError):
pass
return False, recursed, default
return True, True, scope
[docs]
def default_getter(
scope: typing.Any,
scopes: collections.abc.Sequence,
key: Buffer,
default: typing.Any = None,
*,
virtuals: VirtualPropertyMapping = default_virtuals,
) -> typing.Any:
"""
Extract property value from scope hierarchy retrying until full match.
:param scope: uppermost scope (corresponding to key ``'.'``)
:param scopes: parent scope sequence
:param key: property key
:param default: value will be used as default when missing
:param virtuals: mapping of virtual property callables
:param strict: disable compatibility workarounds
:returns: value from scope or default
Both :class:`AttributeError` and :class:`TypeError` exceptions
raised by virtual property implementations will be handled as if
that property doesn't exist, which can be useful to filter out
incompatible types.
.. versionadded:: 0.1.3
**virtuals** parameter.
"""
if key in (b'.', '.'):
return scope
components, resolve = (
(key.split('.'), getter_resolve) if isinstance(key, str) else
(key.split(b'.'), getter_resolve_binary)
)
m, _, res = resolve(scope, components, virtuals) # type: ignore
if m:
return res
for ref in reversed(scopes):
m, _, res = resolve(ref, components, virtuals) # type: ignore
if m:
return res
return default
[docs]
def default_strict_getter(
scope: typing.Any,
scopes: collections.abc.Sequence,
key: Buffer,
default: typing.Any = None,
*,
virtuals: VirtualPropertyMapping = default_virtuals,
) -> typing.Any:
"""
Extract property value from scope hierarchy retrying until partial match.
:param scope: uppermost scope (corresponding to key ``'.'``)
:param scopes: parent scope sequence
:param key: property key
:param default: value will be used as default when missing
:param virtuals: mapping of virtual property callables
:returns: value from scope or default
Both :class:`AttributeError` and :class:`TypeError` exceptions
raised by virtual property implementations will be handled as if
that property doesn't exist, which can be useful to filter out
incompatible types.
.. versionadded:: 0.3.0
"""
if key in (b'.', '.'):
return scope
components, resolve = ( # type: ignore
(key.split('.'), getter_resolve) if isinstance(key, str) else
(key.split(b'.'), getter_resolve_binary)
)
_, m, res = resolve(scope, components, virtuals, default) # type: ignore
if m:
return res
for ref in reversed(scopes):
_, m, res = resolve(ref, components, virtuals, default) # type: ignore
if m:
return res
return default
default_getter_tuple = default_getter, default_strict_getter
"""
Tuple with each getter function for compatibility and strict modes.
Items:
- Reference to :func:`default_getter` used in non-strict mode.
- Reference to :func:`default_strict_getter` used in strict mode..
.. seealso::
:func:`default_getter`
Default getter implementation referenced by this tuple.
.. versionadded:: 0.3.0
"""
[docs]
def default_lambda_render(
scope: typing.Any,
**kwargs,
) -> collections.abc.Callable[[TString], TString]:
r"""
Generate a template-only render function with fixed parameters.
:param scope: current scope
:param \**kwargs: parameters forwarded to :func:`render`
:returns: template render function
"""
def lambda_render(template: TString) -> TString:
"""
Render given template to string/bytes.
:param template: template text
:returns: rendered string or bytes (depending on template type)
"""
return render(template, scope, **kwargs)
return lambda_render
def decode_tags(start_tag: Buffer, end_tag: Buffer) -> TagsByteTuple:
"""
Convert tag delimiters to bytes.
:param start_tag: tag start delimiter
:param end_tag: tag end delimiter
:returns: tuple with delimiters as bytes
.. versionadded:: 0.3.0
"""
return (
start_tag.encode() if isinstance(start_tag, str) else start_tag,
end_tag.encode() if isinstance(end_tag, str) else end_tag,
)
[docs]
def tokenize(
template: bytes,
*,
tags: TagsByteTuple = default_tags,
comments: bool = False,
cache: CompiledTemplateCache = default_cache,
cache_make_key: CacheMakeKeyFunction = default_cache_make_key,
keep_lines: bool = False,
strict: bool = False,
) -> CompiledTemplate:
"""
Compile mustache template as a tuple of token tuples.
:param template: template as utf-8 encoded bytes
:param tags: mustache tag tuple (open, close)
:param comments: whether yield comment tokens or not (ignore comments)
:param cache: mutable mapping for compiled template cache
:param cache_make_key: key function for compiled template cache
:param keep_lines: disable mustache tag-only-line collapsing
:param strict: disable compatibility workarounds
:returns: tuple of token tuples
:raises UnclosedSectionException: if section is left unclosed
:raises UnopenedSectionException: if closing an invalid section
:raises UnclosedTokenException: if tag is left unclosed
:raises DelimiterTokenException: if delimiter token syntax is invalid
.. versionadded:: 0.3.0
**keep_lines** to disable tag-only-line collapsing,
**strict** to disable compatability workarounds
"""
start_tag, end_tag = tags
if cached := cache.get(tokenization_key := cache_make_key(
(template, start_tag, end_tag, comments, keep_lines, strict),
)):
return cached
template_find = template.find
tags_find = RE_TAGS.findall
stack: list[tuple[bytes | None, int, int, int]] = []
stack_append = stack.append
stack_pop = stack.pop
scope_label = None
scope_head = 0
scope_start = 0
scope_index = 0
indent = b''
standalone = RE_STANDALONE.match
linebreaks = b'\r', b'\n'
end_literal = b'}' + end_tag
end_switch = b'=' + end_tag
start_len = len(start_tag)
end_len = len(end_tag)
token_types = TOKEN_TYPES
recording: list[CompiledToken] = []
record = recording.append
text_start = 0
while (text_end := template_find(start_tag, text_start)) != -1:
tag_start = text_end + start_len
try:
a, b, c, d, e = token_types[template[tag_start]]
except IndexError:
raise UnclosedTokenException.from_template(
template=template,
start=text_end,
) from None
if a: # variables
tag_start += b
if text_start < text_end: # inline text
tail = template[text_start:text_end]
record((False, True, True, b'', tail, -1))
if c: # variable
tag_end = template_find(end_tag, tag_start)
text_start = tag_end + end_len
else: # triple-keyed variable
tag_end = template_find(end_literal, tag_start)
text_start = tag_end + end_len + 1
if tag_end < 0:
raise UnclosedTokenException.from_template(
template=template,
start=text_end,
)
record((
False, True, False,
template[tag_start:tag_end].strip(), b'', d,
))
continue
tag_start += 1
cur_start = text_start
if e:
tag_end = template_find(end_switch, tag_start)
text_start = tag_end + end_len + 1
else:
tag_end = template_find(end_tag, tag_start)
text_start = tag_end + end_len
if tag_end < 0:
raise UnclosedTokenException.from_template(
template=template,
start=text_end,
)
if keep_lines:
if cur_start < text_end: # inline text
tail = template[cur_start:text_end]
record((False, True, True, b'', tail, -1))
elif wholeline := standalone(template, text_start):
if cur_start < text_end:
tail = template[cur_start:text_end]
trimmed = tail.rstrip(b' ')
if not trimmed and not cur_start: # start standalone
text_start = wholeline.end()
indent = tail
elif trimmed.endswith(linebreaks): # linebreak standalone
record((False, True, True, b'', trimmed, -1))
text_start = wholeline.end()
indent = tail[len(trimmed):]
else:
record((False, True, True, b'', tail, -1))
indent = b''
else:
text_start = wholeline.end()
indent = b''
elif cur_start < text_end:
tail = template[cur_start:text_end]
record((False, True, True, b'', tail, -1))
indent = b''
else:
indent = b''
if b: # block
if c: # open
stack_append((scope_label, text_end, scope_start, scope_index))
scope_label = template[tag_start:tag_end].strip()
scope_head = text_end
scope_start = text_start
scope_index = len(recording) + 1
record((
True, False, True,
scope_label, b'', d,
))
elif scope_label == template[tag_start:tag_end].strip(): # close
record((
True, True, True,
scope_label, # type: ignore
template[scope_start:text_end].strip(),
scope_index,
))
scope_label, scope_head, scope_start, scope_index = stack_pop()
else:
raise UnopenedSectionException.from_template(
template=template,
start=text_end,
end=text_start,
)
elif c: # partial
record((
True, False, False,
template[tag_start:tag_end].strip(), indent, -1,
))
elif d: # tags
try:
start_tag, end_tag = (
tags_find(template, tag_start, tag_end) if strict else
tags_find(template, tag_start, tag_end)[:2]
)
except ValueError:
raise DelimiterTokenException.from_template(
template=template,
start=tag_start,
end=tag_end,
) from None
end_literal = b'}' + end_tag
end_switch = b'=' + end_tag
start_len = len(start_tag)
end_len = len(end_tag)
record((
False, False, True,
start_tag, end_tag, -1,
))
elif comments: # comment
record((
False, False, False,
b'', template[tag_start:tag_end].strip(), -1,
))
if stack:
raise UnclosedSectionException.from_template(
template=template,
start=scope_head,
end=scope_start,
)
if (tail := template[text_start:]) or not recording: # text
record((
False, True, True,
b'', tail, -1,
))
tokens = cache[tokenization_key] = tuple(recording)
return tokens
def process(
template: bytes,
scope: typing.Any,
*,
scopes: collections.abc.Iterable = (),
resolver: PartialResolver = default_resolver,
getter: PropertyGetter | PropertyGetterTuple = default_getter_tuple,
stringify: StringifyFunction = default_stringify,
escape: EscapeFunction = default_escape,
lambda_render: LambdaRenderFunctionConstructor = default_lambda_render,
tags: TagsByteTuple = default_tags,
cache: CompiledTemplateCache = default_cache,
cache_make_key: CacheMakeKeyFunction = default_cache_make_key,
recursion_limit: int = default_recursion_limit,
keep_lines: bool = False,
strict: bool = False,
text: bool = False,
) -> collections.abc.Generator[bytes, None, None]:
"""
Generate rendered mustache template byte chunks.
:param template: mustache template string
:param scope: root object used as root mustache scope
:param scopes: iterable of parent scopes
:param resolver: callable will be used to resolve partials (bytes)
:param getter: callable will be used to pick variables from scope
:param stringify: callable will be used to render python types (bytes)
:param escape: callable will be used to escape template (bytes)
:param lambda_render: explicit lambda render function constructor
:param tags: mustache tag tuple (open, close)
:param cache: mutable mapping for compiled template cache
:param cache_make_key: key function for compiled template cache
:param recursion_limit: maximum number of nested render operations
:param keep_lines: disable mustache tag-only-line collapsing
:param strict: disable compatibility workarounds
:param text: toggle str/bytes mode
:returns: byte chunk generator
:raises UnclosedSectionException: if section is left unclosed
:raises UnopenedSectionException: if closing an invalid section
:raises UnclosedTokenException: if tag is left unclosed
:raises DelimiterTokenException: if delimiter token syntax is invalid
:raises TemplateRecursionError: if rendering recursion limit is exceeded
.. versionadded:: 0.3.0
**getter** support for :type:`mstache.PropertyGetterTuple`,
**keep_lines** to disable tag-only-line collapsing,
**strict** to disable compatability workarounds
"""
if recursion_limit < 0:
message = 'Template rendering exceeded maximum render recursion'
raise TemplateRecursionError(message)
# current context
recursion_left = recursion_limit - 1
current_tags = tags
items: collections.abc.Iterator | None = None
callback = False
silent = False
# context stack
stack: list[tuple[collections.abc.Iterator | None, bool, bool]] = []
stack_append = stack.append
stack_pop = stack.pop
# scope stack
scopes = list(scopes)
scopes_append = scopes.append
scopes_pop = scopes.pop
# locals
templ = Buffer
decode: typing.Callable[[bytes], Buffer] = (
bytes.decode if text else # type: ignore
bytes
)
implicit_render = lambda_render is None
miss = MISSING
falsies = COMMON_FALSY
non_looping = NON_LOOPING_TYPES
isnan = math.isnan
mappings = collections.abc.Mapping
indent_sub = RE_INDENT.sub
getv = getter if callable(getter) else getter[strict]
start = 0
tokens = tokenized = tokenize(
template,
tags=tags,
cache=cache,
cache_make_key=cache_make_key,
keep_lines=keep_lines,
strict=strict,
)
while True:
for a, b, c, token_name, token_content, token_option in tokens:
if silent:
if a:
if b: # close / loop
closing_scope = scope
closing_callback = callback
scope = scopes_pop()
items, callback, silent = stack_pop()
if closing_callback and not silent: # lambda block
content = decode(token_content)
if implicit_render: # mustache lambda
value = closing_scope(content) # type: ignore
if value and isinstance(value, templ):
yield from process(
template=(
value.encode()
if isinstance(value, str) else
value
),
scope=scope,
scopes=scopes,
resolver=resolver,
getter=getter,
stringify=stringify,
escape=escape,
lambda_render=lambda_render,
tags=current_tags,
cache=cache,
cache_make_key=cache_make_key,
recursion_limit=recursion_left,
keep_lines=keep_lines,
strict=strict,
text=text,
)
elif value := stringify(value, text):
yield value
elif value := stringify( # chevron lambda
closing_scope( # type: ignore
content,
lambda_render( # type: ignore
scope=scope,
scopes=scopes,
resolver=resolver,
getter=getter,
stringify=stringify,
escape=escape,
lambda_render=lambda_render,
tags=current_tags,
cache=cache,
cache_make_key=cache_make_key,
recursion_limit=recursion_left,
keep_lines=keep_lines,
strict=strict,
),
),
text,
):
yield value
elif c: # block
scopes_append(scope)
stack_append((items, callback, silent))
elif a:
if b: # close / loop
if items and (scope := next(items, miss)) is not miss:
callback = callable(scope)
silent = callback
if start != token_option:
start = token_option
tokens = tokenized[start:]
break # restart
scope = scopes_pop()
items, callback, silent = stack_pop()
elif c: # block
curr = scope
scope = getv(curr, scopes, decode(token_name), None)
scopes_append(curr)
stack_append((items, callback, silent))
falsy = (
hasattr(scope, '__float__') and isnan(scope)
if scope else
scope in falsies or not isinstance(scope, mappings)
)
if token_option: # falsy block
items = None
callback = False
silent = not falsy
elif falsy: # truthy block with falsy value
items = None
callback = False
silent = True
elif ( # loop
hasattr(scope, '__iter__')
and not isinstance(scope, non_looping)
):
items = iter(scope)
if ( # loop item
scope := next(items, miss) # type: ignore
) is not miss:
callback = callable(scope)
silent = callback
else: # loop exhausted
items = None
scope = None
callback = False
silent = True
else: # truthy block with truthy value
items = None
callback = callable(scope)
silent = callback
elif value := resolver(decode(token_name)): # partial
content = (
value.encode() if isinstance(value, str) else
value
)
yield from process(
template=(
indent_sub(token_content, content)
if token_content else
content
),
scope=scope,
scopes=scopes,
resolver=resolver,
getter=getter,
stringify=stringify,
escape=escape,
lambda_render=lambda_render,
tags=tags,
cache=cache,
cache_make_key=cache_make_key,
recursion_limit=recursion_left,
keep_lines=keep_lines,
strict=strict,
text=text,
)
elif b:
if c: # text
yield token_content
elif ( # variable
value := getv(scope, scopes, decode(token_name), miss)
) is not miss:
if ( # lambda variable
callable(value)
and (value := value())
and isinstance(value, templ)
):
content = (
value.encode() if isinstance(value, str) else
value
)
yield from process(
template=(
content if token_option else
escape(content)
),
scope=scope,
scopes=scopes,
resolver=resolver,
getter=getter,
stringify=stringify,
escape=escape,
lambda_render=lambda_render,
tags=tags,
cache=cache,
cache_make_key=cache_make_key,
recursion_limit=recursion_left,
keep_lines=keep_lines,
strict=strict,
text=text,
)
elif value := stringify(value, text):
yield value if token_option else escape(value)
elif c: # tags
current_tags = token_name, token_content
# else comment
else:
break
[docs]
def stream(
template: TString,
scope: typing.Any,
*,
scopes: collections.abc.Iterable = (),
resolver: PartialResolver = default_resolver,
getter: PropertyGetter | PropertyGetterTuple = default_getter_tuple,
stringify: StringifyFunction = default_stringify,
escape: EscapeFunction = default_escape,
lambda_render: LambdaRenderFunctionConstructor = default_lambda_render,
tags: TagsTuple = default_tags,
cache: CompiledTemplateCache = default_cache,
cache_make_key: CacheMakeKeyFunction = default_cache_make_key,
recursion_limit: int = default_recursion_limit,
keep_lines: bool = False,
strict: bool = False,
) -> collections.abc.Generator[TString, None, None]:
"""
Generate rendered mustache template chunks.
:param template: mustache template (str or bytes)
:param scope: current rendering scope (data object)
:param scopes: list of precedent scopes
:param resolver: callable will be used to resolve partials (bytes)
:param getter: callable will be used to pick variables from scope
:param stringify: callable will be used to render python types (bytes)
:param escape: callable will be used to escape template (bytes)
:param lambda_render: explicit lambda render function constructor
:param tags: tuple (start, end) specifying the initial mustache delimiters
:param cache: mutable mapping for compiled template cache
:param cache_make_key: key function for compiled template cache
:param recursion_limit: maximum number of nested render operations
:param keep_lines: disable mustache tag-only-line collapsing
:param strict: disable compatibility workarounds
:returns: generator of bytes/str chunks (type depends on template)
:raises UnclosedSectionException: if section is left unclosed
:raises UnopenedSectionException: if closing an invalid section
:raises UnclosedTokenException: if tag is left unclosed
:raises DelimiterTokenException: if delimiter token syntax is invalid
:raises TemplateRecursionError: if rendering recursion limit is exceeded
.. versionadded:: 0.3.0
**getter** support for :attr:`mstache.PropertyGetterTuple`,
**keep_lines** to disable block-line stripping,
**strict** to disable compatability workarounds
"""
text = isinstance(template, str)
data = process(
template=template.encode() if text else template, # type: ignore
scope=scope,
scopes=scopes,
resolver=resolver,
getter=getter,
stringify=stringify,
escape=escape,
lambda_render=lambda_render,
tags=decode_tags(*tags),
cache=cache,
cache_make_key=cache_make_key,
recursion_limit=recursion_limit,
keep_lines=keep_lines,
strict=strict,
text=text,
)
return codecs.iterdecode(data, 'utf8') if text else data # type: ignore
[docs]
def render(
template: TString,
scope: typing.Any,
*,
scopes: collections.abc.Iterable = (),
resolver: PartialResolver = default_resolver,
getter: PropertyGetter | PropertyGetterTuple = default_getter_tuple,
stringify: StringifyFunction = default_stringify,
escape: EscapeFunction = default_escape,
lambda_render: LambdaRenderFunctionConstructor = default_lambda_render,
tags: TagsTuple = default_tags,
cache: CompiledTemplateCache = default_cache,
cache_make_key: CacheMakeKeyFunction = default_cache_make_key,
recursion_limit: int = default_recursion_limit,
keep_lines: bool = False,
strict: bool = False,
) -> TString:
"""
Render mustache template.
:param template: mustache template
:param scope: current rendering scope (data object)
:param scopes: list of precedent scopes
:param resolver: callable will be used to resolve partials (bytes)
:param getter: callable will be used to pick variables from scope
:param stringify: callable will be used to render python types (bytes)
:param escape: callable will be used to escape template (bytes)
:param lambda_render: explicit lambda render function constructor
:param tags: tuple (start, end) specifying the initial mustache delimiters
:param cache: mutable mapping for compiled template cache
:param cache_make_key: key function for compiled template cache
:param recursion_limit: maximum number of nested render operations
:param keep_lines: disable mustache tag-only-line collapsing
:param strict: disable compatibility workarounds
:returns: rendered bytes/str (type depends on template)
:raises UnclosedSectionException: if section is left unclosed
:raises UnopenedSectionException: if closing an invalid section
:raises UnclosedTokenException: if tag is left unclosed
:raises DelimiterTokenException: if delimiter token syntax is invalid
:raises TemplateRecursionError: if rendering recursion limit is exceeded
.. versionadded:: 0.3.0
**getter** support for :attr:`mstache.PropertyGetterTuple`,
**keep_lines** to disable tag-only-line collapsing,
**strict** to disable compatability workarounds
"""
text = isinstance(template, str)
data = b''.join(process(
template=template.encode() if text else template, # type: ignore
scope=scope,
scopes=scopes,
resolver=resolver,
getter=getter,
stringify=stringify,
escape=escape,
lambda_render=lambda_render,
tags=decode_tags(*tags),
cache=cache,
cache_make_key=cache_make_key,
recursion_limit=recursion_limit,
keep_lines=keep_lines,
strict=strict,
text=text,
))
return data.decode() if text else data # type: ignore
[docs]
def cli(argv: collections.abc.Sequence[str] | None = None) -> None:
"""
Render template from command line.
Use ``python -m mstache --help`` to check available options.
:param argv: command line arguments, :attr:`sys.argv` when None
"""
import argparse
import json
import sys
arguments = argparse.ArgumentParser(
description='Render mustache template.',
)
arguments.add_argument(
'template',
metavar='PATH',
type=argparse.FileType('r'),
help='template file',
)
arguments.add_argument(
'-j', '--json',
metavar='PATH',
type=argparse.FileType('r'),
default=sys.stdin,
help='JSON file, default: stdin',
)
arguments.add_argument(
'-o', '--output',
metavar='PATH',
type=argparse.FileType('w'),
default=sys.stdout,
help='output file, default: stdout',
)
arguments.add_argument(
'-k', '--keep-lines',
action='store_true',
help='disable mustache tag-only-line collapsing',
)
args = arguments.parse_args(argv)
try:
args.output.write(render(
args.template.read(),
json.load(args.json),
keep_lines=args.keep_lines,
))
finally:
args.template.close()
for fd, std in ((args.json, sys.stdin), (args.output, sys.stdout)):
if fd is not std:
fd.close()
if __name__ == '__main__':
cli()