feat: dynamic mode
This commit is contained in:
322
backend/uframe/codegen.py
Normal file
322
backend/uframe/codegen.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""Dynamic page compiler — generate executable Python scripts from µFrame IR.
|
||||
|
||||
Takes a parsed IR tree containing dynamic nodes (source, if, for, on_submit,
|
||||
state) and generates a self-contained Python script that:
|
||||
1. Sets shebang + cache header
|
||||
2. Reads form data from environment variables
|
||||
3. Executes source commands
|
||||
4. Evaluates conditionals and loops
|
||||
5. Renders the layout into a CharGrid
|
||||
6. Emits Micron to stdout
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from uframe.ir import (
|
||||
IRNode, Page, Box, Row, Col, Spacer, Pad,
|
||||
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
SourceType, BorderWeight, HeadingLevel, DividerStyle, ListStyle,
|
||||
)
|
||||
|
||||
|
||||
def _indent(code: str, level: int = 1) -> str:
|
||||
"""Indent a block of code."""
|
||||
prefix = " " * level
|
||||
return "\n".join(prefix + line if line.strip() else "" for line in code.split("\n"))
|
||||
|
||||
|
||||
def _resolve_vars(text: str) -> str:
|
||||
"""Convert $var references to Python f-string expressions."""
|
||||
import re
|
||||
# Replace $var.attr.attr with {var_attr_attr} and simple $var with {var}
|
||||
def replace_var(m: re.Match) -> str:
|
||||
var = m.group(1)
|
||||
# Replace dots with underscores for Python variable names
|
||||
py_var = var.replace(".", "_")
|
||||
return "{" + py_var + "}"
|
||||
return re.sub(r'\$([a-zA-Z_][\w.]*)', replace_var, text)
|
||||
|
||||
|
||||
def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
|
||||
"""Generate Python code lines for a single IR node."""
|
||||
lines: list[str] = []
|
||||
ind = " " * indent_level
|
||||
|
||||
if isinstance(node, Let):
|
||||
val = node.var_value
|
||||
# Try to detect numeric values
|
||||
try:
|
||||
float(val)
|
||||
lines.append(f"{ind}{node.var_name} = {val}")
|
||||
except ValueError:
|
||||
if "," in val:
|
||||
# Comma-separated list
|
||||
lines.append(f"{ind}{node.var_name} = [{val}]")
|
||||
else:
|
||||
lines.append(f"{ind}{node.var_name} = {val!r}")
|
||||
|
||||
elif isinstance(node, Source):
|
||||
var = node.var_name
|
||||
if node.source_type == SourceType.SHELL:
|
||||
lines.append(f"{ind}{var} = _shell({node.command!r}, timeout={node.timeout})")
|
||||
elif node.source_type == SourceType.FILE:
|
||||
lines.append(f"{ind}{var} = _read_file({node.command!r})")
|
||||
elif node.source_type == SourceType.JSON:
|
||||
lines.append(f"{ind}{var} = _read_json({node.command!r})")
|
||||
elif node.source_type == SourceType.PYTHON:
|
||||
lines.append(f"{ind}{var} = eval({node.command!r})")
|
||||
elif node.source_type == SourceType.PARAM:
|
||||
lines.append(f"{ind}{var} = _get_param({node.command!r})")
|
||||
elif node.source_type == SourceType.RNS:
|
||||
lines.append(f"{ind}{var} = _shell('rnstatus {node.command}', timeout={node.timeout})")
|
||||
|
||||
elif isinstance(node, CacheControl):
|
||||
lines.append(f"{ind}_cache_seconds = {node.seconds}")
|
||||
|
||||
elif isinstance(node, StateDecl):
|
||||
lines.append(f"{ind}{node.state_name} = _load_state({node.path!r})")
|
||||
|
||||
elif isinstance(node, IfBlock):
|
||||
cond = _resolve_vars(node.condition)
|
||||
# Convert simple comparisons
|
||||
cond = cond.replace("&&", " and ").replace("||", " or ")
|
||||
lines.append(f"{ind}if {cond}:")
|
||||
if node.children:
|
||||
for child in node.children:
|
||||
lines.extend(_emit_node(child, indent_level + 1))
|
||||
else:
|
||||
lines.append(f"{ind} pass")
|
||||
|
||||
for elif_cond, elif_children in node.elif_branches:
|
||||
ec = _resolve_vars(elif_cond).replace("&&", " and ").replace("||", " or ")
|
||||
lines.append(f"{ind}elif {ec}:")
|
||||
if elif_children:
|
||||
for child in elif_children:
|
||||
lines.extend(_emit_node(child, indent_level + 1))
|
||||
else:
|
||||
lines.append(f"{ind} pass")
|
||||
|
||||
if node.else_children:
|
||||
lines.append(f"{ind}else:")
|
||||
for child in node.else_children:
|
||||
lines.extend(_emit_node(child, indent_level + 1))
|
||||
|
||||
elif isinstance(node, ForLoop):
|
||||
iterable = _resolve_vars(node.iterable)
|
||||
lines.append(f"{ind}for {node.var_name} in _iter({iterable}):")
|
||||
if node.children:
|
||||
for child in node.children:
|
||||
lines.extend(_emit_node(child, indent_level + 1))
|
||||
else:
|
||||
lines.append(f"{ind} pass")
|
||||
|
||||
elif isinstance(node, OnSubmit):
|
||||
lines.append(f"{ind}if _get_field({node.form_name!r}, ''):")
|
||||
lines.append(f"{ind} # Form '{node.form_name}' was submitted")
|
||||
for child in node.children:
|
||||
lines.extend(_emit_node(child, indent_level + 1))
|
||||
|
||||
elif isinstance(node, Page):
|
||||
lines.append(f"{ind}_page_title = {node.title!r}")
|
||||
lines.append(f"{ind}_page_width = {node.width}")
|
||||
lines.append(f"{ind}_uf_source_parts = []")
|
||||
for child in node.children:
|
||||
lines.extend(_emit_node(child, indent_level))
|
||||
|
||||
# Content nodes — emit as µFrame source that gets compiled
|
||||
elif isinstance(node, Heading):
|
||||
level = node.level.value
|
||||
text = _resolve_vars(node.text)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'heading {level} \"{text}\"')")
|
||||
|
||||
elif isinstance(node, Text):
|
||||
text = _resolve_vars(node.content)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'text \"{text}\"')")
|
||||
|
||||
elif isinstance(node, Label):
|
||||
key = _resolve_vars(node.key)
|
||||
val = _resolve_vars(node.value)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'label \"{key}\" \"{val}\"')")
|
||||
|
||||
elif isinstance(node, Gauge):
|
||||
label = _resolve_vars(node.label)
|
||||
value = _resolve_vars(str(node.value)) if "$" in str(node.value) else str(node.value)
|
||||
extra = ""
|
||||
if node.warn is not None:
|
||||
extra += f" warn={node.warn}"
|
||||
if node.crit is not None:
|
||||
extra += f" crit={node.crit}"
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'gauge \"{label}\" {value} {node.max_val} {node.bar_width}{extra}')")
|
||||
|
||||
elif isinstance(node, Status):
|
||||
label = _resolve_vars(node.label)
|
||||
state = _resolve_vars(node.state)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'status \"{label}\" {state}')")
|
||||
|
||||
elif isinstance(node, Box):
|
||||
weight = node.weight.name.lower()
|
||||
title = _resolve_vars(node.title)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'box {weight} \"{title}\"')")
|
||||
for child in node.children:
|
||||
# Indent children for the box
|
||||
child_lines = _emit_node(child, indent_level)
|
||||
for cl in child_lines:
|
||||
if "_uf_source_parts.append" in cl:
|
||||
# Add 2-space indent to the µFrame source
|
||||
cl = cl.replace(".append(f'", ".append(f' ")
|
||||
cl = cl.replace(".append('", ".append(' ")
|
||||
lines.append(cl)
|
||||
|
||||
elif isinstance(node, Divider):
|
||||
style = node.divider_style.name.lower()
|
||||
lines.append(f"{ind}_uf_source_parts.append('divider {style}')")
|
||||
|
||||
elif isinstance(node, Spacer):
|
||||
lines.append(f"{ind}_uf_source_parts.append('spacer {node.lines}')")
|
||||
|
||||
elif isinstance(node, Link):
|
||||
display = _resolve_vars(node.display)
|
||||
dest = _resolve_vars(node.dest)
|
||||
lines.append(f"{ind}_uf_source_parts.append(f'link \"{display}\" \"{dest}\"')")
|
||||
|
||||
elif isinstance(node, Field):
|
||||
lines.append(f"{ind}_uf_source_parts.append('field \"{node.field_name}\" {node.field_width} \"{node.placeholder}\"')")
|
||||
|
||||
elif isinstance(node, FormButton):
|
||||
lines.append(f"{ind}_uf_source_parts.append('button \"{node.button_label}\" \"{node.dest}\"')")
|
||||
|
||||
elif isinstance(node, Form):
|
||||
lines.append(f"{ind}_uf_source_parts.append('form \"{node.form_name}\"')")
|
||||
for child in node.children:
|
||||
child_lines = _emit_node(child, indent_level)
|
||||
for cl in child_lines:
|
||||
if "_uf_source_parts.append" in cl:
|
||||
cl = cl.replace(".append(f'", ".append(f' ")
|
||||
cl = cl.replace(".append('", ".append(' ")
|
||||
lines.append(cl)
|
||||
|
||||
else:
|
||||
# Generic: emit children
|
||||
for child in node.children:
|
||||
lines.extend(_emit_node(child, indent_level))
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime template embedded in generated scripts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_script(uframe_import: str, page_logic: str, page_title: str, page_width: int) -> str:
|
||||
"""Build the dynamic script from parts (avoids str.format brace issues)."""
|
||||
lines = [
|
||||
"#!/usr/bin/env python3",
|
||||
"# Auto-generated by uFrame",
|
||||
"# Do not edit — regenerate with: uframe compile <source>.uf",
|
||||
"",
|
||||
"import os, sys, json, subprocess, datetime, secrets, shlex",
|
||||
"",
|
||||
"# ─── Runtime Helpers ─────────────────────────────────────────",
|
||||
"",
|
||||
'def _shell(cmd, timeout=5):',
|
||||
' """Execute shell command, return stdout."""',
|
||||
' try:',
|
||||
' return subprocess.check_output(cmd, shell=True, timeout=timeout).decode().strip()',
|
||||
' except Exception:',
|
||||
' return ""',
|
||||
"",
|
||||
'def _read_file(path):',
|
||||
' """Read file contents."""',
|
||||
' try:',
|
||||
' return open(path).read().strip()',
|
||||
' except Exception:',
|
||||
' return ""',
|
||||
"",
|
||||
'def _read_json(path):',
|
||||
' """Read and parse JSON file."""',
|
||||
' try:',
|
||||
' with open(path) as f:',
|
||||
' return json.load(f)',
|
||||
' except Exception:',
|
||||
' return {}',
|
||||
"",
|
||||
'def _get_field(name, default=""):',
|
||||
' """Read submitted form field from environment."""',
|
||||
' return os.environ.get(f"FIELD_{name}", default)',
|
||||
"",
|
||||
'def _get_param(name, default=""):',
|
||||
' """Read URL parameter."""',
|
||||
' return os.environ.get(f"PARAM_{name}",',
|
||||
' os.environ.get(f"var_{name}", default))',
|
||||
"",
|
||||
'def _load_state(path):',
|
||||
' """Load state from JSON file."""',
|
||||
' try:',
|
||||
' with open(path) as f:',
|
||||
' return json.load(f)',
|
||||
' except Exception:',
|
||||
' return {}',
|
||||
"",
|
||||
'def _save_state(path, data):',
|
||||
' """Save state to JSON file."""',
|
||||
' os.makedirs(os.path.dirname(path), exist_ok=True)',
|
||||
' with open(path, "w") as f:',
|
||||
' json.dump(data, f, indent=2)',
|
||||
"",
|
||||
'def _iter(val):',
|
||||
' """Make a value iterable for for-loops."""',
|
||||
' if isinstance(val, (list, tuple)):',
|
||||
' return val',
|
||||
' if isinstance(val, dict):',
|
||||
' return [val]',
|
||||
' if isinstance(val, str):',
|
||||
' return val.strip().splitlines()',
|
||||
' return []',
|
||||
"",
|
||||
f"# ─── µFrame Compile ──────────────────────────────────────────",
|
||||
"",
|
||||
uframe_import,
|
||||
"",
|
||||
"# ─── Page Logic ──────────────────────────────────────────────",
|
||||
"",
|
||||
"_cache_seconds = 0",
|
||||
"",
|
||||
page_logic,
|
||||
"",
|
||||
"# ─── Render & Output ─────────────────────────────────────────",
|
||||
"",
|
||||
f'_uf_source = f\'\'\'page "{page_title}" {page_width}',
|
||||
"''' + \"\\n\".join(_uf_source_parts)",
|
||||
"",
|
||||
f"result = uframe.compile(_uf_source, width={page_width})",
|
||||
"",
|
||||
"if _cache_seconds >= 0:",
|
||||
' print(f"#!c={_cache_seconds}")',
|
||||
"print(result.micron)",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def compile_dynamic(page: Page) -> str:
|
||||
"""Generate a self-contained executable Python script from an IR tree.
|
||||
|
||||
The generated script imports uframe at runtime and compiles the
|
||||
dynamically-built .uf source into Micron output.
|
||||
"""
|
||||
logic_lines = _emit_node(page, indent_level=0)
|
||||
page_logic = "\n".join(logic_lines)
|
||||
|
||||
uframe_import = "import uframe"
|
||||
|
||||
return _build_script(
|
||||
uframe_import=uframe_import,
|
||||
page_logic=page_logic,
|
||||
page_title=page.title,
|
||||
page_width=page.width,
|
||||
)
|
||||
Reference in New Issue
Block a user