1
0

jinja-tester.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. #!/usr/bin/env python3
  2. import sys
  3. import json
  4. import argparse
  5. import jinja2.ext as jinja2_ext
  6. from PySide6.QtWidgets import (
  7. QApplication,
  8. QMainWindow,
  9. QWidget,
  10. QVBoxLayout,
  11. QHBoxLayout,
  12. QLabel,
  13. QPlainTextEdit,
  14. QTextEdit,
  15. QPushButton,
  16. QFileDialog,
  17. )
  18. from PySide6.QtGui import QColor, QColorConstants, QTextCursor, QTextFormat
  19. from PySide6.QtCore import Qt, QRect, QSize
  20. from jinja2 import TemplateSyntaxError
  21. from jinja2.sandbox import ImmutableSandboxedEnvironment
  22. from datetime import datetime
  23. def format_template_content(template_content):
  24. """Format the Jinja template content using Jinja2's lexer."""
  25. if not template_content.strip():
  26. return template_content
  27. env = ImmutableSandboxedEnvironment()
  28. tc_rstrip = template_content.rstrip()
  29. tokens = list(env.lex(tc_rstrip))
  30. result = ""
  31. indent_level = 0
  32. i = 0
  33. while i < len(tokens):
  34. token = tokens[i]
  35. _, token_type, token_value = token
  36. if token_type == "block_begin":
  37. block_start = i
  38. # Collect all tokens for this block construct
  39. construct_content = token_value
  40. end_token_type = token_type.replace("_begin", "_end")
  41. j = i + 1
  42. while j < len(tokens) and tokens[j][1] != end_token_type:
  43. construct_content += tokens[j][2]
  44. j += 1
  45. if j < len(tokens): # Found the end token
  46. construct_content += tokens[j][2]
  47. i = j # Skip to the end token
  48. # Check for control structure keywords for indentation
  49. stripped_content = construct_content.strip()
  50. instr = block_start + 1
  51. while tokens[instr][1] == "whitespace":
  52. instr = instr + 1
  53. instruction_token = tokens[instr][2]
  54. start_control_tokens = ["if", "for", "macro", "call", "block"]
  55. end_control_tokens = ["end" + t for t in start_control_tokens]
  56. is_control_start = any(
  57. instruction_token.startswith(kw) for kw in start_control_tokens
  58. )
  59. is_control_end = any(
  60. instruction_token.startswith(kw) for kw in end_control_tokens
  61. )
  62. # Adjust indentation for control structures
  63. # For control end blocks, decrease indent BEFORE adding the content
  64. if is_control_end:
  65. indent_level = max(0, indent_level - 1)
  66. # Remove all previous whitespace before this block
  67. result = result.rstrip()
  68. # Add proper indent, but only if this is not the first token
  69. added_newline = False
  70. if result: # Only add newline and indent if there's already content
  71. result += (
  72. "\n" + " " * indent_level
  73. ) # Use 2 spaces per indent level
  74. added_newline = True
  75. else: # For the first token, don't add any indent
  76. result += ""
  77. # Add the block content
  78. result += stripped_content
  79. # Add '-' after '%' if it wasn't there and we added a newline or indent
  80. if (
  81. added_newline
  82. and stripped_content.startswith("{%")
  83. and not stripped_content.startswith("{%-")
  84. ):
  85. # Add '-' at the beginning
  86. result = (
  87. result[: result.rfind("{%")]
  88. + "{%-"
  89. + result[result.rfind("{%") + 2 :]
  90. )
  91. if stripped_content.endswith("%}") and not stripped_content.endswith(
  92. "-%}"
  93. ):
  94. # Only add '-' if this is not the last token or if there's content after
  95. if i + 1 < len(tokens) and tokens[i + 1][1] != "eof":
  96. result = result[:-2] + "-%}"
  97. # For control start blocks, increase indent AFTER adding the content
  98. if is_control_start:
  99. indent_level += 1
  100. else:
  101. # Malformed template, just add the token
  102. result += token_value
  103. elif token_type == "variable_begin":
  104. # Collect all tokens for this variable construct
  105. construct_content = token_value
  106. end_token_type = token_type.replace("_begin", "_end")
  107. j = i + 1
  108. while j < len(tokens) and tokens[j][1] != end_token_type:
  109. construct_content += tokens[j][2]
  110. j += 1
  111. if j < len(tokens): # Found the end token
  112. construct_content += tokens[j][2]
  113. i = j # Skip to the end token
  114. # For variable constructs, leave them alone
  115. # Do not add indent or whitespace before or after them
  116. result += construct_content
  117. else:
  118. # Malformed template, just add the token
  119. result += token_value
  120. elif token_type == "data":
  121. # Handle data (text between Jinja constructs)
  122. # For data content, preserve it as is
  123. result += token_value
  124. else:
  125. # Handle any other tokens
  126. result += token_value
  127. i += 1
  128. # Clean up trailing newlines and spaces
  129. result = result.rstrip()
  130. # Copy the newline / space count from the original
  131. if (trailing_length := len(template_content) - len(tc_rstrip)):
  132. result += template_content[-trailing_length:]
  133. return result
  134. # ------------------------
  135. # Line Number Widget
  136. # ------------------------
  137. class LineNumberArea(QWidget):
  138. def __init__(self, editor):
  139. super().__init__(editor)
  140. self.code_editor = editor
  141. def sizeHint(self):
  142. return QSize(self.code_editor.line_number_area_width(), 0)
  143. def paintEvent(self, event):
  144. self.code_editor.line_number_area_paint_event(event)
  145. class CodeEditor(QPlainTextEdit):
  146. def __init__(self):
  147. super().__init__()
  148. self.line_number_area = LineNumberArea(self)
  149. self.blockCountChanged.connect(self.update_line_number_area_width)
  150. self.updateRequest.connect(self.update_line_number_area)
  151. self.cursorPositionChanged.connect(self.highlight_current_line)
  152. self.update_line_number_area_width(0)
  153. self.highlight_current_line()
  154. def line_number_area_width(self):
  155. digits = len(str(self.blockCount()))
  156. space = 3 + self.fontMetrics().horizontalAdvance("9") * digits
  157. return space
  158. def update_line_number_area_width(self, _):
  159. self.setViewportMargins(self.line_number_area_width(), 0, 0, 0)
  160. def update_line_number_area(self, rect, dy):
  161. if dy:
  162. self.line_number_area.scroll(0, dy)
  163. else:
  164. self.line_number_area.update(
  165. 0, rect.y(), self.line_number_area.width(), rect.height()
  166. )
  167. if rect.contains(self.viewport().rect()):
  168. self.update_line_number_area_width(0)
  169. def resizeEvent(self, event):
  170. super().resizeEvent(event)
  171. cr = self.contentsRect()
  172. self.line_number_area.setGeometry(
  173. QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height())
  174. )
  175. def line_number_area_paint_event(self, event):
  176. from PySide6.QtGui import QPainter
  177. painter = QPainter(self.line_number_area)
  178. painter.fillRect(event.rect(), QColorConstants.LightGray)
  179. block = self.firstVisibleBlock()
  180. block_number = block.blockNumber()
  181. top = int(
  182. self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
  183. )
  184. bottom = top + int(self.blockBoundingRect(block).height())
  185. while block.isValid() and top <= event.rect().bottom():
  186. if block.isVisible() and bottom >= event.rect().top():
  187. number = str(block_number + 1)
  188. painter.setPen(QColorConstants.Black)
  189. painter.drawText(
  190. 0,
  191. top,
  192. self.line_number_area.width() - 2,
  193. self.fontMetrics().height(),
  194. Qt.AlignmentFlag.AlignRight,
  195. number,
  196. )
  197. block = block.next()
  198. top = bottom
  199. bottom = top + int(self.blockBoundingRect(block).height())
  200. block_number += 1
  201. def highlight_current_line(self):
  202. extra_selections = []
  203. if not self.isReadOnly():
  204. selection = QTextEdit.ExtraSelection()
  205. line_color = QColorConstants.Yellow.lighter(160)
  206. selection.format.setBackground(line_color) # pyright: ignore[reportAttributeAccessIssue]
  207. selection.format.setProperty(QTextFormat.Property.FullWidthSelection, True) # pyright: ignore[reportAttributeAccessIssue]
  208. selection.cursor = self.textCursor() # pyright: ignore[reportAttributeAccessIssue]
  209. selection.cursor.clearSelection() # pyright: ignore[reportAttributeAccessIssue]
  210. extra_selections.append(selection)
  211. self.setExtraSelections(extra_selections)
  212. def highlight_position(self, lineno: int, col: int, color: QColor):
  213. block = self.document().findBlockByLineNumber(lineno - 1)
  214. if block.isValid():
  215. cursor = QTextCursor(block)
  216. text = block.text()
  217. start = block.position() + max(0, col - 1)
  218. cursor.setPosition(start)
  219. if col <= len(text):
  220. cursor.movePosition(
  221. QTextCursor.MoveOperation.NextCharacter,
  222. QTextCursor.MoveMode.KeepAnchor,
  223. )
  224. extra = QTextEdit.ExtraSelection()
  225. extra.format.setBackground(color.lighter(160)) # pyright: ignore[reportAttributeAccessIssue]
  226. extra.cursor = cursor # pyright: ignore[reportAttributeAccessIssue]
  227. self.setExtraSelections(self.extraSelections() + [extra])
  228. def highlight_line(self, lineno: int, color: QColor):
  229. block = self.document().findBlockByLineNumber(lineno - 1)
  230. if block.isValid():
  231. cursor = QTextCursor(block)
  232. cursor.select(QTextCursor.SelectionType.LineUnderCursor)
  233. extra = QTextEdit.ExtraSelection()
  234. extra.format.setBackground(color.lighter(160)) # pyright: ignore[reportAttributeAccessIssue]
  235. extra.cursor = cursor # pyright: ignore[reportAttributeAccessIssue]
  236. self.setExtraSelections(self.extraSelections() + [extra])
  237. def clear_highlighting(self):
  238. self.highlight_current_line()
  239. # ------------------------
  240. # Main App
  241. # ------------------------
  242. class JinjaTester(QMainWindow):
  243. def __init__(self):
  244. super().__init__()
  245. self.setWindowTitle("Jinja Template Tester")
  246. self.resize(1200, 800)
  247. central = QWidget()
  248. main_layout = QVBoxLayout(central)
  249. # -------- Top input area --------
  250. input_layout = QHBoxLayout()
  251. # Template editor with label
  252. template_layout = QVBoxLayout()
  253. template_label = QLabel("Jinja2 Template")
  254. template_layout.addWidget(template_label)
  255. self.template_edit = CodeEditor()
  256. template_layout.addWidget(self.template_edit)
  257. input_layout.addLayout(template_layout)
  258. # JSON editor with label
  259. json_layout = QVBoxLayout()
  260. json_label = QLabel("Context (JSON)")
  261. json_layout.addWidget(json_label)
  262. self.json_edit = CodeEditor()
  263. self.json_edit.setPlainText("""
  264. {
  265. "add_generation_prompt": true,
  266. "bos_token": "",
  267. "eos_token": "",
  268. "messages": [
  269. {
  270. "role": "user",
  271. "content": "What is the capital of Poland?"
  272. }
  273. ]
  274. }
  275. """.strip())
  276. json_layout.addWidget(self.json_edit)
  277. input_layout.addLayout(json_layout)
  278. main_layout.addLayout(input_layout)
  279. # -------- Rendered output area --------
  280. output_label = QLabel("Rendered Output")
  281. main_layout.addWidget(output_label)
  282. self.output_edit = QPlainTextEdit()
  283. self.output_edit.setReadOnly(True)
  284. main_layout.addWidget(self.output_edit)
  285. # -------- Render button and status --------
  286. btn_layout = QHBoxLayout()
  287. # Load template button
  288. self.load_btn = QPushButton("Load Template")
  289. self.load_btn.clicked.connect(self.load_template)
  290. btn_layout.addWidget(self.load_btn)
  291. # Format template button
  292. self.format_btn = QPushButton("Format")
  293. self.format_btn.clicked.connect(self.format_template)
  294. btn_layout.addWidget(self.format_btn)
  295. self.render_btn = QPushButton("Render")
  296. self.render_btn.clicked.connect(self.render_template)
  297. btn_layout.addWidget(self.render_btn)
  298. main_layout.addLayout(btn_layout)
  299. # Status label below buttons
  300. self.status_label = QLabel("Ready")
  301. main_layout.addWidget(self.status_label)
  302. self.setCentralWidget(central)
  303. def render_template(self):
  304. self.template_edit.clear_highlighting()
  305. self.output_edit.clear()
  306. template_str = self.template_edit.toPlainText()
  307. json_str = self.json_edit.toPlainText()
  308. # Parse JSON context
  309. try:
  310. context = json.loads(json_str) if json_str.strip() else {}
  311. except Exception as e:
  312. self.status_label.setText(f"❌ JSON Error: {e}")
  313. return
  314. def raise_exception(text: str) -> str:
  315. raise RuntimeError(text)
  316. env = ImmutableSandboxedEnvironment(
  317. trim_blocks=True,
  318. lstrip_blocks=True,
  319. extensions=[jinja2_ext.loopcontrols],
  320. )
  321. env.filters["tojson"] = (
  322. lambda x,
  323. indent=None,
  324. separators=None,
  325. sort_keys=False,
  326. ensure_ascii=False: json.dumps(
  327. x,
  328. indent=indent,
  329. separators=separators,
  330. sort_keys=sort_keys,
  331. ensure_ascii=ensure_ascii,
  332. )
  333. )
  334. env.globals["strftime_now"] = lambda format: datetime.now().strftime(format)
  335. env.globals["raise_exception"] = raise_exception
  336. try:
  337. template = env.from_string(template_str)
  338. output = template.render(context)
  339. self.output_edit.setPlainText(output)
  340. self.status_label.setText("✅ Render successful")
  341. except TemplateSyntaxError as e:
  342. self.status_label.setText(f"❌ Syntax Error (line {e.lineno}): {e.message}")
  343. if e.lineno:
  344. self.template_edit.highlight_line(e.lineno, QColor("red"))
  345. except Exception as e:
  346. # Catch all runtime errors
  347. # Try to extract template line number
  348. lineno = None
  349. tb = e.__traceback__
  350. while tb:
  351. frame = tb.tb_frame
  352. if frame.f_code.co_filename == "<template>":
  353. lineno = tb.tb_lineno
  354. break
  355. tb = tb.tb_next
  356. error_msg = f"Runtime Error: {type(e).__name__}: {e}"
  357. if lineno:
  358. error_msg = f"Runtime Error at line {lineno} in template: {type(e).__name__}: {e}"
  359. self.template_edit.highlight_line(lineno, QColor("orange"))
  360. self.output_edit.setPlainText(error_msg)
  361. self.status_label.setText(f"❌ {error_msg}")
  362. def load_template(self):
  363. """Load a Jinja template from a file using a file dialog."""
  364. file_path, _ = QFileDialog.getOpenFileName(
  365. self,
  366. "Load Jinja Template",
  367. "",
  368. "Template Files (*.jinja *.j2 *.html *.txt);;All Files (*)",
  369. )
  370. if file_path:
  371. try:
  372. with open(file_path, "r", encoding="utf-8") as file:
  373. content = file.read()
  374. self.template_edit.setPlainText(content)
  375. self.status_label.setText(f"✅ Loaded template from {file_path}")
  376. except Exception as e:
  377. self.status_label.setText(f"❌ Error loading file: {str(e)}")
  378. def format_template(self):
  379. """Format the Jinja template using Jinja2's lexer for proper parsing."""
  380. try:
  381. template_content = self.template_edit.toPlainText()
  382. if not template_content.strip():
  383. self.status_label.setText("⚠️ Template is empty")
  384. return
  385. formatted_content = format_template_content(template_content)
  386. self.template_edit.setPlainText(formatted_content)
  387. self.status_label.setText("✅ Template formatted")
  388. except Exception as e:
  389. self.status_label.setText(f"❌ Error formatting template: {str(e)}")
  390. if __name__ == "__main__":
  391. if len(sys.argv) > 1:
  392. # CLI mode
  393. parser = argparse.ArgumentParser(description="Jinja Template Tester")
  394. parser.add_argument(
  395. "--template", required=True, help="Path to Jinja template file"
  396. )
  397. parser.add_argument("--context", required=True, help="JSON string for context")
  398. parser.add_argument(
  399. "--action",
  400. choices=["format", "render"],
  401. default="render",
  402. help="Action to perform",
  403. )
  404. args = parser.parse_args()
  405. # Load template
  406. with open(args.template, "r", encoding="utf-8") as f:
  407. template_content = f.read()
  408. # Load JSON
  409. context = json.loads(args.context)
  410. # Add missing variables
  411. context.setdefault("bos_token", "")
  412. context.setdefault("eos_token", "")
  413. context.setdefault("add_generation_prompt", False)
  414. env = ImmutableSandboxedEnvironment()
  415. if args.action == "format":
  416. formatted = format_template_content(template_content)
  417. print(formatted) # noqa: NP100
  418. elif args.action == "render":
  419. template = env.from_string(template_content)
  420. output = template.render(context)
  421. print(output) # noqa: NP100
  422. else:
  423. # GUI mode
  424. app = QApplication(sys.argv)
  425. window = JinjaTester()
  426. window.show()
  427. sys.exit(app.exec())