evaleval 0.2.3__tar.gz

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.
@@ -0,0 +1,23 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ environment: pypi
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: astral-sh/setup-uv@v4
18
+
19
+ - name: Build
20
+ run: uv build
21
+
22
+ - name: Publish to PyPI
23
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,2 @@
1
+ __pycache__/
2
+ .venv/
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: evaleval
3
+ Version: 0.2.3
4
+ Summary: Signed snippets, hiccup, and a dual-eval loop. The web that should have been.
5
+ Project-URL: Homepage, https://github.com/tommy-mor/evaleval
6
+ Project-URL: Repository, https://github.com/tommy-mor/evaleval
7
+ Author: Tommy Morris
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: FastAPI
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet :: WWW/HTTP
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+
18
+ # evaleval
19
+
20
+ `evaleval` is a tiny server-driven web pattern: signed Python expressions in forms, eval on submit, and UI patching over SSE.
21
+
22
+ The entire client.
23
+
24
+ ```js
25
+ import { Idiomorph } from 'idiomorph';
26
+ window.Idiomorph = Idiomorph;
27
+ const es = new EventSource('/sse');
28
+ es.addEventListener('exec', e => eval(e.data));
29
+ document.addEventListener('submit', async e => {
30
+ e.preventDefault();
31
+ const r = await fetch(e.target.action, { method: 'POST', body: new FormData(e.target) });
32
+ const t = await r.text();
33
+ if (t) eval(t);
34
+ });
35
+ ```
36
+
37
+ Three endpoints. No framework.
38
+
39
+ ```
40
+ GET / — serve the shell
41
+ GET /sse — push what you see
42
+ POST / — verify, eval
43
+ ```
44
+
45
+ ## Example: [evaleval-todo](https://github.com/tommy-mor/evaleval-todo)
46
+
47
+ A todo list in ~170 lines.
48
+
49
+ Forms are data. They carry their own signed handlers.
50
+
51
+ ```python
52
+ from evaleval import Signer, Three, Two, Selector, MORPH, APPEND, REMOVE
53
+
54
+ signer = Signer()
55
+
56
+ def add_form():
57
+ return ["form", {"action": "/", "method": "post"},
58
+ *signer.snippet_hidden("add($text)"),
59
+ ["input", {"type": "text", "name": "text", "placeholder": "what needs doing?"}],
60
+ ["button", {"type": "submit"}, "add"],
61
+ ]
62
+ ```
63
+
64
+ Sandbox functions return JS patch chains. The chains say how many parts they have, then become strings and disappear.
65
+
66
+ ```python
67
+ def add(text):
68
+ t = {"id": uuid.uuid4().hex[:8], "text": text, "done": False}
69
+ TODOS.append(t)
70
+ return PlainTextResponse(";".join([
71
+ Three[Selector("#add-form")][MORPH][add_form()],
72
+ Three[Selector("#todo-list")][APPEND][todo_item(t)],
73
+ Three[Selector("p.count")][MORPH][remaining_count()],
74
+ ]))
75
+
76
+ def delete(todo_id):
77
+ TODOS.remove(_find(todo_id))
78
+ return PlainTextResponse(";".join([
79
+ Two[Selector(f"#todo-{todo_id}")][REMOVE],
80
+ Three[Selector("p.count")][MORPH][remaining_count()],
81
+ ]))
82
+ ```
83
+
84
+ The `POST /` route verifies the signature and evals the snippet. `verify_snippet` raises `SnippetExecutionError` with a status code.
85
+
86
+ ```python
87
+ from evaleval import SnippetExecutionError
88
+
89
+ @app.post("/")
90
+ async def do(request):
91
+ form = await request.form()
92
+ try:
93
+ snippet = signer.verify_snippet(form)
94
+ return eval(snippet)
95
+ except SnippetExecutionError as e:
96
+ return PlainTextResponse(e.message, status_code=e.status_code)
97
+ except Exception as e:
98
+ return PlainTextResponse(str(e), status_code=500)
99
+ ```
100
+
101
+ `eval` only accepts a single expression. Multi-line snippets and `return ...` statements will fail with `SyntaxError`.
102
+
103
+ If you want side effects plus a return value, use an expression pattern like:
104
+
105
+ ```python
106
+ (print("form callback for add todo", $text), add($text))[1]
107
+ ```
108
+
109
+ SSE pushes the initial page as JS the browser evals.
110
+
111
+ ```python
112
+ from evaleval import exec_event, shell_html, One, Eval
113
+
114
+ @app.get("/")
115
+ async def index():
116
+ return HTMLResponse(shell_html())
117
+
118
+ @app.get("/sse")
119
+ async def sse(request):
120
+ async def generate():
121
+ yield exec_event([
122
+ One[Eval("document.title = 'todos'")],
123
+ Three[Selector("body")][MORPH][["body", page()]],
124
+ ])
125
+ return StreamingResponse(generate(), media_type="text/event-stream")
126
+ ```
127
+
128
+ `pip install evaleval`
@@ -0,0 +1,111 @@
1
+ # evaleval
2
+
3
+ `evaleval` is a tiny server-driven web pattern: signed Python expressions in forms, eval on submit, and UI patching over SSE.
4
+
5
+ The entire client.
6
+
7
+ ```js
8
+ import { Idiomorph } from 'idiomorph';
9
+ window.Idiomorph = Idiomorph;
10
+ const es = new EventSource('/sse');
11
+ es.addEventListener('exec', e => eval(e.data));
12
+ document.addEventListener('submit', async e => {
13
+ e.preventDefault();
14
+ const r = await fetch(e.target.action, { method: 'POST', body: new FormData(e.target) });
15
+ const t = await r.text();
16
+ if (t) eval(t);
17
+ });
18
+ ```
19
+
20
+ Three endpoints. No framework.
21
+
22
+ ```
23
+ GET / — serve the shell
24
+ GET /sse — push what you see
25
+ POST / — verify, eval
26
+ ```
27
+
28
+ ## Example: [evaleval-todo](https://github.com/tommy-mor/evaleval-todo)
29
+
30
+ A todo list in ~170 lines.
31
+
32
+ Forms are data. They carry their own signed handlers.
33
+
34
+ ```python
35
+ from evaleval import Signer, Three, Two, Selector, MORPH, APPEND, REMOVE
36
+
37
+ signer = Signer()
38
+
39
+ def add_form():
40
+ return ["form", {"action": "/", "method": "post"},
41
+ *signer.snippet_hidden("add($text)"),
42
+ ["input", {"type": "text", "name": "text", "placeholder": "what needs doing?"}],
43
+ ["button", {"type": "submit"}, "add"],
44
+ ]
45
+ ```
46
+
47
+ Sandbox functions return JS patch chains. The chains say how many parts they have, then become strings and disappear.
48
+
49
+ ```python
50
+ def add(text):
51
+ t = {"id": uuid.uuid4().hex[:8], "text": text, "done": False}
52
+ TODOS.append(t)
53
+ return PlainTextResponse(";".join([
54
+ Three[Selector("#add-form")][MORPH][add_form()],
55
+ Three[Selector("#todo-list")][APPEND][todo_item(t)],
56
+ Three[Selector("p.count")][MORPH][remaining_count()],
57
+ ]))
58
+
59
+ def delete(todo_id):
60
+ TODOS.remove(_find(todo_id))
61
+ return PlainTextResponse(";".join([
62
+ Two[Selector(f"#todo-{todo_id}")][REMOVE],
63
+ Three[Selector("p.count")][MORPH][remaining_count()],
64
+ ]))
65
+ ```
66
+
67
+ The `POST /` route verifies the signature and evals the snippet. `verify_snippet` raises `SnippetExecutionError` with a status code.
68
+
69
+ ```python
70
+ from evaleval import SnippetExecutionError
71
+
72
+ @app.post("/")
73
+ async def do(request):
74
+ form = await request.form()
75
+ try:
76
+ snippet = signer.verify_snippet(form)
77
+ return eval(snippet)
78
+ except SnippetExecutionError as e:
79
+ return PlainTextResponse(e.message, status_code=e.status_code)
80
+ except Exception as e:
81
+ return PlainTextResponse(str(e), status_code=500)
82
+ ```
83
+
84
+ `eval` only accepts a single expression. Multi-line snippets and `return ...` statements will fail with `SyntaxError`.
85
+
86
+ If you want side effects plus a return value, use an expression pattern like:
87
+
88
+ ```python
89
+ (print("form callback for add todo", $text), add($text))[1]
90
+ ```
91
+
92
+ SSE pushes the initial page as JS the browser evals.
93
+
94
+ ```python
95
+ from evaleval import exec_event, shell_html, One, Eval
96
+
97
+ @app.get("/")
98
+ async def index():
99
+ return HTMLResponse(shell_html())
100
+
101
+ @app.get("/sse")
102
+ async def sse(request):
103
+ async def generate():
104
+ yield exec_event([
105
+ One[Eval("document.title = 'todos'")],
106
+ Three[Selector("body")][MORPH][["body", page()]],
107
+ ])
108
+ return StreamingResponse(generate(), media_type="text/event-stream")
109
+ ```
110
+
111
+ `pip install evaleval`
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "evaleval"
3
+ version = "0.2.3"
4
+ description = "Signed snippets, hiccup, and a dual-eval loop. The web that should have been."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ authors = [
9
+ {name = "Tommy Morris"}
10
+ ]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Framework :: FastAPI",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Internet :: WWW/HTTP",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/tommy-mor/evaleval"
22
+ Repository = "https://github.com/tommy-mor/evaleval"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/evaleval"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ addopts = "-v"
@@ -0,0 +1,81 @@
1
+ (ns scripts.release
2
+ (:require [babashka.process :as p]
3
+ [clojure.string :as str]))
4
+
5
+ (defn bump-version [version bump-type]
6
+ (let [[major minor patch] (map #(Integer/parseInt %) (str/split version #"\."))
7
+ [new-major new-minor new-patch]
8
+ (case bump-type
9
+ "major" [(inc major) 0 0]
10
+ "minor" [major (inc minor) 0]
11
+ "patch" [major minor (inc patch)]
12
+ [major minor (inc patch)])]
13
+ (str new-major "." new-minor "." new-patch)))
14
+
15
+ (defn update-pyproject-version [version]
16
+ (let [path "pyproject.toml"
17
+ content (slurp path)
18
+ updated (str/replace content
19
+ #"(?m)^version\s*=\s*\"[^\"]*\""
20
+ (str "version = \"" version "\""))]
21
+ (spit path updated)))
22
+
23
+ (defn release [& args]
24
+ (let [bump-type-or-version (first args)
25
+ explicit-version (when (and bump-type-or-version
26
+ (re-matches #"^\d+\.\d+\.\d+$" bump-type-or-version))
27
+ bump-type-or-version)
28
+ bump-type (if explicit-version "patch" (or bump-type-or-version "patch"))
29
+
30
+ latest-tag (-> (p/process ["git" "tag" "--sort=-version:refname"] {:out :string})
31
+ deref
32
+ :out
33
+ str/trim
34
+ (str/split-lines)
35
+ first
36
+ (or ""))
37
+ latest-version (if (empty? latest-tag)
38
+ "0.0.0"
39
+ (str/replace latest-tag #"^v" ""))
40
+
41
+ version (if explicit-version
42
+ (str/replace explicit-version #"^v" "")
43
+ (bump-version latest-version bump-type))
44
+ tag (str "v" version)]
45
+
46
+ ;; Check for clean working tree
47
+ (let [result @(p/process ["git" "diff" "--quiet"] {:continue true})]
48
+ (when (not= 0 (:exit result))
49
+ (println "error: working tree has uncommitted changes — commit or stash first")
50
+ (System/exit 1)))
51
+ (let [result @(p/process ["git" "diff" "--cached" "--quiet"] {:continue true})]
52
+ (when (not= 0 (:exit result))
53
+ (println "error: working tree has staged uncommitted changes — commit or stash first")
54
+ (System/exit 1)))
55
+
56
+ (when (not explicit-version)
57
+ (println (str "Latest version: " latest-version))
58
+ (println (str "Bumping " bump-type " version to: " version)))
59
+
60
+ ;; Update version in pyproject.toml, commit, tag, push
61
+ (update-pyproject-version version)
62
+ @(p/process ["git" "add" "pyproject.toml"] {:inherit true})
63
+ @(p/process ["git" "commit" "-m" (str "Release " tag)] {:inherit true})
64
+
65
+ (println (str "Tagging " tag " and pushing to origin..."))
66
+ @(p/process ["git" "tag" "-a" tag "-m" (str "Release " tag)] {:inherit true})
67
+ @(p/process ["git" "push" "origin" "main" tag] {:inherit true})
68
+
69
+ ;; Create GitHub release (triggers the publish workflow)
70
+ (println (str "Creating GitHub release " tag "..."))
71
+ @(p/process ["gh" "release" "create" tag
72
+ "--title" tag
73
+ "--notes" (str "Release " tag)]
74
+ {:inherit true})
75
+
76
+ (println "")
77
+ (println (str "Done. " tag " released."))
78
+ (println "Watch the publish workflow at:")
79
+ (println " https://github.com/tommy-mor/evaleval/actions")))
80
+
81
+ (apply release *command-line-args*)
@@ -0,0 +1,26 @@
1
+ from evaleval.hiccup import render, RawContent, parse_tag
2
+ from evaleval.patch import (
3
+ Selector, Eval, Action,
4
+ MORPH, PREPEND, APPEND, REMOVE, OUTER, CLASSES, ADD, TOGGLE,
5
+ DepthChain, One, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten,
6
+ )
7
+ from evaleval.signing import (
8
+ Signer,
9
+ SnippetExecutionError,
10
+ scrub,
11
+ apply_snippet_substitutions,
12
+ )
13
+ from evaleval.sse import exec_event, shell_html
14
+
15
+ __all__ = [
16
+ # hiccup
17
+ "render", "RawContent", "parse_tag",
18
+ # patch
19
+ "Selector", "Eval", "Action",
20
+ "MORPH", "PREPEND", "APPEND", "REMOVE", "OUTER", "CLASSES", "ADD", "TOGGLE",
21
+ "DepthChain", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten",
22
+ # signing
23
+ "Signer", "SnippetExecutionError", "scrub", "apply_snippet_substitutions",
24
+ # sse
25
+ "exec_event", "shell_html",
26
+ ]
@@ -0,0 +1,96 @@
1
+ from html import escape
2
+
3
+
4
+ VOID_ELEMENTS = {
5
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
6
+ 'link', 'meta', 'param', 'source', 'track', 'wbr'
7
+ }
8
+
9
+
10
+ class RawContent:
11
+ """Wrapper for content that should not be HTML-escaped.
12
+ Use with caution - only for trusted content like inline scripts/styles."""
13
+ def __init__(self, content):
14
+ self.content = content
15
+
16
+
17
+ def parse_tag(tag_str):
18
+ """Parse 'div.class1.class2#id' into tag, id, classes."""
19
+ parts = tag_str.split('#')
20
+ tag_and_classes = parts[0]
21
+ id_val = parts[1].split('.')[0] if len(parts) > 1 else None
22
+
23
+ class_parts = tag_and_classes.split('.')
24
+ tag = class_parts[0] or 'div'
25
+ classes = class_parts[1:] + (parts[1].split('.')[1:] if len(parts) > 1 else [])
26
+
27
+ return tag, id_val, classes
28
+
29
+
30
+ def render_attrs(attrs, id_val, classes):
31
+ """Render HTML attributes."""
32
+ parts = []
33
+
34
+ if id_val:
35
+ parts.append(f'id="{escape(id_val)}"')
36
+ elif 'id' in attrs:
37
+ parts.append(f'id="{escape(str(attrs["id"]))}"')
38
+
39
+ all_classes = classes[:]
40
+ if 'class' in attrs:
41
+ all_classes.append(attrs['class'])
42
+ if all_classes:
43
+ parts.append(f'class="{escape(" ".join(all_classes))}"')
44
+
45
+ for key, val in attrs.items():
46
+ if key not in ('id', 'class'):
47
+ parts.append(f'{escape(key)}="{escape(str(val))}"')
48
+
49
+ return ' ' + ' '.join(parts) if parts else ''
50
+
51
+
52
+ def render(data, parent_tag=None):
53
+ """Render hiccup data structure to HTML string."""
54
+ if isinstance(data, RawContent):
55
+ return data.content
56
+
57
+ if isinstance(data, str):
58
+ return escape(data)
59
+
60
+ if not isinstance(data, (list, tuple)):
61
+ return ''
62
+
63
+ if len(data) == 0:
64
+ return ''
65
+
66
+ tag_str = data[0]
67
+
68
+ if not isinstance(tag_str, str):
69
+ return ''
70
+
71
+ tag, id_val, classes = parse_tag(tag_str)
72
+
73
+ attrs = {}
74
+ children_start = 1
75
+
76
+ if len(data) > 1 and isinstance(data[1], dict):
77
+ attrs = data[1]
78
+ children_start = 2
79
+
80
+ children = data[children_start:]
81
+
82
+ flattened = []
83
+ for child in children:
84
+ if isinstance(child, list) and len(child) > 0 and isinstance(child[0], list):
85
+ flattened.extend(child)
86
+ else:
87
+ flattened.append(child)
88
+
89
+ children_html = ''.join(render(child, tag) for child in flattened)
90
+
91
+ attrs_str = render_attrs(attrs, id_val, classes)
92
+
93
+ if tag in VOID_ELEMENTS:
94
+ return f'<{tag}{attrs_str} />'
95
+ else:
96
+ return f'<{tag}{attrs_str}>{children_html}</{tag}>'
@@ -0,0 +1,126 @@
1
+ from evaleval.hiccup import render
2
+
3
+
4
+ class Selector:
5
+ def __init__(self, query): self.query = query
6
+
7
+ class Eval:
8
+ def __init__(self, code): self.code = code
9
+
10
+ class Action:
11
+ def __init__(self, name, requires=None):
12
+ self.name = name
13
+ self.requires = requires # Selector or Action that must precede this
14
+
15
+ MORPH = Action("MORPH", requires=Selector)
16
+ PREPEND = Action("PREPEND", requires=Selector)
17
+ APPEND = Action("APPEND", requires=Selector)
18
+ REMOVE = Action("REMOVE")
19
+ OUTER = Action("OUTER", requires=Selector)
20
+ CLASSES = Action("CLASSES", requires=Selector)
21
+ ADD = Action("ADD", requires=CLASSES)
22
+ TOGGLE = Action("TOGGLE", requires=CLASSES)
23
+
24
+
25
+ def _validate_step(items):
26
+ item = items[-1]
27
+ prior = items[:-1]
28
+
29
+ prior_actions = [x for x in prior if isinstance(x, Action)]
30
+ has_selector = any(isinstance(x, Selector) for x in prior)
31
+ prior_data = [x for x in prior if not isinstance(x, (Selector, Action, Eval))]
32
+
33
+ if prior_data:
34
+ raise ValueError(f"Data must be the last item in the chain")
35
+
36
+ if isinstance(item, Selector) and prior_actions:
37
+ raise ValueError(f"Selector must come before actions, not after {prior_actions[-1].name}")
38
+
39
+ if isinstance(item, Action) and item.requires is not None:
40
+ if item.requires is Selector:
41
+ if not has_selector:
42
+ raise ValueError(f"{item.name} requires a Selector before it")
43
+ elif isinstance(item.requires, Action):
44
+ if item.requires not in prior_actions:
45
+ raise ValueError(f"{item.name} requires {item.requires.name} before it")
46
+
47
+
48
+ def _resolve(items):
49
+ selector = None
50
+ actions = []
51
+ code = None
52
+ data = None
53
+
54
+ for item in items:
55
+ if isinstance(item, Selector): selector = item.query
56
+ elif isinstance(item, Action): actions.append(item)
57
+ elif isinstance(item, Eval): code = item.code
58
+ else: data = item
59
+
60
+ if selector:
61
+ safe = selector.replace("\\", "\\\\").replace('"', '\\"')
62
+ sel_js = f'document.querySelector("{safe}")'
63
+ else:
64
+ sel_js = "null"
65
+
66
+ if code:
67
+ if code.startswith("=>"):
68
+ body = code.replace("=>", "", 1).strip()
69
+ return f"(($) => {{ {body} }})({sel_js})"
70
+ return code
71
+
72
+ html = ""
73
+ if data is not None:
74
+ raw = render(data) if not isinstance(data, str) else data
75
+ html = raw.replace("`", "\\`").replace("${", "\\${")
76
+
77
+ if CLASSES in actions:
78
+ if REMOVE in actions:
79
+ return f"{sel_js}?.classList.remove('{data}')"
80
+ if ADD in actions:
81
+ return f"{sel_js}?.classList.add('{data}')"
82
+ if TOGGLE in actions:
83
+ return f"{sel_js}?.classList.toggle('{data}')"
84
+
85
+ action = actions[0] if actions else None
86
+
87
+ if action == MORPH:
88
+ return f"Idiomorph.morph({sel_js}, `{html}`)"
89
+ if action == PREPEND:
90
+ return f"{sel_js}.insertAdjacentHTML('afterbegin', `{html}`)"
91
+ if action == APPEND:
92
+ return f"{sel_js}.insertAdjacentHTML('beforeend', `{html}`)"
93
+ if action == REMOVE:
94
+ return f"{sel_js}?.remove()"
95
+ if action == OUTER:
96
+ return f"{sel_js}.outerHTML = `{html}`"
97
+
98
+ return "console.warn('unresolved patch chain')"
99
+
100
+
101
+ class DepthChain:
102
+ def __init__(self, depth, items=None):
103
+ self.depth = depth
104
+ self.items = items or []
105
+
106
+ def __getitem__(self, item):
107
+ items = self.items + [item]
108
+ _validate_step(items)
109
+ if len(items) >= self.depth:
110
+ return _resolve(items)
111
+ return DepthChain(self.depth, items)
112
+
113
+ def __str__(self):
114
+ return _resolve(self.items)
115
+
116
+
117
+ One = DepthChain(1)
118
+ Two = DepthChain(2)
119
+ Three = DepthChain(3)
120
+ Four = DepthChain(4)
121
+ Five = DepthChain(5)
122
+ Six = DepthChain(6)
123
+ Seven = DepthChain(7)
124
+ Eight = DepthChain(8)
125
+ Nine = DepthChain(9)
126
+ Ten = DepthChain(10)
@@ -0,0 +1,105 @@
1
+ import hashlib
2
+ import hmac
3
+ import uuid
4
+ import time
5
+ import base64
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ def scrub(value: str) -> str:
10
+ """Escape a form value so it can't break out of an eval context."""
11
+ return repr(value)
12
+
13
+
14
+ def apply_snippet_substitutions(snippet: str, form_data: dict[str, str]) -> str:
15
+ """Replace $key placeholders; longest keys first so $idx is not broken by $id."""
16
+ for key, value in sorted(form_data.items(), key=lambda x: len(x[0]), reverse=True):
17
+ snippet = snippet.replace(f"${key}", scrub(value))
18
+ return snippet
19
+
20
+
21
+ class Signer:
22
+ """HMAC-SHA256 snippet signing with one-time nonces.
23
+
24
+ Usage:
25
+ signer = Signer()
26
+
27
+ # At render time — embed in the form
28
+ code = "go('whale', $message)"
29
+ hidden_fields = signer.snippet_hidden(code)
30
+
31
+ # At /do time — verify and consume
32
+ if not signer.verify(snippet, nonce, sig):
33
+ return 403
34
+ if not signer.consume_nonce(nonce):
35
+ return 403
36
+ """
37
+
38
+ def __init__(self, secret: bytes | None = None, nonce_ttl: int = 3600):
39
+ self.secret = secret or hashlib.sha256(f"snippets-{uuid.uuid4()}".encode()).digest()
40
+ self.nonce_ttl = nonce_ttl
41
+ self._nonces: dict[str, float] = {}
42
+ self._last_nonce_clean: float = 0.0
43
+
44
+ def _clean_nonces(self):
45
+ now = time.time()
46
+ if now - self._last_nonce_clean < 60:
47
+ return
48
+ self._last_nonce_clean = now
49
+ for n in [n for n, exp in self._nonces.items() if exp < now]:
50
+ del self._nonces[n]
51
+
52
+ def generate_nonce(self) -> str:
53
+ self._clean_nonces()
54
+ nonce = uuid.uuid4().hex
55
+ self._nonces[nonce] = time.time() + self.nonce_ttl
56
+ return nonce
57
+
58
+ def consume_nonce(self, nonce: str) -> bool:
59
+ self._clean_nonces()
60
+ if nonce in self._nonces:
61
+ del self._nonces[nonce]
62
+ return True
63
+ return False
64
+
65
+ def sign(self, code: str, nonce: str) -> str:
66
+ msg = f"{code}|{nonce}".encode()
67
+ return base64.urlsafe_b64encode(
68
+ hmac.new(self.secret, msg, hashlib.sha256).digest()
69
+ ).decode()
70
+
71
+ def verify(self, code: str, nonce: str, sig: str) -> bool:
72
+ return hmac.compare_digest(self.sign(code, nonce), sig)
73
+
74
+ def snippet_hidden(self, code: str) -> list:
75
+ """Generate hiccup hidden input fields for a signed snippet."""
76
+ nonce = self.generate_nonce()
77
+ sig = self.sign(code, nonce)
78
+ return [
79
+ ["input", {"type": "hidden", "name": "__snippet__", "value": code}],
80
+ ["input", {"type": "hidden", "name": "__sig__", "value": sig}],
81
+ ["input", {"type": "hidden", "name": "__nonce__", "value": nonce}],
82
+ ]
83
+
84
+ def verify_snippet(self, form: Mapping[str, Any]) -> str:
85
+ """Verify signed form payload and return substituted snippet."""
86
+ snippet = str(form.get("__snippet__", ""))
87
+ sig = str(form.get("__sig__", ""))
88
+ nonce = str(form.get("__nonce__", ""))
89
+
90
+ if not all([snippet, sig, nonce]):
91
+ raise SnippetExecutionError("Missing fields", status_code=400)
92
+ if not self.verify(snippet, nonce, sig):
93
+ raise SnippetExecutionError("Invalid signature", status_code=403)
94
+ if not self.consume_nonce(nonce):
95
+ raise SnippetExecutionError("Invalid nonce", status_code=403)
96
+
97
+ form_data = {k: str(v) for k, v in form.items() if not k.startswith("__")}
98
+ return apply_snippet_substitutions(snippet, form_data)
99
+
100
+
101
+ class SnippetExecutionError(Exception):
102
+ def __init__(self, message: str, status_code: int):
103
+ super().__init__(message)
104
+ self.message = message
105
+ self.status_code = status_code
@@ -0,0 +1,51 @@
1
+ """SSE helpers for the dual-eval loop."""
2
+
3
+
4
+ def exec_event(js: str | list[str] | tuple[str, ...]) -> str:
5
+ """Wrap JavaScript code as an SSE 'exec' event."""
6
+ if isinstance(js, (list, tuple)):
7
+ js = ";".join(js)
8
+ lines = ["event: exec"]
9
+ for line in js.split('\n'):
10
+ lines.append(f"data: {line}")
11
+ lines += ["", ""]
12
+ return "\n".join(lines)
13
+
14
+
15
+ SHELL_HTML_TEMPLATE = """<!DOCTYPE html>
16
+ <html>
17
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
18
+ <body>
19
+ <script type="module">
20
+ import { Idiomorph } from '__IDIOMORPH_URL__';
21
+ window.Idiomorph = Idiomorph;
22
+ const es = new EventSource(__SSE_PATH__);
23
+ es.addEventListener('exec', e => {
24
+ eval(e.data);
25
+ });
26
+ document.addEventListener('submit', async e => {
27
+ e.preventDefault();
28
+ const f = e.target;
29
+ try {
30
+ const r = await fetch(f.action, { method: 'POST', body: new FormData(f) });
31
+ const t = await r.text();
32
+ if (t) eval(t);
33
+ } catch (err) {
34
+ console.error(err);
35
+ }
36
+ if (f.dataset.reset !== 'false') f.reset();
37
+ });
38
+ </script>
39
+ </body>
40
+ </html>"""
41
+
42
+
43
+ def shell_html(sse_path: str | None = None, idiomorph_url: str | None = None) -> str:
44
+ """Generate the shell HTML with optional custom SSE path and Idiomorph URL."""
45
+ idi = idiomorph_url or "https://unpkg.com/idiomorph@0.3.0/dist/idiomorph.esm.js"
46
+ sse = f"'{sse_path}'" if sse_path else "'/sse'"
47
+ return (
48
+ SHELL_HTML_TEMPLATE
49
+ .replace("__IDIOMORPH_URL__", idi)
50
+ .replace("__SSE_PATH__", sse)
51
+ )
evaleval-0.2.3/uv.lock ADDED
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "evaleval"
7
+ version = "0.2.2"
8
+ source = { editable = "." }