| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504 |
- #!/usr/bin/env python3
- 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
- # Collect all tokens for this block construct
- 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): # Found the end token
- construct_content += tokens[j][2]
- i = j # Skip to the end token
- # Check for control structure keywords for indentation
- 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
- )
- # Adjust indentation for control structures
- # For control end blocks, decrease indent BEFORE adding the content
- if is_control_end:
- indent_level = max(0, indent_level - 1)
- # Remove all previous whitespace before this block
- result = result.rstrip()
- # Add proper indent, but only if this is not the first token
- added_newline = False
- if result: # Only add newline and indent if there's already content
- result += (
- "\n" + " " * indent_level
- ) # Use 2 spaces per indent level
- added_newline = True
- else: # For the first token, don't add any indent
- result += ""
- # Add the block content
- result += stripped_content
- # Add '-' after '%' if it wasn't there and we added a newline or indent
- if (
- added_newline
- and stripped_content.startswith("{%")
- and not stripped_content.startswith("{%-")
- ):
- # Add '-' at the beginning
- result = (
- result[: result.rfind("{%")]
- + "{%-"
- + result[result.rfind("{%") + 2 :]
- )
- if stripped_content.endswith("%}") and not stripped_content.endswith(
- "-%}"
- ):
- # Only add '-' if this is not the last token or if there's content after
- if i + 1 < len(tokens) and tokens[i + 1][1] != "eof":
- result = result[:-2] + "-%}"
- # For control start blocks, increase indent AFTER adding the content
- if is_control_start:
- indent_level += 1
- else:
- # Malformed template, just add the token
- result += token_value
- elif token_type == "variable_begin":
- # Collect all tokens for this variable construct
- 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): # Found the end token
- construct_content += tokens[j][2]
- i = j # Skip to the end token
- # For variable constructs, leave them alone
- # Do not add indent or whitespace before or after them
- result += construct_content
- else:
- # Malformed template, just add the token
- result += token_value
- elif token_type == "data":
- # Handle data (text between Jinja constructs)
- # For data content, preserve it as is
- result += token_value
- else:
- # Handle any other tokens
- result += token_value
- i += 1
- # Clean up trailing newlines and spaces
- result = result.rstrip()
- # Copy the newline / space count from the original
- if (trailing_length := len(template_content) - len(tc_rstrip)):
- result += template_content[-trailing_length:]
- return result
- # ------------------------
- # Line Number Widget
- # ------------------------
- 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) # pyright: ignore[reportAttributeAccessIssue]
- selection.format.setProperty(QTextFormat.Property.FullWidthSelection, True) # pyright: ignore[reportAttributeAccessIssue]
- selection.cursor = self.textCursor() # pyright: ignore[reportAttributeAccessIssue]
- selection.cursor.clearSelection() # pyright: ignore[reportAttributeAccessIssue]
- 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)) # pyright: ignore[reportAttributeAccessIssue]
- extra.cursor = cursor # pyright: ignore[reportAttributeAccessIssue]
- 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)) # pyright: ignore[reportAttributeAccessIssue]
- extra.cursor = cursor # pyright: ignore[reportAttributeAccessIssue]
- self.setExtraSelections(self.extraSelections() + [extra])
- def clear_highlighting(self):
- self.highlight_current_line()
- # ------------------------
- # Main App
- # ------------------------
- class JinjaTester(QMainWindow):
- def __init__(self):
- super().__init__()
- self.setWindowTitle("Jinja Template Tester")
- self.resize(1200, 800)
- central = QWidget()
- main_layout = QVBoxLayout(central)
- # -------- Top input area --------
- input_layout = QHBoxLayout()
- # Template editor with label
- 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 editor with label
- 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)
- # -------- Rendered output area --------
- 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)
- # -------- Render button and status --------
- btn_layout = QHBoxLayout()
- # Load template button
- self.load_btn = QPushButton("Load Template")
- self.load_btn.clicked.connect(self.load_template)
- btn_layout.addWidget(self.load_btn)
- # Format template button
- 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)
- # Status label below buttons
- 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()
- # Parse JSON context
- 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:
- # Catch all runtime errors
- # Try to extract template line number
- 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:
- # CLI mode
- 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()
- # Load template
- with open(args.template, "r", encoding="utf-8") as f:
- template_content = f.read()
- # Load JSON
- context = json.loads(args.context)
- # Add missing variables
- 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) # noqa: NP100
- elif args.action == "render":
- template = env.from_string(template_content)
- output = template.render(context)
- print(output) # noqa: NP100
- else:
- # GUI mode
- app = QApplication(sys.argv)
- window = JinjaTester()
- window.show()
- sys.exit(app.exec())
|