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.
- evaleval-0.2.3/.github/workflows/publish.yml +23 -0
- evaleval-0.2.3/.gitignore +2 -0
- evaleval-0.2.3/PKG-INFO +128 -0
- evaleval-0.2.3/README.md +111 -0
- evaleval-0.2.3/pyproject.toml +33 -0
- evaleval-0.2.3/scripts/release.clj +81 -0
- evaleval-0.2.3/src/evaleval/__init__.py +26 -0
- evaleval-0.2.3/src/evaleval/hiccup.py +96 -0
- evaleval-0.2.3/src/evaleval/patch.py +126 -0
- evaleval-0.2.3/src/evaleval/signing.py +105 -0
- evaleval-0.2.3/src/evaleval/sse.py +51 -0
- evaleval-0.2.3/uv.lock +8 -0
|
@@ -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
|
evaleval-0.2.3/PKG-INFO
ADDED
|
@@ -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`
|
evaleval-0.2.3/README.md
ADDED
|
@@ -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
|
+
)
|