generflow-core 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
generflow_core/cli.py ADDED
@@ -0,0 +1,241 @@
1
+ """Local-only CLI: render a GF-Lang spec file to HTML/JSON without an LLM.
2
+
3
+ Useful for:
4
+ - Static dashboard embedding
5
+ - Storybook integration
6
+ - Design-system handoff (designer writes a spec, dev renders it)
7
+ - Email-renderable reports (spec → HTML → email)
8
+
9
+ Usage:
10
+ python -m generflow_core.cli render spec.gf -o output.html
11
+ python -m generflow_core.cli validate spec.gf
12
+ python -m generflow_core.cli diff before.gf after.gf
13
+ python -m generflow_core.cli to-a2ui spec.gf -o output.a2ui.json
14
+ python -m generflow_core.cli from-a2ui incoming.a2ui.json -o spec.gf
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from .spec import GFLangParser, diff_specs, summarize_diff
24
+ from .registry import Registry
25
+ from .interop import a2ui_to_gflang, gflang_to_a2ui
26
+
27
+
28
+ # ── Render: spec → HTML ───────────────────────────────────────────────────
29
+
30
+ def render_html(spec_text: str, registry: Registry | None = None) -> str:
31
+ """Render a GF-Lang spec to a self-contained HTML document.
32
+
33
+ The HTML has inline CSS and minimal JS. No React, no Tailwind CDN —
34
+ everything's a single file you can drop in an email or iframe.
35
+ """
36
+ registry = registry or Registry()
37
+ parser = GFLangParser()
38
+ nodes = parser.feed_chunk(spec_text)
39
+ if not nodes:
40
+ return _empty_html("(empty spec)")
41
+ root = nodes[0]
42
+ if hasattr(root, "value"):
43
+ root = root.value
44
+
45
+ body = _render_node_html(root, registry)
46
+ return f"""<!doctype html>
47
+ <html><head>
48
+ <meta charset="utf-8">
49
+ <title>Generflow render</title>
50
+ <style>
51
+ body {{ font: 14px/1.5 system-ui, -apple-system, sans-serif; margin: 0; padding: 24px; background: #fafafa; color: #1a1a1a; }}
52
+ .gf-card {{ background: white; border: 1px solid #e5e5e5; border-radius: 8px; padding: 16px; margin: 12px 0; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }}
53
+ .gf-card > .gf-title {{ font-size: 18px; font-weight: 600; margin-bottom: 12px; color: #111; }}
54
+ .gf-stack, .gf-row, .gf-col {{ display: flex; gap: 12px; }}
55
+ .gf-col {{ flex-direction: column; }}
56
+ .gf-header {{ font-weight: 600; margin: 8px 0; }}
57
+ .gf-header.h1 {{ font-size: 24px; }} .gf-header.h2 {{ font-size: 18px; }} .gf-header.h3 {{ font-size: 14px; }}
58
+ .gf-text {{ margin: 4px 0; }}
59
+ .gf-metric {{ display: flex; flex-direction: column; padding: 12px; background: #f8f8f8; border-radius: 6px; flex: 1; }}
60
+ .gf-metric .label {{ font-size: 12px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }}
61
+ .gf-metric .value {{ font-size: 28px; font-weight: 700; color: #111; margin-top: 4px; }}
62
+ .gf-metric .delta-positive {{ color: #10b981; font-size: 12px; }}
63
+ .gf-metric .delta-negative {{ color: #ef4444; font-size: 12px; }}
64
+ .gf-button {{ background: #2563eb; color: white; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font-weight: 500; }}
65
+ .gf-button:hover {{ background: #1d4ed8; }}
66
+ .gf-form {{ background: white; border: 1px solid #e5e5e5; border-radius: 8px; padding: 16px; }}
67
+ .gf-field {{ display: flex; flex-direction: column; margin: 8px 0; }}
68
+ .gf-field label {{ font-size: 12px; color: #666; margin-bottom: 4px; }}
69
+ .gf-field input {{ padding: 8px; border: 1px solid #ddd; border-radius: 4px; }}
70
+ .gf-list {{ list-style: none; padding: 0; }}
71
+ .gf-list li {{ padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }}
72
+ .gf-chart {{ background: #f8f8f8; border-radius: 6px; padding: 12px; min-height: 120px; display: flex; align-items: center; justify-content: center; color: #666; }}
73
+ .gf-meta {{ font-size: 11px; color: #999; margin-top: 24px; padding-top: 12px; border-top: 1px solid #eee; }}
74
+ </style>
75
+ </head><body>
76
+ {body}
77
+ <div class="gf-meta">Rendered by Generflow · {len(nodes)} top-level node(s)</div>
78
+ </body></html>"""
79
+
80
+
81
+ def _render_node_html(node, registry: Registry) -> str:
82
+ from .spec.ast import Component, Literal
83
+ if not isinstance(node, Component):
84
+ return ""
85
+ name = node.name
86
+ kwargs = {k: (v.value if isinstance(v, Literal) else str(v)) for k, v in node.kwargs.items()}
87
+ children_html = ""
88
+ # read children from new Component.children field first; fall back to args[-1]
89
+ children = getattr(node, "children", None)
90
+ if children:
91
+ children_html = "".join(_render_node_html(c, registry) for c in children if isinstance(c, Component))
92
+ elif node.args:
93
+ last = node.args[-1]
94
+ if isinstance(last, Literal) and isinstance(last.value, list):
95
+ children_html = "".join(_render_node_html(c, registry) for c in last.value if isinstance(c, Component))
96
+
97
+ if name == "Card":
98
+ title = kwargs.get("title", "")
99
+ title_html = f'<div class="gf-title">{title}</div>' if title else ""
100
+ return f'<div class="gf-card">{title_html}{children_html}</div>'
101
+ if name in ("Stack", "Col"):
102
+ return f'<div class="gf-col">{children_html}</div>'
103
+ if name == "Row":
104
+ return f'<div class="gf-row">{children_html}</div>'
105
+ if name == "Header":
106
+ lvl = kwargs.get("level", "1")
107
+ text = kwargs.get("text", "")
108
+ return f'<div class="gf-header h{lvl}">{text}</div>'
109
+ if name == "Text":
110
+ text = kwargs.get("text", "")
111
+ return f'<div class="gf-text">{text}</div>'
112
+ if name == "Metric":
113
+ label = kwargs.get("label", "")
114
+ value = kwargs.get("value", "—")
115
+ delta = kwargs.get("delta", "")
116
+ delta_cls = "delta-positive" if delta.startswith("+") else ("delta-negative" if delta.startswith("-") else "")
117
+ delta_html = f'<span class="{delta_cls}">{delta}</span>' if delta else ""
118
+ return f'<div class="gf-metric"><span class="label">{label}</span><span class="value">{value}</span>{delta_html}</div>'
119
+ if name == "Chart":
120
+ title = kwargs.get("title", "")
121
+ ctype = kwargs.get("type", "bar")
122
+ return f'<div class="gf-chart">[Chart: {title or ctype}]</div>'
123
+ if name == "Button":
124
+ label = kwargs.get("label", "")
125
+ intent = kwargs.get("intent", "")
126
+ intent_attr = f' data-intent="{intent}"' if intent else ""
127
+ return f'<button class="gf-button"{intent_attr}>{label}</button>'
128
+ if name == "Form":
129
+ title = kwargs.get("title", "")
130
+ title_html = f'<h3>{title}</h3>' if title else ""
131
+ return f'<div class="gf-form">{title_html}{children_html}</div>'
132
+ if name == "Field":
133
+ label = kwargs.get("label", "")
134
+ name_attr = kwargs.get("name", "")
135
+ ftype = kwargs.get("type", "text")
136
+ return f'<div class="gf-field"><label>{label}</label><input type="{ftype}" name="{name_attr}"></div>'
137
+ if name == "List":
138
+ return f'<ul class="gf-list">{children_html or "<li>(list — data source not resolved in static mode)</li>"}</ul>'
139
+ if name == "Navbar":
140
+ title = kwargs.get("title", "")
141
+ return f'<nav class="gf-card"><div class="gf-title">{title}</div></nav>'
142
+ if name == "Modal":
143
+ return f'<div class="gf-card"><em>(Modal — not interactive in static mode)</em>{children_html}</div>'
144
+ # unknown component — render as warning
145
+ return f'<div class="gf-text" style="color:#999">[Unknown component: {name}]</div>'
146
+
147
+
148
+ def _empty_html(msg: str) -> str:
149
+ return f'<!doctype html><html><body><p>{msg}</p></body></html>'
150
+
151
+
152
+ # ── CLI ───────────────────────────────────────────────────────────────────
153
+
154
+ def main() -> int:
155
+ parser = argparse.ArgumentParser(prog="generflow", description="Generflow local-only CLI")
156
+ sub = parser.add_subparsers(dest="command", required=True)
157
+
158
+ p_render = sub.add_parser("render", help="Render a GF-Lang spec to HTML")
159
+ p_render.add_argument("spec", type=Path, help="Path to .gf file")
160
+ p_render.add_argument("-o", "--output", type=Path, default=None)
161
+
162
+ p_validate = sub.add_parser("validate", help="Validate a spec against the registry")
163
+ p_validate.add_argument("spec", type=Path)
164
+
165
+ p_diff = sub.add_parser("diff", help="Compare two specs")
166
+ p_diff.add_argument("before", type=Path)
167
+ p_diff.add_argument("after", type=Path)
168
+
169
+ p_to_a2ui = sub.add_parser("to-a2ui", help="Convert GF-Lang to A2UI JSONL")
170
+ p_to_a2ui.add_argument("spec", type=Path)
171
+ p_to_a2ui.add_argument("-o", "--output", type=Path, default=None)
172
+
173
+ p_from_a2ui = sub.add_parser("from-a2ui", help="Convert A2UI JSONL to GF-Lang")
174
+ p_from_a2ui.add_argument("a2ui", type=Path)
175
+ p_from_a2ui.add_argument("-o", "--output", type=Path, default=None)
176
+
177
+ args = parser.parse_args()
178
+
179
+ if args.command == "render":
180
+ text = args.spec.read_text()
181
+ html = render_html(text)
182
+ if args.output:
183
+ args.output.write_text(html)
184
+ print(f"wrote {args.output}")
185
+ else:
186
+ print(html)
187
+ return 0
188
+
189
+ if args.command == "validate":
190
+ text = args.spec.read_text()
191
+ p = GFLangParser()
192
+ nodes = p.feed_chunk(text)
193
+ r = Registry()
194
+ valid = 0
195
+ invalid = 0
196
+ from .spec.ast import Assignment, Component
197
+ for n in nodes:
198
+ target = n.value if isinstance(n, Assignment) else n
199
+ if isinstance(target, Component):
200
+ if r.has(target.name):
201
+ valid += 1
202
+ print(f" ✓ {target.name}")
203
+ else:
204
+ invalid += 1
205
+ print(f" ✗ {target.name} — not in registry")
206
+ print(f"\n{valid} valid, {invalid} invalid")
207
+ return 0 if invalid == 0 else 1
208
+
209
+ if args.command == "diff":
210
+ before = args.before.read_text()
211
+ after = args.after.read_text()
212
+ entries = diff_specs(before, after)
213
+ summary = summarize_diff(entries)
214
+ print(json.dumps({"summary": summary, "entries": entries}, indent=2))
215
+ return 0
216
+
217
+ if args.command == "to-a2ui":
218
+ text = args.spec.read_text()
219
+ out = gflang_to_a2ui(text)
220
+ if args.output:
221
+ args.output.write_text(out + "\n")
222
+ print(f"wrote {args.output}")
223
+ else:
224
+ print(out)
225
+ return 0
226
+
227
+ if args.command == "from-a2ui":
228
+ text = args.a2ui.read_text()
229
+ out = a2ui_to_gflang(text)
230
+ if args.output:
231
+ args.output.write_text(out + "\n")
232
+ print(f"wrote {args.output}")
233
+ else:
234
+ print(out)
235
+ return 0
236
+
237
+ return 1
238
+
239
+
240
+ if __name__ == "__main__":
241
+ sys.exit(main())
@@ -0,0 +1,30 @@
1
+ """Databind module: data sources + resolvers."""
2
+ from .config import Action, AppConfig, DataSource
3
+ from .resolver import (
4
+ McpResolver,
5
+ GraphqlResolver,
6
+ Resolver,
7
+ ResolverError,
8
+ RestResolver,
9
+ SqlResolver,
10
+ cache_clear,
11
+ get_resolver,
12
+ register_resolver,
13
+ resolve_source,
14
+ )
15
+
16
+ __all__ = [
17
+ "Action",
18
+ "AppConfig",
19
+ "DataSource",
20
+ "GraphqlResolver",
21
+ "McpResolver",
22
+ "Resolver",
23
+ "ResolverError",
24
+ "RestResolver",
25
+ "SqlResolver",
26
+ "cache_clear",
27
+ "get_resolver",
28
+ "register_resolver",
29
+ "resolve_source",
30
+ ]
@@ -0,0 +1,183 @@
1
+ """App-level registry config: data sources + actions, loaded from a YAML file.
2
+
3
+ This is the *application developer's* config — separate from the
4
+ component registry (which is the LLM-facing allow-list of UI primitives).
5
+
6
+ The config binds:
7
+ - `sources`: named data sources (REST, SQL, GraphQL, MCP) — the LLM
8
+ emits `src="q3_revenue"` and the backend resolves it here.
9
+ - `actions`: named intent → endpoint mappings — the LLM emits
10
+ `intent="refund.approve"` and the backend dispatches here.
11
+
12
+ Secrets are referenced via `${SECRET_NAME}` syntax and resolved at
13
+ load time from environment variables. Secrets never appear in prompts
14
+ or logs.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any, Literal
23
+
24
+ import yaml
25
+
26
+
27
+ _SECRET_RE = re.compile(r"\$\{([A-Z][A-Z0-9_]*)\}")
28
+
29
+
30
+ def _interpolate(value: Any, vars: dict[str, str]) -> Any:
31
+ """Replace `{key}` placeholders in any string within a nested structure.
32
+
33
+ Used to inject paths like `{config_dir}` into YAML values so config files
34
+ stay self-contained (no absolute paths leaking into the repo).
35
+ """
36
+ if isinstance(value, str):
37
+ out = value
38
+ for k, v in vars.items():
39
+ out = out.replace("{" + k + "}", v)
40
+ return out
41
+ if isinstance(value, dict):
42
+ return {k: _interpolate(v, vars) for k, v in value.items()}
43
+ if isinstance(value, list):
44
+ return [_interpolate(v, vars) for v in value]
45
+ return value
46
+
47
+
48
+ def _resolve_secrets(value: Any) -> Any:
49
+ """Recursively resolve ${SECRET_NAME} references from environment."""
50
+ if isinstance(value, str):
51
+ def repl(m: re.Match) -> str:
52
+ name = m.group(1)
53
+ v = os.environ.get(name)
54
+ if v is None:
55
+ raise ValueError(f"Secret {name!r} referenced in config but not set in environment")
56
+ return v
57
+ return _SECRET_RE.sub(repl, value)
58
+ if isinstance(value, dict):
59
+ return {k: _resolve_secrets(v) for k, v in value.items()}
60
+ if isinstance(value, list):
61
+ return [_resolve_secrets(v) for v in value]
62
+ return value
63
+
64
+
65
+ @dataclass
66
+ class DataSource:
67
+ name: str
68
+ type: Literal["rest", "sql", "graphql", "mcp", "file"]
69
+ config: dict = field(default_factory=dict)
70
+ cache_seconds: int = 0
71
+ description: str = ""
72
+
73
+ @classmethod
74
+ def from_dict(cls, name: str, d: dict) -> "DataSource":
75
+ t = d.get("type")
76
+ if t not in ("rest", "sql", "graphql", "mcp", "file"):
77
+ raise ValueError(f"Source {name!r}: unknown type {t!r}")
78
+ return cls(
79
+ name=name,
80
+ type=t,
81
+ config={k: v for k, v in d.items() if k not in ("type", "cache", "description")},
82
+ cache_seconds=int(d.get("cache", 0)),
83
+ description=d.get("description", ""),
84
+ )
85
+
86
+
87
+ @dataclass
88
+ class Action:
89
+ name: str
90
+ type: Literal["rest"] = "rest" # only REST actions for v2; SQL/MCP later
91
+ method: str = "POST"
92
+ url: str = ""
93
+ headers: dict = field(default_factory=dict)
94
+ body_template: dict = field(default_factory=dict)
95
+ bind: list[str] = field(default_factory=list) # keys the LLM may supply
96
+ confirm: bool = True
97
+ audit: bool = True
98
+ requires_role: str | None = None
99
+ description: str = ""
100
+
101
+ @classmethod
102
+ def from_dict(cls, name: str, d: dict) -> "Action":
103
+ bind = d.get("bind", [])
104
+ if not isinstance(bind, list):
105
+ raise ValueError(f"Action {name!r}: 'bind' must be a list")
106
+ return cls(
107
+ name=name,
108
+ type="rest",
109
+ method=d.get("method", "POST").upper(),
110
+ url=d.get("url", ""),
111
+ headers=d.get("headers", {}),
112
+ body_template=d.get("body", {}),
113
+ bind=bind,
114
+ confirm=bool(d.get("confirm", True)),
115
+ audit=bool(d.get("audit", True)),
116
+ requires_role=d.get("requires_role"),
117
+ description=d.get("description", ""),
118
+ )
119
+
120
+
121
+ @dataclass
122
+ class AppConfig:
123
+ sources: dict[str, DataSource] = field(default_factory=dict)
124
+ actions: dict[str, Action] = field(default_factory=dict)
125
+ version: int = 1
126
+
127
+ def source(self, name: str) -> DataSource | None:
128
+ return self.sources.get(name)
129
+
130
+ def action(self, name: str) -> Action | None:
131
+ return self.actions.get(name)
132
+
133
+ def source_names(self) -> list[str]:
134
+ return sorted(self.sources.keys())
135
+
136
+ def action_names(self) -> list[str]:
137
+ return sorted(self.actions.keys())
138
+
139
+ @classmethod
140
+ def from_dict(cls, d: dict) -> "AppConfig":
141
+ d = _resolve_secrets(d)
142
+ sources = {n: DataSource.from_dict(n, s) for n, s in d.get("sources", {}).items()}
143
+ actions = {n: Action.from_dict(n, a) for n, a in d.get("actions", {}).items()}
144
+ return cls(sources=sources, actions=actions, version=d.get("version", 1))
145
+
146
+ @classmethod
147
+ def from_file(cls, path: str | Path) -> "AppConfig":
148
+ p = Path(path).resolve()
149
+ data = yaml.safe_load(p.read_text())
150
+ # interpolate {config_dir} in string values so paths stay relative to the YAML file
151
+ config_dir = str(p.parent)
152
+ data = _interpolate(data, {"config_dir": config_dir})
153
+ return cls.from_dict(data)
154
+
155
+ @classmethod
156
+ def empty(cls) -> "AppConfig":
157
+ return cls()
158
+
159
+ def to_prompt_section(self) -> str:
160
+ """Render the data sources + actions as sections of the LLM prompt.
161
+
162
+ The LLM only sees intent/source names — never URLs, never secrets.
163
+ This is the security boundary for writes and the grounding layer
164
+ for data.
165
+ """
166
+ lines: list[str] = []
167
+ if self.sources:
168
+ lines.append("Data sources (use `src=\"name\"` to bind a component to live data):")
169
+ lines.append("")
170
+ for s in self.sources.values():
171
+ desc = f" — {s.description}" if s.description else ""
172
+ lines.append(f"- {s.name} ({s.type}){desc}")
173
+ lines.append("")
174
+ if self.actions:
175
+ lines.append("Available actions (use `intent=\"name\"` on a Button):")
176
+ lines.append("")
177
+ for a in self.actions.values():
178
+ bind = ", ".join(a.bind) if a.bind else "(no args)"
179
+ conf = " [requires confirmation]" if a.confirm else ""
180
+ desc = f" — {a.description}" if a.description else ""
181
+ lines.append(f"- {a.name}({bind}){conf}{desc}")
182
+ lines.append("")
183
+ return "\n".join(lines)