nodekit 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.
Files changed (62) hide show
  1. nodekit/.DS_Store +0 -0
  2. nodekit/__init__.py +53 -0
  3. nodekit/_internal/__init__.py +0 -0
  4. nodekit/_internal/ops/__init__.py +0 -0
  5. nodekit/_internal/ops/build_site/__init__.py +117 -0
  6. nodekit/_internal/ops/build_site/harness.j2 +158 -0
  7. nodekit/_internal/ops/concat.py +51 -0
  8. nodekit/_internal/ops/open_asset_save_asset.py +125 -0
  9. nodekit/_internal/ops/play/__init__.py +4 -0
  10. nodekit/_internal/ops/play/local_runner/__init__.py +0 -0
  11. nodekit/_internal/ops/play/local_runner/main.py +234 -0
  12. nodekit/_internal/ops/play/local_runner/site-template.j2 +38 -0
  13. nodekit/_internal/ops/save_graph_load_graph.py +131 -0
  14. nodekit/_internal/ops/topological_sorting.py +122 -0
  15. nodekit/_internal/types/__init__.py +0 -0
  16. nodekit/_internal/types/actions/__init__.py +0 -0
  17. nodekit/_internal/types/actions/actions.py +89 -0
  18. nodekit/_internal/types/assets/__init__.py +151 -0
  19. nodekit/_internal/types/cards/__init__.py +85 -0
  20. nodekit/_internal/types/events/__init__.py +0 -0
  21. nodekit/_internal/types/events/events.py +145 -0
  22. nodekit/_internal/types/expressions/__init__.py +0 -0
  23. nodekit/_internal/types/expressions/expressions.py +242 -0
  24. nodekit/_internal/types/graph.py +42 -0
  25. nodekit/_internal/types/node.py +21 -0
  26. nodekit/_internal/types/regions/__init__.py +13 -0
  27. nodekit/_internal/types/sensors/__init__.py +0 -0
  28. nodekit/_internal/types/sensors/sensors.py +156 -0
  29. nodekit/_internal/types/trace.py +17 -0
  30. nodekit/_internal/types/transition.py +68 -0
  31. nodekit/_internal/types/value.py +145 -0
  32. nodekit/_internal/utils/__init__.py +0 -0
  33. nodekit/_internal/utils/get_browser_bundle.py +35 -0
  34. nodekit/_internal/utils/get_extension_from_media_type.py +15 -0
  35. nodekit/_internal/utils/hashing.py +46 -0
  36. nodekit/_internal/utils/iter_assets.py +61 -0
  37. nodekit/_internal/version.py +1 -0
  38. nodekit/_static/nodekit.css +10 -0
  39. nodekit/_static/nodekit.js +59 -0
  40. nodekit/actions/__init__.py +25 -0
  41. nodekit/assets/__init__.py +7 -0
  42. nodekit/cards/__init__.py +15 -0
  43. nodekit/events/__init__.py +30 -0
  44. nodekit/experimental/.DS_Store +0 -0
  45. nodekit/experimental/__init__.py +0 -0
  46. nodekit/experimental/recruitment_services/__init__.py +0 -0
  47. nodekit/experimental/recruitment_services/base.py +77 -0
  48. nodekit/experimental/recruitment_services/mechanical_turk/__init__.py +0 -0
  49. nodekit/experimental/recruitment_services/mechanical_turk/client.py +359 -0
  50. nodekit/experimental/recruitment_services/mechanical_turk/models.py +116 -0
  51. nodekit/experimental/s3.py +219 -0
  52. nodekit/experimental/turk_helper.py +223 -0
  53. nodekit/experimental/visualization/.DS_Store +0 -0
  54. nodekit/experimental/visualization/__init__.py +0 -0
  55. nodekit/experimental/visualization/pointer.py +443 -0
  56. nodekit/expressions/__init__.py +55 -0
  57. nodekit/sensors/__init__.py +25 -0
  58. nodekit/transitions/__init__.py +15 -0
  59. nodekit/values/__init__.py +63 -0
  60. nodekit-0.2.0.dist-info/METADATA +221 -0
  61. nodekit-0.2.0.dist-info/RECORD +62 -0
  62. nodekit-0.2.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,234 @@
1
+ import atexit
2
+ import hashlib
3
+ import threading
4
+ import time
5
+ from pathlib import Path
6
+ from typing import List, Dict
7
+
8
+ import fastapi
9
+ import fastapi.responses
10
+ import fastapi.templating
11
+ import pydantic
12
+ import uvicorn
13
+
14
+ from nodekit import Graph
15
+ from nodekit._internal.utils.get_browser_bundle import get_browser_bundle
16
+ from nodekit._internal.utils.iter_assets import iter_assets
17
+ from nodekit._internal.types.assets import URL, Asset
18
+ from nodekit._internal.types.value import SHA256
19
+ from nodekit._internal.types.events.events import Event, EventTypeEnum
20
+ from nodekit._internal.types.trace import Trace
21
+ from nodekit._internal.ops.open_asset_save_asset import open_asset
22
+
23
+
24
+ # %%
25
+ class LocalRunner:
26
+ def __init__(
27
+ self,
28
+ port: int = 7651,
29
+ host: str = "127.0.0.1",
30
+ ):
31
+ self._lock = threading.RLock()
32
+ self._thread: threading.Thread | None = None
33
+ self._server: uvicorn.Server | None = None
34
+ self._running = False
35
+
36
+ self.port = port
37
+ self.host = host
38
+
39
+ # In-memory state of the runner:
40
+ self._graph: Graph | None = None
41
+ self._events: List[Event] = []
42
+
43
+ self.asset_id_to_asset: Dict[SHA256, Asset] = {}
44
+
45
+ # Initialize FastAPI app
46
+ self.app = self._build_app()
47
+ atexit.register(self.shutdown)
48
+
49
+ def ensure_running(self):
50
+ with self._lock:
51
+ if self._running:
52
+ return
53
+
54
+ config = uvicorn.Config(
55
+ app=self.app,
56
+ host=self.host,
57
+ port=self.port,
58
+ log_level="warning",
59
+ )
60
+ self._server = uvicorn.Server(config=config)
61
+ self._thread = threading.Thread(target=self._server.run, daemon=True)
62
+ self._thread.start()
63
+ self._running = True
64
+
65
+ def shutdown(self):
66
+ with self._lock:
67
+ if not self._running:
68
+ return
69
+
70
+ if self._server is not None:
71
+ self._server.should_exit = True
72
+ if self._thread is not None:
73
+ self._thread.join(timeout=5.0)
74
+
75
+ self._running = False
76
+ self._server = None
77
+ self._thread = None
78
+
79
+ def set_graph(self, graph: Graph):
80
+ with self._lock:
81
+ graph = graph.model_copy(deep=True)
82
+ # Reset Graph and Events
83
+ self._graph = graph
84
+ self._events = []
85
+
86
+ # Mount the Graph's assets:
87
+ for asset in iter_assets(graph=graph):
88
+ if not isinstance(asset.locator, URL):
89
+ # Save a copy of the original Asset:
90
+ self.asset_id_to_asset[asset.sha256] = asset.model_copy(deep=True)
91
+
92
+ # Mutate the Graph's Asset to have a URL locator:
93
+ asset.locator = URL(url=f"assets/{asset.sha256}")
94
+
95
+ def _build_app(self) -> fastapi.FastAPI:
96
+ app = fastapi.FastAPI()
97
+
98
+ # Mount the static JS and CSS files
99
+ bundle = get_browser_bundle()
100
+
101
+ def _sha(text: str) -> str:
102
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
103
+
104
+ NODEKIT_JS_HASH = _sha(bundle.js)
105
+ NODEKIT_CSS_HASH = _sha(bundle.css)
106
+
107
+ # Mount the jinja2 template at ./site-template.j2:
108
+ templates = fastapi.templating.Jinja2Templates(directory=Path(__file__).parent)
109
+
110
+ # Cache-busted asset endpoints
111
+ @app.get("/static/nodekit.{js_hash}.js", name="get_nodekit_javascript")
112
+ def get_nodekit_javascript(js_hash: str) -> fastapi.responses.PlainTextResponse:
113
+ if not js_hash == NODEKIT_JS_HASH:
114
+ raise fastapi.HTTPException(status_code=404, detail="JS not found")
115
+ return fastapi.responses.PlainTextResponse(
116
+ bundle.js, media_type="application/javascript"
117
+ )
118
+
119
+ @app.get("/static/nodekit.{css_hash}.css", name="get_nodekit_css")
120
+ def get_nodekit_css(css_hash: str) -> fastapi.responses.PlainTextResponse:
121
+ if not css_hash == NODEKIT_CSS_HASH:
122
+ raise fastapi.HTTPException(status_code=404, detail="CSS not found")
123
+ return fastapi.responses.PlainTextResponse(
124
+ bundle.css, media_type="text/css"
125
+ )
126
+
127
+ @app.get("/health")
128
+ def health():
129
+ return fastapi.Response(status_code=fastapi.status.HTTP_204_NO_CONTENT)
130
+
131
+ @app.get("/assets/{asset_id}")
132
+ async def get_asset(asset_id: str):
133
+ try:
134
+ asset = self.asset_id_to_asset[asset_id]
135
+ except KeyError:
136
+ raise fastapi.HTTPException(
137
+ status_code=404, detail=f"Asset with ID {asset_id} not found."
138
+ )
139
+
140
+ # Hardcode
141
+ with open_asset(asset) as f:
142
+ savepath = Path(f"/tmp/{asset_id}")
143
+ if not savepath.exists():
144
+ with open(savepath, "wb") as out:
145
+ out.write(f.read())
146
+ print(f"Saved asset to {savepath}")
147
+ return fastapi.responses.FileResponse(
148
+ path=savepath,
149
+ media_type=asset.media_type,
150
+ )
151
+
152
+ @app.get("/")
153
+ def site(
154
+ request: fastapi.Request,
155
+ ) -> fastapi.responses.HTMLResponse:
156
+ if self._graph is None:
157
+ raise fastapi.HTTPException(
158
+ status_code=404,
159
+ detail="No Graph is currently being served. Call `nodekit.play` first.",
160
+ )
161
+
162
+ return templates.TemplateResponse(
163
+ request=request,
164
+ name="site-template.j2",
165
+ context={
166
+ "graph": self._graph.model_dump(mode="json"),
167
+ "nodekit_javascript_link": request.url_for(
168
+ "get_nodekit_javascript",
169
+ js_hash=NODEKIT_JS_HASH,
170
+ ),
171
+ "nodekit_css_link": request.url_for(
172
+ "get_nodekit_css",
173
+ css_hash=NODEKIT_CSS_HASH,
174
+ ),
175
+ "submit_event_url": request.url_for(
176
+ "submit_event",
177
+ ),
178
+ },
179
+ )
180
+
181
+ @app.post("/submit")
182
+ def submit_event(
183
+ event: dict,
184
+ ) -> fastapi.Response:
185
+ # Event is a type alias which is a Union of multiple concrete event types.
186
+ # Need a TypeAdapter for this.
187
+ typeadapter = pydantic.TypeAdapter(Event)
188
+ event = typeadapter.validate_python(event)
189
+ print(f"Received {event.event_type.value}")
190
+ self._events.append(event)
191
+ return fastapi.Response(status_code=fastapi.status.HTTP_204_NO_CONTENT)
192
+
193
+ return app
194
+
195
+ @property
196
+ def url(self) -> str:
197
+ return f"http://{self.host}:{self.port}"
198
+
199
+ def list_events(self) -> List[Event]:
200
+ with self._lock:
201
+ return list(self._events)
202
+
203
+
204
+ # %%
205
+ def play(
206
+ graph: Graph,
207
+ ) -> Trace:
208
+ """
209
+ Play the given Graph locally, then return the Trace.
210
+
211
+ Args:
212
+ graph: x
213
+
214
+ Returns:
215
+ The Trace of Events observed during execution.
216
+
217
+ """
218
+ runner = LocalRunner()
219
+ runner.ensure_running()
220
+ runner.set_graph(graph)
221
+
222
+ print("Play the Graph at:\n", runner.url)
223
+
224
+ # Wait until the End Event is observed:
225
+ while True:
226
+ events = runner.list_events()
227
+ if any([e.event_type == EventTypeEnum.TraceEndedEvent for e in events]):
228
+ break
229
+ time.sleep(1)
230
+
231
+ # Shut down the server:
232
+ runner.shutdown()
233
+
234
+ return Trace(events=events)
@@ -0,0 +1,38 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>NodeKit Preview Page</title>
6
+ <link rel="shortcut icon" href="">
7
+
8
+ <!-- NodeKit -->
9
+ <script src="{{ nodekit_javascript_link }}"></script>
10
+ <link href="{{ nodekit_css_link }}" rel="stylesheet">
11
+
12
+ <script>
13
+ const graph = {{ graph | tojson(indent=4) | indent(8, first=False) }};
14
+ const eventSubmitUrl = "{{ submit_event_url }}";
15
+
16
+ async function sendEvent(ev) {
17
+ console.log(ev)
18
+ try {
19
+ await fetch(eventSubmitUrl, {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify(ev),
23
+ });
24
+ } catch (err) {
25
+ console.error("Failed to send event", err);
26
+ }
27
+ }
28
+
29
+ window.onload = async () => {
30
+ await NodeKit.play(
31
+ graph,
32
+ sendEvent
33
+ );
34
+ };
35
+ </script>
36
+ </head>
37
+ <body></body>
38
+ </html>
@@ -0,0 +1,131 @@
1
+ import os
2
+ import shutil
3
+ import zipfile
4
+ from pathlib import Path
5
+ from typing import Tuple, Dict
6
+
7
+ from nodekit._internal.utils.get_extension_from_media_type import (
8
+ get_extension_from_media_type,
9
+ )
10
+ from nodekit._internal.utils.iter_assets import iter_assets
11
+ from nodekit._internal.ops.open_asset_save_asset import open_asset
12
+ from nodekit._internal.types.assets import (
13
+ ZipArchiveInnerPath,
14
+ RelativePath,
15
+ Asset,
16
+ )
17
+ from nodekit._internal.types.value import MediaType, SHA256
18
+ from nodekit._internal.types.graph import Graph
19
+
20
+
21
+ # %%
22
+ def _get_archive_relative_path(media_type: MediaType, sha256: SHA256) -> Path:
23
+ """
24
+ Returns the relative path within the .nkg archive for a given asset.
25
+ """
26
+ extension = get_extension_from_media_type(media_type)
27
+ return Path("assets") / media_type / f"{sha256}.{extension}"
28
+
29
+
30
+ # %%
31
+ def save_graph(
32
+ graph: Graph,
33
+ path: str | os.PathLike,
34
+ ) -> Path:
35
+ """
36
+ Packs the Graph model into a .nkg file, which is the canonical representation of a Graph.
37
+ A .nkg file is a .zip archive with the following structure:
38
+
39
+ graph.json
40
+ assets/
41
+ {mime-type-1}/{mime-type-2}/{sha256}.{ext}
42
+ """
43
+ # Ensure the given path ends with .nkg or has no extension:
44
+ path = Path(path)
45
+ if not str(path).endswith(".nkg"):
46
+ raise ValueError(f"Path must end with .nkg: {path}")
47
+
48
+ if not path.parent.exists():
49
+ raise ValueError(f"Parent directory does not exist: {path.parent}")
50
+
51
+ # Deep copy the Graph, as we will be modifying it so that all AssetLocators are RelativePathAssetLocators:
52
+ graph = graph.model_copy(deep=True)
53
+
54
+ # Mutate all AssetLocators in the Graph to be RelativePathAssetLocators:
55
+ supplied_assets: Dict[Tuple[MediaType, SHA256], Asset] = {}
56
+ relative_asset_locators: Dict[Tuple[MediaType, SHA256], RelativePath] = {}
57
+ for asset in iter_assets(graph=graph):
58
+ # Log the asset locator if we haven't seen it before:
59
+ asset_key = (asset.media_type, asset.sha256)
60
+ if asset_key not in supplied_assets:
61
+ supplied_assets[asset_key] = asset.model_copy()
62
+ relative_asset_locators[asset_key] = RelativePath(
63
+ relative_path=_get_archive_relative_path(
64
+ media_type=asset.media_type, sha256=asset.sha256
65
+ )
66
+ )
67
+
68
+ # Mutate the AssetLocator to be a RelativePathAssetLocator:
69
+ asset.locator = relative_asset_locators[asset_key]
70
+
71
+ # Open a temporary zip file for writing:
72
+ temp_path = path.with_suffix(".nkg.tmp")
73
+ if temp_path.exists():
74
+ raise ValueError(f"Temporary path already exists: {temp_path}")
75
+
76
+ try:
77
+ with zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as myzip:
78
+ # Write all asset files to the archive:
79
+ for asset_key, asset_locator in supplied_assets.items():
80
+ with open_asset(asset_locator) as src_file:
81
+ media_type, sha256 = asset_key
82
+ archive_relative_path = _get_archive_relative_path(
83
+ media_type, sha256
84
+ )
85
+ with myzip.open(str(archive_relative_path), "w") as dst_file:
86
+ shutil.copyfileobj(src_file, dst_file)
87
+
88
+ # Write the graph.json file:
89
+ myzip.writestr("graph.json", graph.model_dump_json(indent=2))
90
+
91
+ # Rename the temporary file to the final path:
92
+ temp_path.rename(path)
93
+ finally:
94
+ if temp_path.exists():
95
+ temp_path.unlink()
96
+ return Path(path)
97
+
98
+
99
+ # %%
100
+ def load_graph(
101
+ path: str | os.PathLike,
102
+ ) -> Graph:
103
+ """
104
+ Unpacks a .nkg file from disk and returns the corresponding Graph object.
105
+ All AssetFiles in the Graph are backed by the asset files in the .nkg archive.
106
+ The user is responsible for ensuring the .nkg file is not moved or edited while the Graph is in use.
107
+ """
108
+
109
+ if not str(path).endswith(".nkg"):
110
+ raise ValueError(f"Invalid path given; must end with .nkg: {path}")
111
+
112
+ # Open the zip file for reading:
113
+ with zipfile.ZipFile(path, "r") as zf:
114
+ # Read graph.json
115
+ with zf.open("graph.json") as f:
116
+ graph = Graph.model_validate_json(f.read().decode("utf-8"))
117
+
118
+ # Mutate all AssetLocators in the Graph from RelativePath to ZipArchiveInnerPath:
119
+ for asset in iter_assets(graph=graph):
120
+ # Raise a ValueError if the asset locator is not a RelativePath:
121
+ if not isinstance(asset.locator, RelativePath):
122
+ raise ValueError(
123
+ f".nkg encoding error: Asset's locator is not a RelativePath: {asset}"
124
+ )
125
+
126
+ # Mutate the asset locator
127
+ asset.locator = ZipArchiveInnerPath(
128
+ zip_archive_path=Path(path), inner_path=asset.locator.relative_path
129
+ )
130
+
131
+ return graph
@@ -0,0 +1,122 @@
1
+ from collections import defaultdict, deque
2
+ from typing import List, Tuple
3
+
4
+ from nodekit import Graph
5
+ from nodekit._internal.types.transition import IfThenElse, Switch, End, Go, Transition
6
+ from nodekit._internal.types.value import NodeId
7
+
8
+
9
+ # %%
10
+ def topological_sort(
11
+ graph: Graph,
12
+ ) -> List[NodeId]:
13
+ """
14
+ Perform a topological sort over a directed graph of nodes and transitions.
15
+
16
+ Each Transition defines zero or more outgoing edges from a node. Nodes are ranked
17
+ according to topological order; ties within the same rank are deterministically
18
+ broken lexicographically. Nested Graphs (if present in nodes) are treated as leaves.
19
+
20
+ Args:
21
+ graph: The nk.Graph object containing nodes and transitions.
22
+ Returns:
23
+ List[NodeId]: A list of node identifiers in topologically sorted order
24
+ """
25
+
26
+ nodes, transitions = graph.nodes, graph.transitions
27
+
28
+ edges: list[tuple[NodeId, NodeId]] = []
29
+ for in_node, transition in transitions.items():
30
+ if in_node not in nodes:
31
+ raise KeyError(f"Transition refers to non-existent node '{in_node}'")
32
+
33
+ for out_node in _outgoing_targets(transition):
34
+ if out_node not in nodes:
35
+ raise KeyError(
36
+ f"Transition from '{in_node}' points to unknown node '{out_node}'"
37
+ )
38
+ edges.append((in_node, out_node))
39
+
40
+ rank_order = _topo_sort_core(list(nodes.keys()), edges)
41
+
42
+ # Group by rank and apply lexical tie-breaker:
43
+ rank_groups = defaultdict(list)
44
+ for key, rank in zip(nodes.keys(), rank_order):
45
+ rank_groups[rank].append(key)
46
+
47
+ ordered: list[NodeId] = []
48
+ for rank in sorted(rank_groups.keys()):
49
+ group = rank_groups[rank]
50
+ group.sort()
51
+ ordered.extend(group)
52
+
53
+ return ordered
54
+
55
+
56
+ def _topo_sort_core(
57
+ node_keys: List[NodeId], edges: List[Tuple[NodeId, NodeId]]
58
+ ) -> List[int]:
59
+ """
60
+ Perform topological sorting and return a list of ranks for each node key.
61
+
62
+ Args:
63
+ node_keys: List of unique node identifiers (strings).
64
+ edges: List of (src, dst) edges representing dependencies.
65
+
66
+ Returns:
67
+ List[int]: Ranks corresponding to each node in node_keys order.
68
+ """
69
+ # Build adjacency list and indegree count:
70
+ adjacency = defaultdict(list)
71
+ indegree = {key: 0 for key in node_keys}
72
+
73
+ for src, dst in edges:
74
+ adjacency[src].append(dst)
75
+ indegree[dst] += 1
76
+
77
+ # Initialize queue with nodes of indegree 0:
78
+ queue = deque([key for key, deg in indegree.items() if deg == 0])
79
+
80
+ rank_map = {}
81
+ current_rank = 0
82
+
83
+ # Core sorting algorithm:
84
+ while queue:
85
+ for _ in range(len(queue)):
86
+ node = queue.popleft()
87
+ rank_map[node] = current_rank
88
+ for neighbor in adjacency[node]:
89
+ indegree[neighbor] -= 1
90
+ if indegree[neighbor] == 0:
91
+ queue.append(neighbor)
92
+ current_rank += 1
93
+
94
+ # Check for cycles:
95
+ if len(rank_map) != len(node_keys):
96
+ raise ValueError("Loop present in Graph, please reconfigure the structure")
97
+
98
+ # Return ranks in the same order as node_keys:
99
+ return [rank_map[key] for key in node_keys]
100
+
101
+
102
+ def _outgoing_targets(tr: Transition) -> list[NodeId]:
103
+ if isinstance(tr, Go):
104
+ return [tr.to]
105
+ if isinstance(tr, End):
106
+ return []
107
+ if isinstance(tr, Switch):
108
+ targets: list[NodeId] = []
109
+ for _value, transition in tr.cases.items():
110
+ if isinstance(transition, Go):
111
+ targets.append(transition.to)
112
+ if isinstance(tr.default, Go):
113
+ targets.append(tr.default.to)
114
+ return targets
115
+ if isinstance(tr, IfThenElse):
116
+ targets: list[NodeId] = []
117
+ if isinstance(tr.then, Go):
118
+ targets.append(tr.then.to)
119
+ if isinstance(tr.else_, Go):
120
+ targets.append(tr.else_.to)
121
+ return targets
122
+ raise TypeError(f"Unsupported transition type: {tr}")
File without changes
File without changes
@@ -0,0 +1,89 @@
1
+ from abc import ABC
2
+ from typing import Literal, Union, Annotated, Dict
3
+
4
+ import pydantic
5
+
6
+ from nodekit._internal.types.value import PressableKey, SpatialPoint
7
+
8
+
9
+ # %%
10
+ class BaseAction(pydantic.BaseModel, ABC):
11
+ action_type: str
12
+
13
+
14
+ # %%
15
+ class ClickAction(BaseAction):
16
+ action_type: Literal["ClickAction"] = "ClickAction"
17
+ x: SpatialPoint = pydantic.Field(
18
+ description="The x-coordinate of the click, in Board units."
19
+ )
20
+ y: SpatialPoint = pydantic.Field(
21
+ description="The y-coordinate of the click, in Board units."
22
+ )
23
+
24
+
25
+ # %%
26
+ class KeyAction(BaseAction):
27
+ action_type: Literal["KeyAction"] = "KeyAction"
28
+ key: PressableKey = pydantic.Field(description="The key that was pressed.")
29
+
30
+
31
+ # %%
32
+ class SliderAction(BaseAction):
33
+ action_type: Literal["SliderAction"] = "SliderAction"
34
+ bin_index: int = pydantic.Field(
35
+ description="The index of the bin that was selected.", ge=0
36
+ )
37
+
38
+
39
+ # %%
40
+ class TextEntryAction(BaseAction):
41
+ action_type: Literal["TextEntryAction"] = "TextEntryAction"
42
+ text: str
43
+
44
+
45
+ # %%
46
+ class WaitAction(BaseAction):
47
+ action_type: Literal["WaitAction"] = "WaitAction"
48
+
49
+
50
+ # %%
51
+ class SelectAction(BaseAction):
52
+ action_type: Literal["SelectAction"] = "SelectAction"
53
+ selection: str
54
+
55
+
56
+ # %%
57
+ class MultiSelectAction(BaseAction):
58
+ action_type: Literal["MultiSelectAction"] = "MultiSelectAction"
59
+ selections: list[str]
60
+
61
+
62
+ # %%
63
+ class ProductAction(BaseAction):
64
+ action_type: Literal["ProductAction"] = "ProductAction"
65
+ child_actions: Dict[str, "Action"]
66
+
67
+
68
+ # %%
69
+ class SumAction(BaseAction):
70
+ action_type: Literal["SumAction"] = "SumAction"
71
+ child_id: str
72
+ child_action: "Action"
73
+
74
+
75
+ # %%
76
+ Action = Annotated[
77
+ Union[
78
+ ClickAction,
79
+ KeyAction,
80
+ SliderAction,
81
+ TextEntryAction,
82
+ WaitAction,
83
+ SelectAction,
84
+ MultiSelectAction,
85
+ ProductAction,
86
+ SumAction,
87
+ ],
88
+ pydantic.Field(discriminator="action_type"),
89
+ ]