forked from bton/matekasse
867 lines
29 KiB
Python
867 lines
29 KiB
Python
|
"""Implements a Jinja / Python combination lexer. The ``Lexer`` class
|
||
|
is used to do some preprocessing. It filters out invalid operators like
|
||
|
the bitshift operators we don't allow in templates. It separates
|
||
|
template code and python code in expressions.
|
||
|
"""
|
||
|
import re
|
||
|
import typing as t
|
||
|
from ast import literal_eval
|
||
|
from collections import deque
|
||
|
from sys import intern
|
||
|
|
||
|
from ._identifier import pattern as name_re
|
||
|
from .exceptions import TemplateSyntaxError
|
||
|
from .utils import LRUCache
|
||
|
|
||
|
if t.TYPE_CHECKING:
|
||
|
import typing_extensions as te
|
||
|
from .environment import Environment
|
||
|
|
||
|
# cache for the lexers. Exists in order to be able to have multiple
|
||
|
# environments with the same lexer
|
||
|
_lexer_cache: t.MutableMapping[t.Tuple, "Lexer"] = LRUCache(50) # type: ignore
|
||
|
|
||
|
# static regular expressions
|
||
|
whitespace_re = re.compile(r"\s+")
|
||
|
newline_re = re.compile(r"(\r\n|\r|\n)")
|
||
|
string_re = re.compile(
|
||
|
r"('([^'\\]*(?:\\.[^'\\]*)*)'" r'|"([^"\\]*(?:\\.[^"\\]*)*)")', re.S
|
||
|
)
|
||
|
integer_re = re.compile(
|
||
|
r"""
|
||
|
(
|
||
|
0b(_?[0-1])+ # binary
|
||
|
|
|
||
|
0o(_?[0-7])+ # octal
|
||
|
|
|
||
|
0x(_?[\da-f])+ # hex
|
||
|
|
|
||
|
[1-9](_?\d)* # decimal
|
||
|
|
|
||
|
0(_?0)* # decimal zero
|
||
|
)
|
||
|
""",
|
||
|
re.IGNORECASE | re.VERBOSE,
|
||
|
)
|
||
|
float_re = re.compile(
|
||
|
r"""
|
||
|
(?<!\.) # doesn't start with a .
|
||
|
(\d+_)*\d+ # digits, possibly _ separated
|
||
|
(
|
||
|
(\.(\d+_)*\d+)? # optional fractional part
|
||
|
e[+\-]?(\d+_)*\d+ # exponent part
|
||
|
|
|
||
|
\.(\d+_)*\d+ # required fractional part
|
||
|
)
|
||
|
""",
|
||
|
re.IGNORECASE | re.VERBOSE,
|
||
|
)
|
||
|
|
||
|
# internal the tokens and keep references to them
|
||
|
TOKEN_ADD = intern("add")
|
||
|
TOKEN_ASSIGN = intern("assign")
|
||
|
TOKEN_COLON = intern("colon")
|
||
|
TOKEN_COMMA = intern("comma")
|
||
|
TOKEN_DIV = intern("div")
|
||
|
TOKEN_DOT = intern("dot")
|
||
|
TOKEN_EQ = intern("eq")
|
||
|
TOKEN_FLOORDIV = intern("floordiv")
|
||
|
TOKEN_GT = intern("gt")
|
||
|
TOKEN_GTEQ = intern("gteq")
|
||
|
TOKEN_LBRACE = intern("lbrace")
|
||
|
TOKEN_LBRACKET = intern("lbracket")
|
||
|
TOKEN_LPAREN = intern("lparen")
|
||
|
TOKEN_LT = intern("lt")
|
||
|
TOKEN_LTEQ = intern("lteq")
|
||
|
TOKEN_MOD = intern("mod")
|
||
|
TOKEN_MUL = intern("mul")
|
||
|
TOKEN_NE = intern("ne")
|
||
|
TOKEN_PIPE = intern("pipe")
|
||
|
TOKEN_POW = intern("pow")
|
||
|
TOKEN_RBRACE = intern("rbrace")
|
||
|
TOKEN_RBRACKET = intern("rbracket")
|
||
|
TOKEN_RPAREN = intern("rparen")
|
||
|
TOKEN_SEMICOLON = intern("semicolon")
|
||
|
TOKEN_SUB = intern("sub")
|
||
|
TOKEN_TILDE = intern("tilde")
|
||
|
TOKEN_WHITESPACE = intern("whitespace")
|
||
|
TOKEN_FLOAT = intern("float")
|
||
|
TOKEN_INTEGER = intern("integer")
|
||
|
TOKEN_NAME = intern("name")
|
||
|
TOKEN_STRING = intern("string")
|
||
|
TOKEN_OPERATOR = intern("operator")
|
||
|
TOKEN_BLOCK_BEGIN = intern("block_begin")
|
||
|
TOKEN_BLOCK_END = intern("block_end")
|
||
|
TOKEN_VARIABLE_BEGIN = intern("variable_begin")
|
||
|
TOKEN_VARIABLE_END = intern("variable_end")
|
||
|
TOKEN_RAW_BEGIN = intern("raw_begin")
|
||
|
TOKEN_RAW_END = intern("raw_end")
|
||
|
TOKEN_COMMENT_BEGIN = intern("comment_begin")
|
||
|
TOKEN_COMMENT_END = intern("comment_end")
|
||
|
TOKEN_COMMENT = intern("comment")
|
||
|
TOKEN_LINESTATEMENT_BEGIN = intern("linestatement_begin")
|
||
|
TOKEN_LINESTATEMENT_END = intern("linestatement_end")
|
||
|
TOKEN_LINECOMMENT_BEGIN = intern("linecomment_begin")
|
||
|
TOKEN_LINECOMMENT_END = intern("linecomment_end")
|
||
|
TOKEN_LINECOMMENT = intern("linecomment")
|
||
|
TOKEN_DATA = intern("data")
|
||
|
TOKEN_INITIAL = intern("initial")
|
||
|
TOKEN_EOF = intern("eof")
|
||
|
|
||
|
# bind operators to token types
|
||
|
operators = {
|
||
|
"+": TOKEN_ADD,
|
||
|
"-": TOKEN_SUB,
|
||
|
"/": TOKEN_DIV,
|
||
|
"//": TOKEN_FLOORDIV,
|
||
|
"*": TOKEN_MUL,
|
||
|
"%": TOKEN_MOD,
|
||
|
"**": TOKEN_POW,
|
||
|
"~": TOKEN_TILDE,
|
||
|
"[": TOKEN_LBRACKET,
|
||
|
"]": TOKEN_RBRACKET,
|
||
|
"(": TOKEN_LPAREN,
|
||
|
")": TOKEN_RPAREN,
|
||
|
"{": TOKEN_LBRACE,
|
||
|
"}": TOKEN_RBRACE,
|
||
|
"==": TOKEN_EQ,
|
||
|
"!=": TOKEN_NE,
|
||
|
">": TOKEN_GT,
|
||
|
">=": TOKEN_GTEQ,
|
||
|
"<": TOKEN_LT,
|
||
|
"<=": TOKEN_LTEQ,
|
||
|
"=": TOKEN_ASSIGN,
|
||
|
".": TOKEN_DOT,
|
||
|
":": TOKEN_COLON,
|
||
|
"|": TOKEN_PIPE,
|
||
|
",": TOKEN_COMMA,
|
||
|
";": TOKEN_SEMICOLON,
|
||
|
}
|
||
|
|
||
|
reverse_operators = {v: k for k, v in operators.items()}
|
||
|
assert len(operators) == len(reverse_operators), "operators dropped"
|
||
|
operator_re = re.compile(
|
||
|
f"({'|'.join(re.escape(x) for x in sorted(operators, key=lambda x: -len(x)))})"
|
||
|
)
|
||
|
|
||
|
ignored_tokens = frozenset(
|
||
|
[
|
||
|
TOKEN_COMMENT_BEGIN,
|
||
|
TOKEN_COMMENT,
|
||
|
TOKEN_COMMENT_END,
|
||
|
TOKEN_WHITESPACE,
|
||
|
TOKEN_LINECOMMENT_BEGIN,
|
||
|
TOKEN_LINECOMMENT_END,
|
||
|
TOKEN_LINECOMMENT,
|
||
|
]
|
||
|
)
|
||
|
ignore_if_empty = frozenset(
|
||
|
[TOKEN_WHITESPACE, TOKEN_DATA, TOKEN_COMMENT, TOKEN_LINECOMMENT]
|
||
|
)
|
||
|
|
||
|
|
||
|
def _describe_token_type(token_type: str) -> str:
|
||
|
if token_type in reverse_operators:
|
||
|
return reverse_operators[token_type]
|
||
|
|
||
|
return {
|
||
|
TOKEN_COMMENT_BEGIN: "begin of comment",
|
||
|
TOKEN_COMMENT_END: "end of comment",
|
||
|
TOKEN_COMMENT: "comment",
|
||
|
TOKEN_LINECOMMENT: "comment",
|
||
|
TOKEN_BLOCK_BEGIN: "begin of statement block",
|
||
|
TOKEN_BLOCK_END: "end of statement block",
|
||
|
TOKEN_VARIABLE_BEGIN: "begin of print statement",
|
||
|
TOKEN_VARIABLE_END: "end of print statement",
|
||
|
TOKEN_LINESTATEMENT_BEGIN: "begin of line statement",
|
||
|
TOKEN_LINESTATEMENT_END: "end of line statement",
|
||
|
TOKEN_DATA: "template data / text",
|
||
|
TOKEN_EOF: "end of template",
|
||
|
}.get(token_type, token_type)
|
||
|
|
||
|
|
||
|
def describe_token(token: "Token") -> str:
|
||
|
"""Returns a description of the token."""
|
||
|
if token.type == TOKEN_NAME:
|
||
|
return token.value
|
||
|
|
||
|
return _describe_token_type(token.type)
|
||
|
|
||
|
|
||
|
def describe_token_expr(expr: str) -> str:
|
||
|
"""Like `describe_token` but for token expressions."""
|
||
|
if ":" in expr:
|
||
|
type, value = expr.split(":", 1)
|
||
|
|
||
|
if type == TOKEN_NAME:
|
||
|
return value
|
||
|
else:
|
||
|
type = expr
|
||
|
|
||
|
return _describe_token_type(type)
|
||
|
|
||
|
|
||
|
def count_newlines(value: str) -> int:
|
||
|
"""Count the number of newline characters in the string. This is
|
||
|
useful for extensions that filter a stream.
|
||
|
"""
|
||
|
return len(newline_re.findall(value))
|
||
|
|
||
|
|
||
|
def compile_rules(environment: "Environment") -> t.List[t.Tuple[str, str]]:
|
||
|
"""Compiles all the rules from the environment into a list of rules."""
|
||
|
e = re.escape
|
||
|
rules = [
|
||
|
(
|
||
|
len(environment.comment_start_string),
|
||
|
TOKEN_COMMENT_BEGIN,
|
||
|
e(environment.comment_start_string),
|
||
|
),
|
||
|
(
|
||
|
len(environment.block_start_string),
|
||
|
TOKEN_BLOCK_BEGIN,
|
||
|
e(environment.block_start_string),
|
||
|
),
|
||
|
(
|
||
|
len(environment.variable_start_string),
|
||
|
TOKEN_VARIABLE_BEGIN,
|
||
|
e(environment.variable_start_string),
|
||
|
),
|
||
|
]
|
||
|
|
||
|
if environment.line_statement_prefix is not None:
|
||
|
rules.append(
|
||
|
(
|
||
|
len(environment.line_statement_prefix),
|
||
|
TOKEN_LINESTATEMENT_BEGIN,
|
||
|
r"^[ \t\v]*" + e(environment.line_statement_prefix),
|
||
|
)
|
||
|
)
|
||
|
if environment.line_comment_prefix is not None:
|
||
|
rules.append(
|
||
|
(
|
||
|
len(environment.line_comment_prefix),
|
||
|
TOKEN_LINECOMMENT_BEGIN,
|
||
|
r"(?:^|(?<=\S))[^\S\r\n]*" + e(environment.line_comment_prefix),
|
||
|
)
|
||
|
)
|
||
|
|
||
|
return [x[1:] for x in sorted(rules, reverse=True)]
|
||
|
|
||
|
|
||
|
class Failure:
|
||
|
"""Class that raises a `TemplateSyntaxError` if called.
|
||
|
Used by the `Lexer` to specify known errors.
|
||
|
"""
|
||
|
|
||
|
def __init__(
|
||
|
self, message: str, cls: t.Type[TemplateSyntaxError] = TemplateSyntaxError
|
||
|
) -> None:
|
||
|
self.message = message
|
||
|
self.error_class = cls
|
||
|
|
||
|
def __call__(self, lineno: int, filename: str) -> "te.NoReturn":
|
||
|
raise self.error_class(self.message, lineno, filename)
|
||
|
|
||
|
|
||
|
class Token(t.NamedTuple):
|
||
|
lineno: int
|
||
|
type: str
|
||
|
value: str
|
||
|
|
||
|
def __str__(self) -> str:
|
||
|
return describe_token(self)
|
||
|
|
||
|
def test(self, expr: str) -> bool:
|
||
|
"""Test a token against a token expression. This can either be a
|
||
|
token type or ``'token_type:token_value'``. This can only test
|
||
|
against string values and types.
|
||
|
"""
|
||
|
# here we do a regular string equality check as test_any is usually
|
||
|
# passed an iterable of not interned strings.
|
||
|
if self.type == expr:
|
||
|
return True
|
||
|
|
||
|
if ":" in expr:
|
||
|
return expr.split(":", 1) == [self.type, self.value]
|
||
|
|
||
|
return False
|
||
|
|
||
|
def test_any(self, *iterable: str) -> bool:
|
||
|
"""Test against multiple token expressions."""
|
||
|
return any(self.test(expr) for expr in iterable)
|
||
|
|
||
|
|
||
|
class TokenStreamIterator:
|
||
|
"""The iterator for tokenstreams. Iterate over the stream
|
||
|
until the eof token is reached.
|
||
|
"""
|
||
|
|
||
|
def __init__(self, stream: "TokenStream") -> None:
|
||
|
self.stream = stream
|
||
|
|
||
|
def __iter__(self) -> "TokenStreamIterator":
|
||
|
return self
|
||
|
|
||
|
def __next__(self) -> Token:
|
||
|
token = self.stream.current
|
||
|
|
||
|
if token.type is TOKEN_EOF:
|
||
|
self.stream.close()
|
||
|
raise StopIteration
|
||
|
|
||
|
next(self.stream)
|
||
|
return token
|
||
|
|
||
|
|
||
|
class TokenStream:
|
||
|
"""A token stream is an iterable that yields :class:`Token`\\s. The
|
||
|
parser however does not iterate over it but calls :meth:`next` to go
|
||
|
one token ahead. The current active token is stored as :attr:`current`.
|
||
|
"""
|
||
|
|
||
|
def __init__(
|
||
|
self,
|
||
|
generator: t.Iterable[Token],
|
||
|
name: t.Optional[str],
|
||
|
filename: t.Optional[str],
|
||
|
):
|
||
|
self._iter = iter(generator)
|
||
|
self._pushed: "te.Deque[Token]" = deque()
|
||
|
self.name = name
|
||
|
self.filename = filename
|
||
|
self.closed = False
|
||
|
self.current = Token(1, TOKEN_INITIAL, "")
|
||
|
next(self)
|
||
|
|
||
|
def __iter__(self) -> TokenStreamIterator:
|
||
|
return TokenStreamIterator(self)
|
||
|
|
||
|
def __bool__(self) -> bool:
|
||
|
return bool(self._pushed) or self.current.type is not TOKEN_EOF
|
||
|
|
||
|
@property
|
||
|
def eos(self) -> bool:
|
||
|
"""Are we at the end of the stream?"""
|
||
|
return not self
|
||
|
|
||
|
def push(self, token: Token) -> None:
|
||
|
"""Push a token back to the stream."""
|
||
|
self._pushed.append(token)
|
||
|
|
||
|
def look(self) -> Token:
|
||
|
"""Look at the next token."""
|
||
|
old_token = next(self)
|
||
|
result = self.current
|
||
|
self.push(result)
|
||
|
self.current = old_token
|
||
|
return result
|
||
|
|
||
|
def skip(self, n: int = 1) -> None:
|
||
|
"""Got n tokens ahead."""
|
||
|
for _ in range(n):
|
||
|
next(self)
|
||
|
|
||
|
def next_if(self, expr: str) -> t.Optional[Token]:
|
||
|
"""Perform the token test and return the token if it matched.
|
||
|
Otherwise the return value is `None`.
|
||
|
"""
|
||
|
if self.current.test(expr):
|
||
|
return next(self)
|
||
|
|
||
|
return None
|
||
|
|
||
|
def skip_if(self, expr: str) -> bool:
|
||
|
"""Like :meth:`next_if` but only returns `True` or `False`."""
|
||
|
return self.next_if(expr) is not None
|
||
|
|
||
|
def __next__(self) -> Token:
|
||
|
"""Go one token ahead and return the old one.
|
||
|
|
||
|
Use the built-in :func:`next` instead of calling this directly.
|
||
|
"""
|
||
|
rv = self.current
|
||
|
|
||
|
if self._pushed:
|
||
|
self.current = self._pushed.popleft()
|
||
|
elif self.current.type is not TOKEN_EOF:
|
||
|
try:
|
||
|
self.current = next(self._iter)
|
||
|
except StopIteration:
|
||
|
self.close()
|
||
|
|
||
|
return rv
|
||
|
|
||
|
def close(self) -> None:
|
||
|
"""Close the stream."""
|
||
|
self.current = Token(self.current.lineno, TOKEN_EOF, "")
|
||
|
self._iter = iter(())
|
||
|
self.closed = True
|
||
|
|
||
|
def expect(self, expr: str) -> Token:
|
||
|
"""Expect a given token type and return it. This accepts the same
|
||
|
argument as :meth:`jinja2.lexer.Token.test`.
|
||
|
"""
|
||
|
if not self.current.test(expr):
|
||
|
expr = describe_token_expr(expr)
|
||
|
|
||
|
if self.current.type is TOKEN_EOF:
|
||
|
raise TemplateSyntaxError(
|
||
|
f"unexpected end of template, expected {expr!r}.",
|
||
|
self.current.lineno,
|
||
|
self.name,
|
||
|
self.filename,
|
||
|
)
|
||
|
|
||
|
raise TemplateSyntaxError(
|
||
|
f"expected token {expr!r}, got {describe_token(self.current)!r}",
|
||
|
self.current.lineno,
|
||
|
self.name,
|
||
|
self.filename,
|
||
|
)
|
||
|
|
||
|
return next(self)
|
||
|
|
||
|
|
||
|
def get_lexer(environment: "Environment") -> "Lexer":
|
||
|
"""Return a lexer which is probably cached."""
|
||
|
key = (
|
||
|
environment.block_start_string,
|
||
|
environment.block_end_string,
|
||
|
environment.variable_start_string,
|
||
|
environment.variable_end_string,
|
||
|
environment.comment_start_string,
|
||
|
environment.comment_end_string,
|
||
|
environment.line_statement_prefix,
|
||
|
environment.line_comment_prefix,
|
||
|
environment.trim_blocks,
|
||
|
environment.lstrip_blocks,
|
||
|
environment.newline_sequence,
|
||
|
environment.keep_trailing_newline,
|
||
|
)
|
||
|
lexer = _lexer_cache.get(key)
|
||
|
|
||
|
if lexer is None:
|
||
|
_lexer_cache[key] = lexer = Lexer(environment)
|
||
|
|
||
|
return lexer
|
||
|
|
||
|
|
||
|
class OptionalLStrip(tuple):
|
||
|
"""A special tuple for marking a point in the state that can have
|
||
|
lstrip applied.
|
||
|
"""
|
||
|
|
||
|
__slots__ = ()
|
||
|
|
||
|
# Even though it looks like a no-op, creating instances fails
|
||
|
# without this.
|
||
|
def __new__(cls, *members, **kwargs): # type: ignore
|
||
|
return super().__new__(cls, members)
|
||
|
|
||
|
|
||
|
class _Rule(t.NamedTuple):
|
||
|
pattern: t.Pattern[str]
|
||
|
tokens: t.Union[str, t.Tuple[str, ...], t.Tuple[Failure]]
|
||
|
command: t.Optional[str]
|
||
|
|
||
|
|
||
|
class Lexer:
|
||
|
"""Class that implements a lexer for a given environment. Automatically
|
||
|
created by the environment class, usually you don't have to do that.
|
||
|
|
||
|
Note that the lexer is not automatically bound to an environment.
|
||
|
Multiple environments can share the same lexer.
|
||
|
"""
|
||
|
|
||
|
def __init__(self, environment: "Environment") -> None:
|
||
|
# shortcuts
|
||
|
e = re.escape
|
||
|
|
||
|
def c(x: str) -> t.Pattern[str]:
|
||
|
return re.compile(x, re.M | re.S)
|
||
|
|
||
|
# lexing rules for tags
|
||
|
tag_rules: t.List[_Rule] = [
|
||
|
_Rule(whitespace_re, TOKEN_WHITESPACE, None),
|
||
|
_Rule(float_re, TOKEN_FLOAT, None),
|
||
|
_Rule(integer_re, TOKEN_INTEGER, None),
|
||
|
_Rule(name_re, TOKEN_NAME, None),
|
||
|
_Rule(string_re, TOKEN_STRING, None),
|
||
|
_Rule(operator_re, TOKEN_OPERATOR, None),
|
||
|
]
|
||
|
|
||
|
# assemble the root lexing rule. because "|" is ungreedy
|
||
|
# we have to sort by length so that the lexer continues working
|
||
|
# as expected when we have parsing rules like <% for block and
|
||
|
# <%= for variables. (if someone wants asp like syntax)
|
||
|
# variables are just part of the rules if variable processing
|
||
|
# is required.
|
||
|
root_tag_rules = compile_rules(environment)
|
||
|
|
||
|
block_start_re = e(environment.block_start_string)
|
||
|
block_end_re = e(environment.block_end_string)
|
||
|
comment_end_re = e(environment.comment_end_string)
|
||
|
variable_end_re = e(environment.variable_end_string)
|
||
|
|
||
|
# block suffix if trimming is enabled
|
||
|
block_suffix_re = "\\n?" if environment.trim_blocks else ""
|
||
|
|
||
|
self.lstrip_blocks = environment.lstrip_blocks
|
||
|
|
||
|
self.newline_sequence = environment.newline_sequence
|
||
|
self.keep_trailing_newline = environment.keep_trailing_newline
|
||
|
|
||
|
root_raw_re = (
|
||
|
rf"(?P<raw_begin>{block_start_re}(\-|\+|)\s*raw\s*"
|
||
|
rf"(?:\-{block_end_re}\s*|{block_end_re}))"
|
||
|
)
|
||
|
root_parts_re = "|".join(
|
||
|
[root_raw_re] + [rf"(?P<{n}>{r}(\-|\+|))" for n, r in root_tag_rules]
|
||
|
)
|
||
|
|
||
|
# global lexing rules
|
||
|
self.rules: t.Dict[str, t.List[_Rule]] = {
|
||
|
"root": [
|
||
|
# directives
|
||
|
_Rule(
|
||
|
c(rf"(.*?)(?:{root_parts_re})"),
|
||
|
OptionalLStrip(TOKEN_DATA, "#bygroup"), # type: ignore
|
||
|
"#bygroup",
|
||
|
),
|
||
|
# data
|
||
|
_Rule(c(".+"), TOKEN_DATA, None),
|
||
|
],
|
||
|
# comments
|
||
|
TOKEN_COMMENT_BEGIN: [
|
||
|
_Rule(
|
||
|
c(
|
||
|
rf"(.*?)((?:\+{comment_end_re}|\-{comment_end_re}\s*"
|
||
|
rf"|{comment_end_re}{block_suffix_re}))"
|
||
|
),
|
||
|
(TOKEN_COMMENT, TOKEN_COMMENT_END),
|
||
|
"#pop",
|
||
|
),
|
||
|
_Rule(c(r"(.)"), (Failure("Missing end of comment tag"),), None),
|
||
|
],
|
||
|
# blocks
|
||
|
TOKEN_BLOCK_BEGIN: [
|
||
|
_Rule(
|
||
|
c(
|
||
|
rf"(?:\+{block_end_re}|\-{block_end_re}\s*"
|
||
|
rf"|{block_end_re}{block_suffix_re})"
|
||
|
),
|
||
|
TOKEN_BLOCK_END,
|
||
|
"#pop",
|
||
|
),
|
||
|
]
|
||
|
+ tag_rules,
|
||
|
# variables
|
||
|
TOKEN_VARIABLE_BEGIN: [
|
||
|
_Rule(
|
||
|
c(rf"\-{variable_end_re}\s*|{variable_end_re}"),
|
||
|
TOKEN_VARIABLE_END,
|
||
|
"#pop",
|
||
|
)
|
||
|
]
|
||
|
+ tag_rules,
|
||
|
# raw block
|
||
|
TOKEN_RAW_BEGIN: [
|
||
|
_Rule(
|
||
|
c(
|
||
|
rf"(.*?)((?:{block_start_re}(\-|\+|))\s*endraw\s*"
|
||
|
rf"(?:\+{block_end_re}|\-{block_end_re}\s*"
|
||
|
rf"|{block_end_re}{block_suffix_re}))"
|
||
|
),
|
||
|
OptionalLStrip(TOKEN_DATA, TOKEN_RAW_END), # type: ignore
|
||
|
"#pop",
|
||
|
),
|
||
|
_Rule(c(r"(.)"), (Failure("Missing end of raw directive"),), None),
|
||
|
],
|
||
|
# line statements
|
||
|
TOKEN_LINESTATEMENT_BEGIN: [
|
||
|
_Rule(c(r"\s*(\n|$)"), TOKEN_LINESTATEMENT_END, "#pop")
|
||
|
]
|
||
|
+ tag_rules,
|
||
|
# line comments
|
||
|
TOKEN_LINECOMMENT_BEGIN: [
|
||
|
_Rule(
|
||
|
c(r"(.*?)()(?=\n|$)"),
|
||
|
(TOKEN_LINECOMMENT, TOKEN_LINECOMMENT_END),
|
||
|
"#pop",
|
||
|
)
|
||
|
],
|
||
|
}
|
||
|
|
||
|
def _normalize_newlines(self, value: str) -> str:
|
||
|
"""Replace all newlines with the configured sequence in strings
|
||
|
and template data.
|
||
|
"""
|
||
|
return newline_re.sub(self.newline_sequence, value)
|
||
|
|
||
|
def tokenize(
|
||
|
self,
|
||
|
source: str,
|
||
|
name: t.Optional[str] = None,
|
||
|
filename: t.Optional[str] = None,
|
||
|
state: t.Optional[str] = None,
|
||
|
) -> TokenStream:
|
||
|
"""Calls tokeniter + tokenize and wraps it in a token stream."""
|
||
|
stream = self.tokeniter(source, name, filename, state)
|
||
|
return TokenStream(self.wrap(stream, name, filename), name, filename)
|
||
|
|
||
|
def wrap(
|
||
|
self,
|
||
|
stream: t.Iterable[t.Tuple[int, str, str]],
|
||
|
name: t.Optional[str] = None,
|
||
|
filename: t.Optional[str] = None,
|
||
|
) -> t.Iterator[Token]:
|
||
|
"""This is called with the stream as returned by `tokenize` and wraps
|
||
|
every token in a :class:`Token` and converts the value.
|
||
|
"""
|
||
|
for lineno, token, value_str in stream:
|
||
|
if token in ignored_tokens:
|
||
|
continue
|
||
|
|
||
|
value: t.Any = value_str
|
||
|
|
||
|
if token == TOKEN_LINESTATEMENT_BEGIN:
|
||
|
token = TOKEN_BLOCK_BEGIN
|
||
|
elif token == TOKEN_LINESTATEMENT_END:
|
||
|
token = TOKEN_BLOCK_END
|
||
|
# we are not interested in those tokens in the parser
|
||
|
elif token in (TOKEN_RAW_BEGIN, TOKEN_RAW_END):
|
||
|
continue
|
||
|
elif token == TOKEN_DATA:
|
||
|
value = self._normalize_newlines(value_str)
|
||
|
elif token == "keyword":
|
||
|
token = value_str
|
||
|
elif token == TOKEN_NAME:
|
||
|
value = value_str
|
||
|
|
||
|
if not value.isidentifier():
|
||
|
raise TemplateSyntaxError(
|
||
|
"Invalid character in identifier", lineno, name, filename
|
||
|
)
|
||
|
elif token == TOKEN_STRING:
|
||
|
# try to unescape string
|
||
|
try:
|
||
|
value = (
|
||
|
self._normalize_newlines(value_str[1:-1])
|
||
|
.encode("ascii", "backslashreplace")
|
||
|
.decode("unicode-escape")
|
||
|
)
|
||
|
except Exception as e:
|
||
|
msg = str(e).split(":")[-1].strip()
|
||
|
raise TemplateSyntaxError(msg, lineno, name, filename) from e
|
||
|
elif token == TOKEN_INTEGER:
|
||
|
value = int(value_str.replace("_", ""), 0)
|
||
|
elif token == TOKEN_FLOAT:
|
||
|
# remove all "_" first to support more Python versions
|
||
|
value = literal_eval(value_str.replace("_", ""))
|
||
|
elif token == TOKEN_OPERATOR:
|
||
|
token = operators[value_str]
|
||
|
|
||
|
yield Token(lineno, token, value)
|
||
|
|
||
|
def tokeniter(
|
||
|
self,
|
||
|
source: str,
|
||
|
name: t.Optional[str],
|
||
|
filename: t.Optional[str] = None,
|
||
|
state: t.Optional[str] = None,
|
||
|
) -> t.Iterator[t.Tuple[int, str, str]]:
|
||
|
"""This method tokenizes the text and returns the tokens in a
|
||
|
generator. Use this method if you just want to tokenize a template.
|
||
|
|
||
|
.. versionchanged:: 3.0
|
||
|
Only ``\\n``, ``\\r\\n`` and ``\\r`` are treated as line
|
||
|
breaks.
|
||
|
"""
|
||
|
lines = newline_re.split(source)[::2]
|
||
|
|
||
|
if not self.keep_trailing_newline and lines[-1] == "":
|
||
|
del lines[-1]
|
||
|
|
||
|
source = "\n".join(lines)
|
||
|
pos = 0
|
||
|
lineno = 1
|
||
|
stack = ["root"]
|
||
|
|
||
|
if state is not None and state != "root":
|
||
|
assert state in ("variable", "block"), "invalid state"
|
||
|
stack.append(state + "_begin")
|
||
|
|
||
|
statetokens = self.rules[stack[-1]]
|
||
|
source_length = len(source)
|
||
|
balancing_stack: t.List[str] = []
|
||
|
newlines_stripped = 0
|
||
|
line_starting = True
|
||
|
|
||
|
while True:
|
||
|
# tokenizer loop
|
||
|
for regex, tokens, new_state in statetokens:
|
||
|
m = regex.match(source, pos)
|
||
|
|
||
|
# if no match we try again with the next rule
|
||
|
if m is None:
|
||
|
continue
|
||
|
|
||
|
# we only match blocks and variables if braces / parentheses
|
||
|
# are balanced. continue parsing with the lower rule which
|
||
|
# is the operator rule. do this only if the end tags look
|
||
|
# like operators
|
||
|
if balancing_stack and tokens in (
|
||
|
TOKEN_VARIABLE_END,
|
||
|
TOKEN_BLOCK_END,
|
||
|
TOKEN_LINESTATEMENT_END,
|
||
|
):
|
||
|
continue
|
||
|
|
||
|
# tuples support more options
|
||
|
if isinstance(tokens, tuple):
|
||
|
groups: t.Sequence[str] = m.groups()
|
||
|
|
||
|
if isinstance(tokens, OptionalLStrip):
|
||
|
# Rule supports lstrip. Match will look like
|
||
|
# text, block type, whitespace control, type, control, ...
|
||
|
text = groups[0]
|
||
|
# Skipping the text and first type, every other group is the
|
||
|
# whitespace control for each type. One of the groups will be
|
||
|
# -, +, or empty string instead of None.
|
||
|
strip_sign = next(g for g in groups[2::2] if g is not None)
|
||
|
|
||
|
if strip_sign == "-":
|
||
|
# Strip all whitespace between the text and the tag.
|
||
|
stripped = text.rstrip()
|
||
|
newlines_stripped = text[len(stripped) :].count("\n")
|
||
|
groups = [stripped, *groups[1:]]
|
||
|
elif (
|
||
|
# Not marked for preserving whitespace.
|
||
|
strip_sign != "+"
|
||
|
# lstrip is enabled.
|
||
|
and self.lstrip_blocks
|
||
|
# Not a variable expression.
|
||
|
and not m.groupdict().get(TOKEN_VARIABLE_BEGIN)
|
||
|
):
|
||
|
# The start of text between the last newline and the tag.
|
||
|
l_pos = text.rfind("\n") + 1
|
||
|
|
||
|
if l_pos > 0 or line_starting:
|
||
|
# If there's only whitespace between the newline and the
|
||
|
# tag, strip it.
|
||
|
if whitespace_re.fullmatch(text, l_pos):
|
||
|
groups = [text[:l_pos], *groups[1:]]
|
||
|
|
||
|
for idx, token in enumerate(tokens):
|
||
|
# failure group
|
||
|
if token.__class__ is Failure:
|
||
|
raise token(lineno, filename)
|
||
|
# bygroup is a bit more complex, in that case we
|
||
|
# yield for the current token the first named
|
||
|
# group that matched
|
||
|
elif token == "#bygroup":
|
||
|
for key, value in m.groupdict().items():
|
||
|
if value is not None:
|
||
|
yield lineno, key, value
|
||
|
lineno += value.count("\n")
|
||
|
break
|
||
|
else:
|
||
|
raise RuntimeError(
|
||
|
f"{regex!r} wanted to resolve the token dynamically"
|
||
|
" but no group matched"
|
||
|
)
|
||
|
# normal group
|
||
|
else:
|
||
|
data = groups[idx]
|
||
|
|
||
|
if data or token not in ignore_if_empty:
|
||
|
yield lineno, token, data
|
||
|
|
||
|
lineno += data.count("\n") + newlines_stripped
|
||
|
newlines_stripped = 0
|
||
|
|
||
|
# strings as token just are yielded as it.
|
||
|
else:
|
||
|
data = m.group()
|
||
|
|
||
|
# update brace/parentheses balance
|
||
|
if tokens == TOKEN_OPERATOR:
|
||
|
if data == "{":
|
||
|
balancing_stack.append("}")
|
||
|
elif data == "(":
|
||
|
balancing_stack.append(")")
|
||
|
elif data == "[":
|
||
|
balancing_stack.append("]")
|
||
|
elif data in ("}", ")", "]"):
|
||
|
if not balancing_stack:
|
||
|
raise TemplateSyntaxError(
|
||
|
f"unexpected '{data}'", lineno, name, filename
|
||
|
)
|
||
|
|
||
|
expected_op = balancing_stack.pop()
|
||
|
|
||
|
if expected_op != data:
|
||
|
raise TemplateSyntaxError(
|
||
|
f"unexpected '{data}', expected '{expected_op}'",
|
||
|
lineno,
|
||
|
name,
|
||
|
filename,
|
||
|
)
|
||
|
|
||
|
# yield items
|
||
|
if data or tokens not in ignore_if_empty:
|
||
|
yield lineno, tokens, data
|
||
|
|
||
|
lineno += data.count("\n")
|
||
|
|
||
|
line_starting = m.group()[-1:] == "\n"
|
||
|
# fetch new position into new variable so that we can check
|
||
|
# if there is a internal parsing error which would result
|
||
|
# in an infinite loop
|
||
|
pos2 = m.end()
|
||
|
|
||
|
# handle state changes
|
||
|
if new_state is not None:
|
||
|
# remove the uppermost state
|
||
|
if new_state == "#pop":
|
||
|
stack.pop()
|
||
|
# resolve the new state by group checking
|
||
|
elif new_state == "#bygroup":
|
||
|
for key, value in m.groupdict().items():
|
||
|
if value is not None:
|
||
|
stack.append(key)
|
||
|
break
|
||
|
else:
|
||
|
raise RuntimeError(
|
||
|
f"{regex!r} wanted to resolve the new state dynamically"
|
||
|
f" but no group matched"
|
||
|
)
|
||
|
# direct state name given
|
||
|
else:
|
||
|
stack.append(new_state)
|
||
|
|
||
|
statetokens = self.rules[stack[-1]]
|
||
|
# we are still at the same position and no stack change.
|
||
|
# this means a loop without break condition, avoid that and
|
||
|
# raise error
|
||
|
elif pos2 == pos:
|
||
|
raise RuntimeError(
|
||
|
f"{regex!r} yielded empty string without stack change"
|
||
|
)
|
||
|
|
||
|
# publish new function and start again
|
||
|
pos = pos2
|
||
|
break
|
||
|
# if loop terminated without break we haven't found a single match
|
||
|
# either we are at the end of the file or we have a problem
|
||
|
else:
|
||
|
# end of text
|
||
|
if pos >= source_length:
|
||
|
return
|
||
|
|
||
|
# something went wrong
|
||
|
raise TemplateSyntaxError(
|
||
|
f"unexpected char {source[pos]!r} at {pos}", lineno, name, filename
|
||
|
)
|