Files
microsoft--semantic-kernel/python/semantic_kernel/template_engine/template_tokenizer.py
T
wehub-resource-sync b957a53def
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:21:23 +08:00

162 lines
6.2 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import logging
from semantic_kernel.exceptions import BlockSyntaxError, CodeBlockTokenError, TemplateSyntaxError
from semantic_kernel.template_engine.blocks.block import Block
from semantic_kernel.template_engine.blocks.block_types import BlockTypes
from semantic_kernel.template_engine.blocks.code_block import CodeBlock
from semantic_kernel.template_engine.blocks.symbols import Symbols
from semantic_kernel.template_engine.blocks.text_block import TextBlock
from semantic_kernel.template_engine.code_tokenizer import CodeTokenizer
logger: logging.Logger = logging.getLogger(__name__)
# BNF parsed by TemplateTokenizer:
# [template] ::= "" | [block] | [block] [template]
# [block] ::= [sk-block] | [text-block]
# [sk-block] ::= "{{" [variable] "}}"
# | "{{" [value] "}}"
# | "{{" [function-call] "}}"
# [text-block] ::= [any-char] | [any-char] [text-block]
# [any-char] ::= any char
class TemplateTokenizer:
"""Tokenize the template text into blocks."""
@staticmethod
def tokenize(text: str) -> list[Block]:
"""Tokenize the template text into blocks."""
code_tokenizer = CodeTokenizer()
# An empty block consists of 4 chars: "{{}}"
EMPTY_CODE_BLOCK_LENGTH = 4
# A block shorter than 5 chars is either empty or
# invalid, e.g. "{{ }}" and "{{$}}"
MIN_CODE_BLOCK_LENGTH = EMPTY_CODE_BLOCK_LENGTH + 1
text = text or ""
# Render None/empty to ""
if not text:
return [TextBlock.from_text("")]
# If the template is "empty" return it as a text block
if len(text) < MIN_CODE_BLOCK_LENGTH:
return [TextBlock.from_text(text)]
blocks: list[Block] = []
end_of_last_block = 0
block_start_pos = 0
block_start_found = False
inside_text_value = False
text_value_delimiter = None
skip_next_char = False
for current_char_pos, current_char in enumerate(text[:-1]):
next_char_pos = current_char_pos + 1
next_char = text[next_char_pos]
if skip_next_char:
skip_next_char = False
continue
# When "{{" is found outside a value
# Note: "{{ {{x}}" => ["{{ ", "{{x}}"]
if not inside_text_value and current_char == Symbols.BLOCK_STARTER and next_char == Symbols.BLOCK_STARTER:
# A block starts at the first "{"
block_start_pos = current_char_pos
block_start_found = True
if not block_start_found:
continue
# After having found "{{"
if inside_text_value:
# While inside a text value, when the end quote is found
# If the current char is escaping the next special char we skip
if current_char == Symbols.ESCAPE_CHAR and next_char in (
Symbols.DBL_QUOTE,
Symbols.SGL_QUOTE,
Symbols.ESCAPE_CHAR,
):
skip_next_char = True
continue
if current_char == text_value_delimiter:
inside_text_value = False
continue
# A value starts here
if current_char in (Symbols.DBL_QUOTE, Symbols.SGL_QUOTE):
inside_text_value = True
text_value_delimiter = current_char
continue
# If the block ends here
if current_char == Symbols.BLOCK_ENDER and next_char == Symbols.BLOCK_ENDER:
blocks.extend(
TemplateTokenizer._extract_blocks(
text, code_tokenizer, block_start_pos, end_of_last_block, next_char_pos
)
)
end_of_last_block = next_char_pos + 1
block_start_found = False
# If there is something left after the last block, capture it as a TextBlock
if end_of_last_block < len(text):
blocks.append(TextBlock.from_text(text, end_of_last_block, len(text)))
return blocks
@staticmethod
def _extract_blocks(
text: str, code_tokenizer: CodeTokenizer, block_start_pos: int, end_of_last_block: int, next_char_pos: int
) -> list[Block]:
"""Extract the blocks from the found code.
If there is text before the current block, create a TextBlock from that.
If the block is empty, return a TextBlock with the delimiters.
If the block is not empty, tokenize it and return the result.
If there is only a variable or value in the code block,
return just that, instead of the CodeBlock.
"""
new_blocks: list[Block] = []
if block_start_pos > end_of_last_block:
new_blocks.append(
TextBlock.from_text(
text,
end_of_last_block,
block_start_pos,
)
)
content_with_delimiters = text[block_start_pos : next_char_pos + 1]
content_without_delimiters = content_with_delimiters[2:-2].strip()
if len(content_without_delimiters) == 0:
# If what is left is empty (only {{}}), consider the raw block
# a TextBlock
new_blocks.append(TextBlock.from_text(content_with_delimiters))
return new_blocks
try:
code_blocks = code_tokenizer.tokenize(content_without_delimiters)
except BlockSyntaxError as e:
msg = f"Failed to tokenize code block: {content_without_delimiters}. {e}"
logger.warning(msg)
raise TemplateSyntaxError(msg) from e
if code_blocks[0].type in (
BlockTypes.VALUE,
BlockTypes.VARIABLE,
):
new_blocks.append(code_blocks[0])
return new_blocks
try:
new_blocks.append(CodeBlock(content=content_without_delimiters, tokens=code_blocks))
return new_blocks
except CodeBlockTokenError as e:
msg = f"Failed to tokenize code block: {content_without_delimiters}. {e}"
logger.warning(msg)
raise TemplateSyntaxError(msg) from e