hypereffect-ui 0.1.0__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,11 @@
1
+ /rust/target/
2
+ /site/demo/wasm/
3
+ /site/demo/lib/
4
+ node_modules/
5
+ /ts/dist/
6
+ /python/dist/
7
+ __pycache__/
8
+ .venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.5
2
+ Name: hypereffect-ui
3
+ Version: 0.1.0
4
+ Summary: Types and helpers for writing hypereffect servers: messages, line framing, signals and components, an App/Server session driver, and a renderer client with a loopback for tests.
5
+ Project-URL: Homepage, https://hypereffect.dev
6
+ Project-URL: Repository, https://github.com/hypereffect/hypereffect
7
+ Project-URL: Issues, https://github.com/hypereffect/hypereffect/issues
8
+ License-Expression: MIT
9
+ Keywords: cel,gui,hypermedia,server-driven-ui,ui
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: cel-python<0.6,>=0.5
12
+ Description-Content-Type: text/markdown
13
+
14
+ # hypereffect (Python)
15
+
16
+ Types and helpers for writing [hypereffect](https://hypereffect.dev) servers in
17
+ Python, ported from the [Rust crate](https://github.com/hypereffect/hypereffect/tree/master/rust):
18
+ elements, handlers and messages with their line framing, signals, the
19
+ components and their nesting rules, an `App`/`Server` session driver, and a
20
+ renderer client with a loopback for testing apps without a renderer.
21
+
22
+ ```
23
+ pip install hypereffect-ui
24
+ ```
25
+
26
+ The distribution is `hypereffect-ui`; the package is imported as
27
+ `hypereffect`. It needs Python 3.11 or later.
28
+
29
+ ## Servers
30
+
31
+ ```python
32
+ from hypereffect import Action, App, Handler, Outbox, cel, ui
33
+
34
+
35
+ class Counter(App):
36
+ def __init__(self) -> None:
37
+ self.count = 0
38
+
39
+ def hello(self, out: Outbox, params: dict[str, str]) -> None:
40
+ out.signal({"count": 0})
41
+ out.element(ui.column("main"))
42
+ out.element(ui.text("value").parent("main").text(cel("'Count: ' + string(s.count)")))
43
+ out.element(ui.button("inc").parent("main").label("+").on_press(Handler(action="add")))
44
+
45
+ def action(self, out: Outbox, action: Action) -> None:
46
+ if action.name == "add":
47
+ self.count += 1
48
+ out.signal({"count": self.count})
49
+ ```
50
+
51
+ - `ui.text("id")` and the other constructors return an `Element` with setters
52
+ for every attribute (`label`, `variant`, `disabled`, …, or `set` for any
53
+ other); each takes a literal or `cel("...")`. `bind` sets a signal path, and
54
+ `on_press`, `on_change`, `on_submit` and `on_dismiss` take a `Handler`,
55
+ built with keywords: `Handler(action="save", payload=7, indicator="_busy")`,
56
+ `Handler(patch={"_open": True})` or `Handler(open="https://…")`.
57
+ - `parse_server_message`, `parse_renderer_message` and each message's
58
+ `to_line` convert to and from lines; `LineBuffer` splits a stream of bytes
59
+ or text into lines.
60
+ - `hypereffect.signals` has merge patch, local-signal stripping and signal
61
+ paths.
62
+ - `Server` drives an `App` from what a renderer sends, creating a new app on
63
+ every hello. An app that overrides `tick_interval` gets `tick` calls.
64
+
65
+ The library does not open connections. Any transport that carries lines
66
+ works: pass what arrives to `server.receive`, then send
67
+ `server.output.take()`. Over stdio:
68
+
69
+ ```python
70
+ import sys
71
+
72
+ server = Server(Counter)
73
+ for chunk in iter(sys.stdin.buffer.readline, b""):
74
+ server.receive(chunk)
75
+ sys.stdout.write(server.output.take())
76
+ sys.stdout.flush()
77
+ ```
78
+
79
+ ## Testing
80
+
81
+ `hypereffect.client` is the renderer side without drawing, on top of
82
+ [cel-python](https://github.com/cloud-custodian/cel-python).
83
+ `Loopback` connects it to a `Server` in one process:
84
+
85
+ ```python
86
+ from hypereffect.client import Loopback
87
+
88
+
89
+ def test_counter() -> None:
90
+ l = Loopback(Counter) # sends hello
91
+ assert l.trigger("inc", "onPress") # True if an action was sent
92
+ assert l.client.attr("value", "text") == "Count: 1"
93
+ assert l.client.take_diagnostics() == [] # rejected elements or failed expressions
94
+ ```
95
+
96
+ `l.write(path, value)` writes a bound signal as a control does,
97
+ `l.tick(now_ms)` runs the timer, `l.client.value(path)` reads a signal and
98
+ `l.client.take_opens()` returns the URLs buttons asked to open.
99
+ `examples/todos.py` is the task list demo; `tests/test_todos.py` tests it.
100
+
101
+ cel-python lacks parts of the CEL environment the spec requires, so
102
+ `hypereffect._cel` adds them: comparison of ints and doubles by value, the
103
+ strings, math and lists extensions (following cel-go), the `sortBy` macro,
104
+ and the `math.`, `strings.` and `lists.` namespaces. It relies on
105
+ cel-python's evaluator internals, so the dependency is pinned to 0.5.x.
106
+
107
+ There is no WebAssembly export like the Rust `wasm_app!`.
108
+
109
+ ## Tests
110
+
111
+ ```
112
+ uv run pytest
113
+ uv run mypy
114
+ ```
115
+
116
+ The tests include the shared renderer fixtures in the repository's
117
+ [`fixtures/`](https://github.com/hypereffect/hypereffect/tree/master/fixtures).
@@ -0,0 +1,104 @@
1
+ # hypereffect (Python)
2
+
3
+ Types and helpers for writing [hypereffect](https://hypereffect.dev) servers in
4
+ Python, ported from the [Rust crate](https://github.com/hypereffect/hypereffect/tree/master/rust):
5
+ elements, handlers and messages with their line framing, signals, the
6
+ components and their nesting rules, an `App`/`Server` session driver, and a
7
+ renderer client with a loopback for testing apps without a renderer.
8
+
9
+ ```
10
+ pip install hypereffect-ui
11
+ ```
12
+
13
+ The distribution is `hypereffect-ui`; the package is imported as
14
+ `hypereffect`. It needs Python 3.11 or later.
15
+
16
+ ## Servers
17
+
18
+ ```python
19
+ from hypereffect import Action, App, Handler, Outbox, cel, ui
20
+
21
+
22
+ class Counter(App):
23
+ def __init__(self) -> None:
24
+ self.count = 0
25
+
26
+ def hello(self, out: Outbox, params: dict[str, str]) -> None:
27
+ out.signal({"count": 0})
28
+ out.element(ui.column("main"))
29
+ out.element(ui.text("value").parent("main").text(cel("'Count: ' + string(s.count)")))
30
+ out.element(ui.button("inc").parent("main").label("+").on_press(Handler(action="add")))
31
+
32
+ def action(self, out: Outbox, action: Action) -> None:
33
+ if action.name == "add":
34
+ self.count += 1
35
+ out.signal({"count": self.count})
36
+ ```
37
+
38
+ - `ui.text("id")` and the other constructors return an `Element` with setters
39
+ for every attribute (`label`, `variant`, `disabled`, …, or `set` for any
40
+ other); each takes a literal or `cel("...")`. `bind` sets a signal path, and
41
+ `on_press`, `on_change`, `on_submit` and `on_dismiss` take a `Handler`,
42
+ built with keywords: `Handler(action="save", payload=7, indicator="_busy")`,
43
+ `Handler(patch={"_open": True})` or `Handler(open="https://…")`.
44
+ - `parse_server_message`, `parse_renderer_message` and each message's
45
+ `to_line` convert to and from lines; `LineBuffer` splits a stream of bytes
46
+ or text into lines.
47
+ - `hypereffect.signals` has merge patch, local-signal stripping and signal
48
+ paths.
49
+ - `Server` drives an `App` from what a renderer sends, creating a new app on
50
+ every hello. An app that overrides `tick_interval` gets `tick` calls.
51
+
52
+ The library does not open connections. Any transport that carries lines
53
+ works: pass what arrives to `server.receive`, then send
54
+ `server.output.take()`. Over stdio:
55
+
56
+ ```python
57
+ import sys
58
+
59
+ server = Server(Counter)
60
+ for chunk in iter(sys.stdin.buffer.readline, b""):
61
+ server.receive(chunk)
62
+ sys.stdout.write(server.output.take())
63
+ sys.stdout.flush()
64
+ ```
65
+
66
+ ## Testing
67
+
68
+ `hypereffect.client` is the renderer side without drawing, on top of
69
+ [cel-python](https://github.com/cloud-custodian/cel-python).
70
+ `Loopback` connects it to a `Server` in one process:
71
+
72
+ ```python
73
+ from hypereffect.client import Loopback
74
+
75
+
76
+ def test_counter() -> None:
77
+ l = Loopback(Counter) # sends hello
78
+ assert l.trigger("inc", "onPress") # True if an action was sent
79
+ assert l.client.attr("value", "text") == "Count: 1"
80
+ assert l.client.take_diagnostics() == [] # rejected elements or failed expressions
81
+ ```
82
+
83
+ `l.write(path, value)` writes a bound signal as a control does,
84
+ `l.tick(now_ms)` runs the timer, `l.client.value(path)` reads a signal and
85
+ `l.client.take_opens()` returns the URLs buttons asked to open.
86
+ `examples/todos.py` is the task list demo; `tests/test_todos.py` tests it.
87
+
88
+ cel-python lacks parts of the CEL environment the spec requires, so
89
+ `hypereffect._cel` adds them: comparison of ints and doubles by value, the
90
+ strings, math and lists extensions (following cel-go), the `sortBy` macro,
91
+ and the `math.`, `strings.` and `lists.` namespaces. It relies on
92
+ cel-python's evaluator internals, so the dependency is pinned to 0.5.x.
93
+
94
+ There is no WebAssembly export like the Rust `wasm_app!`.
95
+
96
+ ## Tests
97
+
98
+ ```
99
+ uv run pytest
100
+ uv run mypy
101
+ ```
102
+
103
+ The tests include the shared renderer fixtures in the repository's
104
+ [`fixtures/`](https://github.com/hypereffect/hypereffect/tree/master/fixtures).
File without changes
@@ -0,0 +1,168 @@
1
+ """A task list: adding, checking off and deleting tasks, a local filter, and
2
+ a confirmation dialog. The Python port of the demo on hypereffect.dev."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+
8
+ from hypereffect import Action, App, Element, Handler, Json, Outbox, cel, ui
9
+
10
+
11
+ @dataclass
12
+ class Task:
13
+ id: str
14
+ title: str
15
+ done: bool
16
+
17
+
18
+ class Todos(App):
19
+ def __init__(self) -> None:
20
+ self.next = 1
21
+ self.tasks = [
22
+ Task(self.new_id(), title, done)
23
+ for title, done in [("Water the plants", True), ("Call the bank", False), ("Read the spec", False)]
24
+ ]
25
+
26
+ def new_id(self) -> str:
27
+ id = f"t{self.next}"
28
+ self.next += 1
29
+ return id
30
+
31
+ def row(self, task: Task) -> list[Element]:
32
+ id = task.id
33
+ done = f"s.done.{id}"
34
+ return [
35
+ ui.row(id)
36
+ .parent("items")
37
+ .justify("between")
38
+ .visible(cel(f"s._filter == 'all' || (s._filter == 'done') == {done}")),
39
+ ui.checkbox(f"{id}-check")
40
+ .parent(id)
41
+ .label(task.title)
42
+ .bind(f"done.{id}")
43
+ .on_change(Handler(action="toggle", payload=id)),
44
+ ui.button(f"{id}-del")
45
+ .parent(id)
46
+ .label("Delete")
47
+ .variant("plain")
48
+ .on_press(Handler(action="delete", payload=id)),
49
+ ]
50
+
51
+ def hello(self, out: Outbox, params: dict[str, str]) -> None:
52
+ out.signal({
53
+ "new": "",
54
+ "done": {t.id: t.done for t in self.tasks},
55
+ "_filter": "all",
56
+ "_adding": False,
57
+ "_confirm": False,
58
+ "_clearing": False,
59
+ })
60
+ out.element(ui.card("todos").title("Tasks"))
61
+ out.element(ui.row("add-row").parent("todos"))
62
+ out.element(
63
+ ui.input("new")
64
+ .parent("add-row")
65
+ .placeholder("New task")
66
+ .bind("new")
67
+ .error(cel("size(s.new) > 80 ? 'Keep it under 80 characters.' : ''"))
68
+ .on_submit(Handler(action="add", indicator="_adding"))
69
+ )
70
+ out.element(
71
+ ui.button("add")
72
+ .parent("add-row")
73
+ .variant("primary")
74
+ .label(cel("s._adding ? 'Adding…' : 'Add'"))
75
+ .disabled(cel("s._adding || s.new.trim() == '' || size(s.new) > 80"))
76
+ .on_press(Handler(action="add", indicator="_adding"))
77
+ )
78
+ out.element(
79
+ ui.select("filter")
80
+ .parent("todos")
81
+ .variant("segmented")
82
+ .bind("_filter")
83
+ .options([
84
+ {"value": "all", "label": "All"},
85
+ {"value": "open", "label": "Open"},
86
+ {"value": "done", "label": "Done"},
87
+ ])
88
+ )
89
+ out.element(ui.list("items").parent("todos").empty("Nothing here."))
90
+ for t in self.tasks:
91
+ for e in self.row(t):
92
+ out.element(e)
93
+ out.element(ui.row("footer").parent("todos").justify("between"))
94
+ out.element(ui.text("left").parent("footer").variant("caption").text(cel(
95
+ "string(s.done.filter(k, !s.done[k]).size()) + ' of ' + string(size(s.done)) + ' left'"
96
+ )))
97
+ out.element(
98
+ ui.button("clear")
99
+ .parent("footer")
100
+ .variant("failure")
101
+ .label("Clear completed…")
102
+ .disabled(cel("!s.done.exists(k, s.done[k]) || s._clearing"))
103
+ .on_press(Handler(patch={"_confirm": True}))
104
+ )
105
+ out.element(
106
+ ui.dialog("confirm")
107
+ .title("Clear completed tasks?")
108
+ .open(cel("s._confirm"))
109
+ .on_dismiss(Handler(patch={"_confirm": False}))
110
+ )
111
+ out.element(ui.text("confirm-text").parent("confirm").text(cel(
112
+ "string(s.done.filter(k, s.done[k]).size()) + ' tasks will be deleted.'"
113
+ )))
114
+ out.element(ui.row("confirm-row").parent("confirm").justify("end"))
115
+ out.element(
116
+ ui.button("confirm-no")
117
+ .parent("confirm-row")
118
+ .label("Cancel")
119
+ .on_press(Handler(patch={"_confirm": False}))
120
+ )
121
+ out.element(
122
+ ui.button("confirm-yes")
123
+ .parent("confirm-row")
124
+ .variant("failure")
125
+ .label("Delete")
126
+ .on_press(Handler(action="clear", patch={"_confirm": False}, indicator="_clearing"))
127
+ )
128
+
129
+ def find(self, id: Json) -> Task | None:
130
+ return next((t for t in self.tasks if t.id == id), None)
131
+
132
+ def action(self, out: Outbox, action: Action) -> None:
133
+ if action.name == "add":
134
+ raw = action.signal("new")
135
+ title = raw.strip() if isinstance(raw, str) else ""
136
+ if not title or len(title) > 80:
137
+ out.signal({"_adding": False})
138
+ return
139
+ task = Task(self.new_id(), title, False)
140
+ # New tasks go at the top.
141
+ first = self.tasks[0].id if self.tasks else None
142
+ # The signal comes first so that the row's expressions can read
143
+ # done.<id> as soon as it arrives.
144
+ out.signal({"new": "", "done": {task.id: False}, "_adding": False})
145
+ for i, e in enumerate(self.row(task)):
146
+ out.element(e.before(first) if i == 0 and first else e)
147
+ self.tasks.insert(0, task)
148
+ elif action.name == "toggle":
149
+ task = self.find(action.payload)
150
+ if task is None:
151
+ return
152
+ done = action.signal(f"done.{task.id}")
153
+ if isinstance(done, bool):
154
+ task.done = done
155
+ out.signal({"done": {task.id: task.done}})
156
+ elif action.name == "delete":
157
+ task = self.find(action.payload)
158
+ if task is None:
159
+ return
160
+ self.tasks.remove(task)
161
+ out.remove(task.id)
162
+ out.signal({"done": {task.id: None}})
163
+ elif action.name == "clear":
164
+ for t in self.tasks:
165
+ if t.done:
166
+ out.remove(t.id)
167
+ out.signal({"done": {t.id: None for t in self.tasks if t.done}, "_clearing": False})
168
+ self.tasks = [t for t in self.tasks if not t.done]
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "hypereffect-ui"
3
+ version = "0.1.0"
4
+ description = "Types and helpers for writing hypereffect servers: messages, line framing, signals and components, an App/Server session driver, and a renderer client with a loopback for tests."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ dependencies = ["cel-python>=0.5,<0.6"]
9
+ keywords = ["hypermedia", "gui", "ui", "server-driven-ui", "cel"]
10
+
11
+ [project.urls]
12
+ Homepage = "https://hypereffect.dev"
13
+ Repository = "https://github.com/hypereffect/hypereffect"
14
+ Issues = "https://github.com/hypereffect/hypereffect/issues"
15
+
16
+ [dependency-groups]
17
+ dev = ["pytest>=8", "mypy>=1.10"]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+ pythonpath = ["."]
26
+
27
+ [tool.mypy]
28
+ strict = true
29
+ files = ["src"]
30
+
31
+ [[tool.mypy.overrides]]
32
+ module = ["celpy", "celpy.*", "lark", "lark.*"]
33
+ ignore_missing_imports = true
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/hypereffect"]
@@ -0,0 +1,45 @@
1
+ """Types and helpers for `hypereffect <https://hypereffect.dev>`_, a small
2
+ hypermedia protocol for server-driven UI.
3
+
4
+ - Elements, handlers, server and renderer messages, and the line framing.
5
+ Building an interface looks like
6
+ ``ui.text("title").parent("main").variant("h1").text("Inbox")``.
7
+ - :mod:`hypereffect.signals`: merge patch, local signals and signal paths.
8
+ - The components, their attributes and nesting rules.
9
+ - An :class:`App` base class for per-session logic, and a :class:`Server`
10
+ that drives one from any transport.
11
+ - :mod:`hypereffect.client`: the renderer side without drawing, and a
12
+ ``Loopback`` that connects it to a Server for tests.
13
+ """
14
+
15
+ from . import signals, ui
16
+ from .components import Attr, AttrType, Component, accepts
17
+ from .message import (
18
+ Action,
19
+ Element,
20
+ Handler,
21
+ Hello,
22
+ Json,
23
+ LineBuffer,
24
+ Metadata,
25
+ ParseError,
26
+ ParseErrorKind,
27
+ RendererMessage,
28
+ Remove,
29
+ ServerMessage,
30
+ Signal,
31
+ cel,
32
+ encode_line,
33
+ is_cel,
34
+ parse_line,
35
+ parse_renderer_message,
36
+ parse_server_message,
37
+ )
38
+ from .server import App, Outbox, Server
39
+
40
+ __all__ = [
41
+ "Action", "App", "Attr", "AttrType", "Component", "Element", "Handler", "Hello", "Json",
42
+ "LineBuffer", "Metadata", "Outbox", "ParseError", "ParseErrorKind", "RendererMessage",
43
+ "Remove", "Server", "ServerMessage", "Signal", "accepts", "cel", "encode_line", "is_cel",
44
+ "parse_line", "parse_renderer_message", "parse_server_message", "signals", "ui",
45
+ ]