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.
- nodekit/.DS_Store +0 -0
- nodekit/__init__.py +53 -0
- nodekit/_internal/__init__.py +0 -0
- nodekit/_internal/ops/__init__.py +0 -0
- nodekit/_internal/ops/build_site/__init__.py +117 -0
- nodekit/_internal/ops/build_site/harness.j2 +158 -0
- nodekit/_internal/ops/concat.py +51 -0
- nodekit/_internal/ops/open_asset_save_asset.py +125 -0
- nodekit/_internal/ops/play/__init__.py +4 -0
- nodekit/_internal/ops/play/local_runner/__init__.py +0 -0
- nodekit/_internal/ops/play/local_runner/main.py +234 -0
- nodekit/_internal/ops/play/local_runner/site-template.j2 +38 -0
- nodekit/_internal/ops/save_graph_load_graph.py +131 -0
- nodekit/_internal/ops/topological_sorting.py +122 -0
- nodekit/_internal/types/__init__.py +0 -0
- nodekit/_internal/types/actions/__init__.py +0 -0
- nodekit/_internal/types/actions/actions.py +89 -0
- nodekit/_internal/types/assets/__init__.py +151 -0
- nodekit/_internal/types/cards/__init__.py +85 -0
- nodekit/_internal/types/events/__init__.py +0 -0
- nodekit/_internal/types/events/events.py +145 -0
- nodekit/_internal/types/expressions/__init__.py +0 -0
- nodekit/_internal/types/expressions/expressions.py +242 -0
- nodekit/_internal/types/graph.py +42 -0
- nodekit/_internal/types/node.py +21 -0
- nodekit/_internal/types/regions/__init__.py +13 -0
- nodekit/_internal/types/sensors/__init__.py +0 -0
- nodekit/_internal/types/sensors/sensors.py +156 -0
- nodekit/_internal/types/trace.py +17 -0
- nodekit/_internal/types/transition.py +68 -0
- nodekit/_internal/types/value.py +145 -0
- nodekit/_internal/utils/__init__.py +0 -0
- nodekit/_internal/utils/get_browser_bundle.py +35 -0
- nodekit/_internal/utils/get_extension_from_media_type.py +15 -0
- nodekit/_internal/utils/hashing.py +46 -0
- nodekit/_internal/utils/iter_assets.py +61 -0
- nodekit/_internal/version.py +1 -0
- nodekit/_static/nodekit.css +10 -0
- nodekit/_static/nodekit.js +59 -0
- nodekit/actions/__init__.py +25 -0
- nodekit/assets/__init__.py +7 -0
- nodekit/cards/__init__.py +15 -0
- nodekit/events/__init__.py +30 -0
- nodekit/experimental/.DS_Store +0 -0
- nodekit/experimental/__init__.py +0 -0
- nodekit/experimental/recruitment_services/__init__.py +0 -0
- nodekit/experimental/recruitment_services/base.py +77 -0
- nodekit/experimental/recruitment_services/mechanical_turk/__init__.py +0 -0
- nodekit/experimental/recruitment_services/mechanical_turk/client.py +359 -0
- nodekit/experimental/recruitment_services/mechanical_turk/models.py +116 -0
- nodekit/experimental/s3.py +219 -0
- nodekit/experimental/turk_helper.py +223 -0
- nodekit/experimental/visualization/.DS_Store +0 -0
- nodekit/experimental/visualization/__init__.py +0 -0
- nodekit/experimental/visualization/pointer.py +443 -0
- nodekit/expressions/__init__.py +55 -0
- nodekit/sensors/__init__.py +25 -0
- nodekit/transitions/__init__.py +15 -0
- nodekit/values/__init__.py +63 -0
- nodekit-0.2.0.dist-info/METADATA +221 -0
- nodekit-0.2.0.dist-info/RECORD +62 -0
- 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
|
+
]
|