|
|
|
|
|
import sys |
|
|
import json |
|
|
import argparse |
|
|
import jinja2.ext as jinja2_ext |
|
|
from PySide6.QtWidgets import ( |
|
|
QApplication, |
|
|
QMainWindow, |
|
|
QWidget, |
|
|
QVBoxLayout, |
|
|
QHBoxLayout, |
|
|
QLabel, |
|
|
QPlainTextEdit, |
|
|
QTextEdit, |
|
|
QPushButton, |
|
|
QFileDialog, |
|
|
) |
|
|
from PySide6.QtGui import QColor, QColorConstants, QTextCursor, QTextFormat |
|
|
from PySide6.QtCore import Qt, QRect, QSize |
|
|
from jinja2 import TemplateSyntaxError |
|
|
from jinja2.sandbox import ImmutableSandboxedEnvironment |
|
|
from datetime import datetime |
|
|
|
|
|
|
|
|
def format_template_content(template_content): |
|
|
"""Format the Jinja template content using Jinja2's lexer.""" |
|
|
if not template_content.strip(): |
|
|
return template_content |
|
|
|
|
|
env = ImmutableSandboxedEnvironment() |
|
|
tc_rstrip = template_content.rstrip() |
|
|
tokens = list(env.lex(tc_rstrip)) |
|
|
result = "" |
|
|
indent_level = 0 |
|
|
i = 0 |
|
|
|
|
|
while i < len(tokens): |
|
|
token = tokens[i] |
|
|
_, token_type, token_value = token |
|
|
|
|
|
if token_type == "block_begin": |
|
|
block_start = i |
|
|
|
|
|
construct_content = token_value |
|
|
end_token_type = token_type.replace("_begin", "_end") |
|
|
j = i + 1 |
|
|
while j < len(tokens) and tokens[j][1] != end_token_type: |
|
|
construct_content += tokens[j][2] |
|
|
j += 1 |
|
|
|
|
|
if j < len(tokens): |
|
|
construct_content += tokens[j][2] |
|
|
i = j |
|
|
|
|
|
|
|
|
stripped_content = construct_content.strip() |
|
|
instr = block_start + 1 |
|
|
while tokens[instr][1] == "whitespace": |
|
|
instr = instr + 1 |
|
|
|
|
|
instruction_token = tokens[instr][2] |
|
|
start_control_tokens = ["if", "for", "macro", "call", "block"] |
|
|
end_control_tokens = ["end" + t for t in start_control_tokens] |
|
|
is_control_start = any( |
|
|
instruction_token.startswith(kw) for kw in start_control_tokens |
|
|
) |
|
|
is_control_end = any( |
|
|
instruction_token.startswith(kw) for kw in end_control_tokens |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
if is_control_end: |
|
|
indent_level = max(0, indent_level - 1) |
|
|
|
|
|
|
|
|
result = result.rstrip() |
|
|
|
|
|
|
|
|
added_newline = False |
|
|
if result: |
|
|
result += ( |
|
|
"\n" + " " * indent_level |
|
|
) |
|
|
added_newline = True |
|
|
else: |
|
|
result += "" |
|
|
|
|
|
|
|
|
result += stripped_content |
|
|
|
|
|
|
|
|
if ( |
|
|
added_newline |
|
|
and stripped_content.startswith("{%") |
|
|
and not stripped_content.startswith("{%-") |
|
|
): |
|
|
|
|
|
result = ( |
|
|
result[: result.rfind("{%")] |
|
|
+ "{%-" |
|
|
+ result[result.rfind("{%") + 2 :] |
|
|
) |
|
|
if stripped_content.endswith("%}") and not stripped_content.endswith( |
|
|
"-%}" |
|
|
): |
|
|
|
|
|
if i + 1 < len(tokens) and tokens[i + 1][1] != "eof": |
|
|
result = result[:-2] + "-%}" |
|
|
|
|
|
|
|
|
if is_control_start: |
|
|
indent_level += 1 |
|
|
else: |
|
|
|
|
|
result += token_value |
|
|
elif token_type == "variable_begin": |
|
|
|
|
|
construct_content = token_value |
|
|
end_token_type = token_type.replace("_begin", "_end") |
|
|
j = i + 1 |
|
|
while j < len(tokens) and tokens[j][1] != end_token_type: |
|
|
construct_content += tokens[j][2] |
|
|
j += 1 |
|
|
|
|
|
if j < len(tokens): |
|
|
construct_content += tokens[j][2] |
|
|
i = j |
|
|
|
|
|
|
|
|
|
|
|
result += construct_content |
|
|
else: |
|
|
|
|
|
result += token_value |
|
|
elif token_type == "data": |
|
|
|
|
|
|
|
|
result += token_value |
|
|
else: |
|
|
|
|
|
result += token_value |
|
|
|
|
|
i += 1 |
|
|
|
|
|
|
|
|
result = result.rstrip() |
|
|
|
|
|
|
|
|
if (trailing_length := len(template_content) - len(tc_rstrip)): |
|
|
result += template_content[-trailing_length:] |
|
|
|
|
|
return result |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LineNumberArea(QWidget): |
|
|
def __init__(self, editor): |
|
|
super().__init__(editor) |
|
|
self.code_editor = editor |
|
|
|
|
|
def sizeHint(self): |
|
|
return QSize(self.code_editor.line_number_area_width(), 0) |
|
|
|
|
|
def paintEvent(self, event): |
|
|
self.code_editor.line_number_area_paint_event(event) |
|
|
|
|
|
|
|
|
class CodeEditor(QPlainTextEdit): |
|
|
def __init__(self): |
|
|
super().__init__() |
|
|
self.line_number_area = LineNumberArea(self) |
|
|
|
|
|
self.blockCountChanged.connect(self.update_line_number_area_width) |
|
|
self.updateRequest.connect(self.update_line_number_area) |
|
|
self.cursorPositionChanged.connect(self.highlight_current_line) |
|
|
|
|
|
self.update_line_number_area_width(0) |
|
|
self.highlight_current_line() |
|
|
|
|
|
def line_number_area_width(self): |
|
|
digits = len(str(self.blockCount())) |
|
|
space = 3 + self.fontMetrics().horizontalAdvance("9") * digits |
|
|
return space |
|
|
|
|
|
def update_line_number_area_width(self, _): |
|
|
self.setViewportMargins(self.line_number_area_width(), 0, 0, 0) |
|
|
|
|
|
def update_line_number_area(self, rect, dy): |
|
|
if dy: |
|
|
self.line_number_area.scroll(0, dy) |
|
|
else: |
|
|
self.line_number_area.update( |
|
|
0, rect.y(), self.line_number_area.width(), rect.height() |
|
|
) |
|
|
|
|
|
if rect.contains(self.viewport().rect()): |
|
|
self.update_line_number_area_width(0) |
|
|
|
|
|
def resizeEvent(self, event): |
|
|
super().resizeEvent(event) |
|
|
cr = self.contentsRect() |
|
|
self.line_number_area.setGeometry( |
|
|
QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height()) |
|
|
) |
|
|
|
|
|
def line_number_area_paint_event(self, event): |
|
|
from PySide6.QtGui import QPainter |
|
|
|
|
|
painter = QPainter(self.line_number_area) |
|
|
painter.fillRect(event.rect(), QColorConstants.LightGray) |
|
|
|
|
|
block = self.firstVisibleBlock() |
|
|
block_number = block.blockNumber() |
|
|
top = int( |
|
|
self.blockBoundingGeometry(block).translated(self.contentOffset()).top() |
|
|
) |
|
|
bottom = top + int(self.blockBoundingRect(block).height()) |
|
|
|
|
|
while block.isValid() and top <= event.rect().bottom(): |
|
|
if block.isVisible() and bottom >= event.rect().top(): |
|
|
number = str(block_number + 1) |
|
|
painter.setPen(QColorConstants.Black) |
|
|
painter.drawText( |
|
|
0, |
|
|
top, |
|
|
self.line_number_area.width() - 2, |
|
|
self.fontMetrics().height(), |
|
|
Qt.AlignmentFlag.AlignRight, |
|
|
number, |
|
|
) |
|
|
block = block.next() |
|
|
top = bottom |
|
|
bottom = top + int(self.blockBoundingRect(block).height()) |
|
|
block_number += 1 |
|
|
|
|
|
def highlight_current_line(self): |
|
|
extra_selections = [] |
|
|
if not self.isReadOnly(): |
|
|
selection = QTextEdit.ExtraSelection() |
|
|
line_color = QColorConstants.Yellow.lighter(160) |
|
|
selection.format.setBackground(line_color) |
|
|
selection.format.setProperty(QTextFormat.Property.FullWidthSelection, True) |
|
|
selection.cursor = self.textCursor() |
|
|
selection.cursor.clearSelection() |
|
|
extra_selections.append(selection) |
|
|
self.setExtraSelections(extra_selections) |
|
|
|
|
|
def highlight_position(self, lineno: int, col: int, color: QColor): |
|
|
block = self.document().findBlockByLineNumber(lineno - 1) |
|
|
if block.isValid(): |
|
|
cursor = QTextCursor(block) |
|
|
text = block.text() |
|
|
start = block.position() + max(0, col - 1) |
|
|
cursor.setPosition(start) |
|
|
if col <= len(text): |
|
|
cursor.movePosition( |
|
|
QTextCursor.MoveOperation.NextCharacter, |
|
|
QTextCursor.MoveMode.KeepAnchor, |
|
|
) |
|
|
|
|
|
extra = QTextEdit.ExtraSelection() |
|
|
extra.format.setBackground(color.lighter(160)) |
|
|
extra.cursor = cursor |
|
|
|
|
|
self.setExtraSelections(self.extraSelections() + [extra]) |
|
|
|
|
|
def highlight_line(self, lineno: int, color: QColor): |
|
|
block = self.document().findBlockByLineNumber(lineno - 1) |
|
|
if block.isValid(): |
|
|
cursor = QTextCursor(block) |
|
|
cursor.select(QTextCursor.SelectionType.LineUnderCursor) |
|
|
|
|
|
extra = QTextEdit.ExtraSelection() |
|
|
extra.format.setBackground(color.lighter(160)) |
|
|
extra.cursor = cursor |
|
|
|
|
|
self.setExtraSelections(self.extraSelections() + [extra]) |
|
|
|
|
|
def clear_highlighting(self): |
|
|
self.highlight_current_line() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class JinjaTester(QMainWindow): |
|
|
def __init__(self): |
|
|
super().__init__() |
|
|
self.setWindowTitle("Jinja Template Tester") |
|
|
self.resize(1200, 800) |
|
|
|
|
|
central = QWidget() |
|
|
main_layout = QVBoxLayout(central) |
|
|
|
|
|
|
|
|
input_layout = QHBoxLayout() |
|
|
|
|
|
|
|
|
template_layout = QVBoxLayout() |
|
|
template_label = QLabel("Jinja2 Template") |
|
|
template_layout.addWidget(template_label) |
|
|
self.template_edit = CodeEditor() |
|
|
template_layout.addWidget(self.template_edit) |
|
|
input_layout.addLayout(template_layout) |
|
|
|
|
|
|
|
|
json_layout = QVBoxLayout() |
|
|
json_label = QLabel("Context (JSON)") |
|
|
json_layout.addWidget(json_label) |
|
|
self.json_edit = CodeEditor() |
|
|
self.json_edit.setPlainText(""" |
|
|
{ |
|
|
"add_generation_prompt": true, |
|
|
"bos_token": "", |
|
|
"eos_token": "", |
|
|
"messages": [ |
|
|
{ |
|
|
"role": "user", |
|
|
"content": "What is the capital of Poland?" |
|
|
} |
|
|
] |
|
|
} |
|
|
""".strip()) |
|
|
json_layout.addWidget(self.json_edit) |
|
|
input_layout.addLayout(json_layout) |
|
|
|
|
|
main_layout.addLayout(input_layout) |
|
|
|
|
|
|
|
|
output_label = QLabel("Rendered Output") |
|
|
main_layout.addWidget(output_label) |
|
|
self.output_edit = QPlainTextEdit() |
|
|
self.output_edit.setReadOnly(True) |
|
|
main_layout.addWidget(self.output_edit) |
|
|
|
|
|
|
|
|
btn_layout = QHBoxLayout() |
|
|
|
|
|
|
|
|
self.load_btn = QPushButton("Load Template") |
|
|
self.load_btn.clicked.connect(self.load_template) |
|
|
btn_layout.addWidget(self.load_btn) |
|
|
|
|
|
|
|
|
self.format_btn = QPushButton("Format") |
|
|
self.format_btn.clicked.connect(self.format_template) |
|
|
btn_layout.addWidget(self.format_btn) |
|
|
|
|
|
self.render_btn = QPushButton("Render") |
|
|
self.render_btn.clicked.connect(self.render_template) |
|
|
btn_layout.addWidget(self.render_btn) |
|
|
main_layout.addLayout(btn_layout) |
|
|
|
|
|
|
|
|
self.status_label = QLabel("Ready") |
|
|
main_layout.addWidget(self.status_label) |
|
|
|
|
|
self.setCentralWidget(central) |
|
|
|
|
|
def render_template(self): |
|
|
self.template_edit.clear_highlighting() |
|
|
self.output_edit.clear() |
|
|
|
|
|
template_str = self.template_edit.toPlainText() |
|
|
json_str = self.json_edit.toPlainText() |
|
|
|
|
|
|
|
|
try: |
|
|
context = json.loads(json_str) if json_str.strip() else {} |
|
|
except Exception as e: |
|
|
self.status_label.setText(f"❌ JSON Error: {e}") |
|
|
return |
|
|
|
|
|
def raise_exception(text: str) -> str: |
|
|
raise RuntimeError(text) |
|
|
|
|
|
env = ImmutableSandboxedEnvironment( |
|
|
trim_blocks=True, |
|
|
lstrip_blocks=True, |
|
|
extensions=[jinja2_ext.loopcontrols], |
|
|
) |
|
|
env.filters["tojson"] = ( |
|
|
lambda x, |
|
|
indent=None, |
|
|
separators=None, |
|
|
sort_keys=False, |
|
|
ensure_ascii=False: json.dumps( |
|
|
x, |
|
|
indent=indent, |
|
|
separators=separators, |
|
|
sort_keys=sort_keys, |
|
|
ensure_ascii=ensure_ascii, |
|
|
) |
|
|
) |
|
|
env.globals["strftime_now"] = lambda format: datetime.now().strftime(format) |
|
|
env.globals["raise_exception"] = raise_exception |
|
|
try: |
|
|
template = env.from_string(template_str) |
|
|
output = template.render(context) |
|
|
self.output_edit.setPlainText(output) |
|
|
self.status_label.setText("✅ Render successful") |
|
|
except TemplateSyntaxError as e: |
|
|
self.status_label.setText(f"❌ Syntax Error (line {e.lineno}): {e.message}") |
|
|
if e.lineno: |
|
|
self.template_edit.highlight_line(e.lineno, QColor("red")) |
|
|
except Exception as e: |
|
|
|
|
|
|
|
|
lineno = None |
|
|
tb = e.__traceback__ |
|
|
while tb: |
|
|
frame = tb.tb_frame |
|
|
if frame.f_code.co_filename == "<template>": |
|
|
lineno = tb.tb_lineno |
|
|
break |
|
|
tb = tb.tb_next |
|
|
|
|
|
error_msg = f"Runtime Error: {type(e).__name__}: {e}" |
|
|
if lineno: |
|
|
error_msg = f"Runtime Error at line {lineno} in template: {type(e).__name__}: {e}" |
|
|
self.template_edit.highlight_line(lineno, QColor("orange")) |
|
|
|
|
|
self.output_edit.setPlainText(error_msg) |
|
|
self.status_label.setText(f"❌ {error_msg}") |
|
|
|
|
|
def load_template(self): |
|
|
"""Load a Jinja template from a file using a file dialog.""" |
|
|
file_path, _ = QFileDialog.getOpenFileName( |
|
|
self, |
|
|
"Load Jinja Template", |
|
|
"", |
|
|
"Template Files (*.jinja *.j2 *.html *.txt);;All Files (*)", |
|
|
) |
|
|
|
|
|
if file_path: |
|
|
try: |
|
|
with open(file_path, "r", encoding="utf-8") as file: |
|
|
content = file.read() |
|
|
self.template_edit.setPlainText(content) |
|
|
self.status_label.setText(f"✅ Loaded template from {file_path}") |
|
|
except Exception as e: |
|
|
self.status_label.setText(f"❌ Error loading file: {str(e)}") |
|
|
|
|
|
def format_template(self): |
|
|
"""Format the Jinja template using Jinja2's lexer for proper parsing.""" |
|
|
try: |
|
|
template_content = self.template_edit.toPlainText() |
|
|
if not template_content.strip(): |
|
|
self.status_label.setText("⚠️ Template is empty") |
|
|
return |
|
|
|
|
|
formatted_content = format_template_content(template_content) |
|
|
self.template_edit.setPlainText(formatted_content) |
|
|
self.status_label.setText("✅ Template formatted") |
|
|
except Exception as e: |
|
|
self.status_label.setText(f"❌ Error formatting template: {str(e)}") |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
if len(sys.argv) > 1: |
|
|
|
|
|
parser = argparse.ArgumentParser(description="Jinja Template Tester") |
|
|
parser.add_argument( |
|
|
"--template", required=True, help="Path to Jinja template file" |
|
|
) |
|
|
parser.add_argument("--context", required=True, help="JSON string for context") |
|
|
parser.add_argument( |
|
|
"--action", |
|
|
choices=["format", "render"], |
|
|
default="render", |
|
|
help="Action to perform", |
|
|
) |
|
|
args = parser.parse_args() |
|
|
|
|
|
|
|
|
with open(args.template, "r", encoding="utf-8") as f: |
|
|
template_content = f.read() |
|
|
|
|
|
|
|
|
context = json.loads(args.context) |
|
|
|
|
|
context.setdefault("bos_token", "") |
|
|
context.setdefault("eos_token", "") |
|
|
context.setdefault("add_generation_prompt", False) |
|
|
|
|
|
env = ImmutableSandboxedEnvironment() |
|
|
|
|
|
if args.action == "format": |
|
|
formatted = format_template_content(template_content) |
|
|
print(formatted) |
|
|
elif args.action == "render": |
|
|
template = env.from_string(template_content) |
|
|
output = template.render(context) |
|
|
print(output) |
|
|
|
|
|
else: |
|
|
|
|
|
app = QApplication(sys.argv) |
|
|
window = JinjaTester() |
|
|
window.show() |
|
|
sys.exit(app.exec()) |
|
|
|