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
nodekit/.DS_Store ADDED
Binary file
nodekit/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ __all__ = [
2
+ "VERSION",
3
+ # Top-level types:
4
+ "Node",
5
+ "Graph",
6
+ "Trace",
7
+ # One-off top-level types:
8
+ "Region",
9
+ # Namespaced types:
10
+ "assets",
11
+ "cards",
12
+ "sensors",
13
+ "actions",
14
+ "events",
15
+ "transitions",
16
+ "expressions",
17
+ "values",
18
+ # Ops:
19
+ "play",
20
+ "concat",
21
+ "save_graph",
22
+ "load_graph",
23
+ "open_asset",
24
+ "build_site",
25
+ ]
26
+
27
+ # Version
28
+ from nodekit._internal.version import VERSION
29
+
30
+ # Incoming models:
31
+ from nodekit._internal.types.node import Node
32
+ from nodekit._internal.types.trace import Trace
33
+ from nodekit._internal.types.graph import Graph
34
+
35
+ # Random
36
+ from nodekit._internal.types.regions import Region
37
+
38
+ # Namespaced types:
39
+ import nodekit.cards as cards
40
+ import nodekit.assets as assets
41
+ import nodekit.sensors as sensors
42
+ import nodekit.actions as actions
43
+ import nodekit.events as events
44
+ import nodekit.transitions as transitions
45
+ import nodekit.expressions as expressions
46
+ import nodekit.values as values
47
+
48
+ # Ops:
49
+ from nodekit._internal.ops.play import play
50
+ from nodekit._internal.ops.concat import concat
51
+ from nodekit._internal.ops.save_graph_load_graph import save_graph, load_graph
52
+ from nodekit._internal.ops.open_asset_save_asset import open_asset
53
+ from nodekit._internal.ops.build_site import build_site
File without changes
File without changes
@@ -0,0 +1,117 @@
1
+ from pathlib import Path
2
+ import os
3
+
4
+ import jinja2
5
+ import pydantic
6
+
7
+ from nodekit._internal.ops.open_asset_save_asset import save_asset
8
+ from nodekit._internal.types.assets import RelativePath
9
+ from nodekit._internal.types.graph import Graph
10
+ from nodekit._internal.utils.get_browser_bundle import get_browser_bundle
11
+ from nodekit._internal.utils.get_extension_from_media_type import (
12
+ get_extension_from_media_type,
13
+ )
14
+ from nodekit._internal.utils.hashing import hash_string
15
+ from nodekit._internal.utils.iter_assets import iter_assets
16
+
17
+
18
+ # %%
19
+ class BuildSiteResult(pydantic.BaseModel):
20
+ site_root: Path = pydantic.Field(
21
+ description="The absolute path to the folder containing the site."
22
+ )
23
+ entrypoint: Path = pydantic.Field(
24
+ description="The path of the index html (relative to the root)."
25
+ )
26
+ dependencies: list[Path] = pydantic.Field(
27
+ description="List of paths to all files needed by the index html, relative to the root."
28
+ )
29
+
30
+
31
+ def build_site(
32
+ graph: Graph,
33
+ savedir: os.PathLike | str,
34
+ ) -> BuildSiteResult:
35
+ """
36
+ Builds a static website for the given Graph, saving all assets and the HTML file to the given directory.
37
+ Returns a path to the entrypoint HTML file of the site ({graph-id}.html).
38
+
39
+ assets/
40
+ {mime-type-1}/{mime-type-2}/{sha256}.{ext}
41
+ runtime/
42
+ nodekit.{js-digest}.js
43
+ nodekit.{css-digest}.css
44
+ graphs/
45
+ {graph_digest}/
46
+ index.html
47
+ graph.json
48
+ """
49
+ savedir = Path(savedir)
50
+ if not savedir.exists():
51
+ savedir.mkdir(parents=True, exist_ok=True)
52
+
53
+ if not savedir.is_dir():
54
+ raise ValueError(f"Savedir must be a directory: {savedir}")
55
+
56
+ dependencies = []
57
+
58
+ # Ensure the browser runtime is saved to the appropriate location:
59
+ browser_bundle = get_browser_bundle()
60
+ css_relative_path = Path("runtime") / f"nodekit.{browser_bundle.css_sha256}.css"
61
+ js_relative_path = Path("runtime") / f"nodekit.{browser_bundle.js_sha256}.js"
62
+ css_abs_path = savedir / css_relative_path
63
+ js_abs_path = savedir / js_relative_path
64
+ dependencies.append(css_relative_path)
65
+ dependencies.append(js_relative_path)
66
+ if not css_abs_path.exists():
67
+ css_abs_path.parent.mkdir(parents=True, exist_ok=True)
68
+ css_abs_path.write_text(browser_bundle.css)
69
+ if not js_abs_path.exists():
70
+ js_abs_path.parent.mkdir(parents=True, exist_ok=True)
71
+ js_abs_path.write_text(browser_bundle.js)
72
+
73
+ # Ensure all assets saved to the appropriate location:
74
+ graph = graph.model_copy(deep=True)
75
+ for asset in iter_assets(graph=graph):
76
+ asset_relative_path = (
77
+ Path("assets")
78
+ / asset.media_type
79
+ / f"{asset.sha256}.{get_extension_from_media_type(asset.media_type)}"
80
+ )
81
+ asset_abs_path = savedir / asset_relative_path
82
+ dependencies.append(asset_relative_path)
83
+ if not asset_abs_path.exists():
84
+ # Copy the asset to the savepath:
85
+ asset_abs_path.parent.mkdir(parents=True, exist_ok=True)
86
+ save_asset(
87
+ asset=asset,
88
+ path=asset_abs_path,
89
+ )
90
+
91
+ # Mutate the asset locator in the graph to be a RelativePath - relative to the graph!:
92
+ asset.locator = RelativePath(relative_path=Path("../..") / asset_relative_path)
93
+
94
+ # Render the HTML site using the Jinja2 template:
95
+ jinja2_location = Path(__file__).parent / "harness.j2"
96
+ jinja2_loader = jinja2.FileSystemLoader(searchpath=jinja2_location.parent)
97
+ jinja2_env = jinja2.Environment(loader=jinja2_loader)
98
+ template = jinja2_env.get_template(jinja2_location.name)
99
+ rendered_html = template.render(
100
+ graph=graph.model_dump(mode="json"),
101
+ css_path=Path("../..") / css_relative_path,
102
+ js_path=Path("../..") / js_relative_path,
103
+ )
104
+
105
+ # Save the graph site:
106
+ graph_serialized = graph.model_dump_json()
107
+ graph_digest = hash_string(s=graph_serialized)
108
+ graph_dir = savedir / "graphs" / graph_digest
109
+ graph_dir.mkdir(parents=True, exist_ok=True)
110
+ graph_html_path = graph_dir / "index.html"
111
+ graph_html_path.write_text(rendered_html)
112
+
113
+ return BuildSiteResult(
114
+ site_root=savedir.resolve(),
115
+ entrypoint=graph_html_path.relative_to(savedir),
116
+ dependencies=dependencies,
117
+ )
@@ -0,0 +1,158 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>NodeKit</title>
6
+ <link rel="shortcut icon" href="">
7
+
8
+ <!-- NodeKit -->
9
+ <script src="{{ js_path }}"></script>
10
+ <link href="{{ css_path }}" rel="stylesheet">
11
+
12
+
13
+ <script>
14
+ function tryInferTurkContext() {
15
+ if (typeof window === "undefined" || !window.location) {
16
+ return null
17
+ }
18
+
19
+ const params = new URLSearchParams(window.location.search);
20
+
21
+ // MTurk-style query params
22
+ const assignmentId = params.get("assignmentId");
23
+ const hitId = params.get("hitId");
24
+ const workerId = params.get("workerId");
25
+ const turkSubmitTo = params.get("turkSubmitTo");
26
+
27
+ const looksLikeMTurk =
28
+ assignmentId !== null ||
29
+ hitId !== null ||
30
+ workerId !== null ||
31
+ turkSubmitTo !== null;
32
+
33
+ if (!looksLikeMTurk) {
34
+ return null
35
+ }
36
+
37
+ // Preview mode per MTurk docs: assignmentId is present and equals this sentinel.
38
+ const PREVIEW_SENTINEL = "ASSIGNMENT_ID_NOT_AVAILABLE";
39
+ const preview_mode = assignmentId === PREVIEW_SENTINEL;
40
+
41
+ if (preview_mode) {
42
+ return {
43
+ previewMode: true,
44
+ context: null,
45
+ };
46
+ }
47
+
48
+ // Not previewing → require all fields
49
+ if (!turkSubmitTo || !hitId || !assignmentId || !workerId) {
50
+ const e = new Error("Missing required parameters in the query for MTurk platform.");
51
+ e.name = "BadPlatformContextError";
52
+ throw e;
53
+ }
54
+
55
+ return {
56
+ previewMode: false,
57
+ context: {
58
+ assignmentId: assignmentId,
59
+ turkSubmitTo: turkSubmitTo,
60
+ workerId: workerId,
61
+ },
62
+ };
63
+ }
64
+ </script>
65
+ <script>
66
+ function addPreviewSplash(){
67
+ if (typeof window === "undefined" || !window.location) {
68
+ return null
69
+ }
70
+ const div = document.createElement('div');
71
+ div.textContent = 'Preview Mode. Accept the HIT to continue.';
72
+ Object.assign(div.style, {
73
+ position: 'fixed',
74
+ top: '50%',
75
+ left: '50%',
76
+ transform: 'translate(-50%, -50%)',
77
+ padding: '1.5rem 2rem',
78
+ background: 'white',
79
+ border: '2px solid #ccc',
80
+ borderRadius: '8px',
81
+ fontFamily: 'sans-serif',
82
+ fontSize: '1.2rem',
83
+ textAlign: 'center',
84
+ boxShadow: '0 0 2px rgba(0,0,0,0.2)',
85
+ });
86
+ document.body.appendChild(div);
87
+ }
88
+ </script>
89
+
90
+ <script>
91
+ function submitToTurk(
92
+ trace,
93
+ assignmentId,
94
+ turkSubmitTo,
95
+ ){
96
+ // Submission procedure for Mechanical Turk. See: https://docs.aws.amazon.com/AWSMechTurk/latest/AWSMechanicalTurkRequester/mturk-hits-defining-questions-html-javascript.html
97
+ // Note the following undocumented gotcha: the form must have at least one input element other than assignmentId, otherwise
98
+ // Mechanical Turk will not accept the submission. Here, we have the input element `sessionId`, which satisfies this requirement.
99
+
100
+ // Get context variables:
101
+
102
+ // create the form element and point it to the correct endpoint
103
+ const form = document.createElement('form')
104
+ form.action = (new URL('mturk/externalSubmit', turkSubmitTo)).href
105
+ form.method = 'post'
106
+
107
+ // attach the assignmentId
108
+ const inputAssignmentId = document.createElement('input')
109
+ inputAssignmentId.name = 'assignmentId'
110
+ inputAssignmentId.value = assignmentId
111
+ inputAssignmentId.hidden = true
112
+ form.appendChild(inputAssignmentId)
113
+
114
+ // attach a runId to allow for Experimenters to map HITs to Sessions
115
+ const inputRunId = document.createElement('input')
116
+ inputRunId.name = 'trace';
117
+ inputRunId.value = JSON.stringify(trace);
118
+ inputRunId.hidden = true;
119
+ form.appendChild(inputRunId)
120
+
121
+ // attach the form to the HTML document and trigger submission
122
+ document.body.appendChild(form)
123
+
124
+ // Submit the form
125
+ form.submit()
126
+ }
127
+ </script>
128
+
129
+ <script>
130
+ const graph = {{ graph | tojson(indent=4) | indent(8, first=False) }};
131
+
132
+ window.onload = async () => {
133
+ // Infer the context:
134
+ const turkContext = tryInferTurkContext()
135
+
136
+ // Guard execution if in preview mode
137
+ if (turkContext && turkContext.previewMode === true){
138
+ // Add a simple, centered div in the middle of the page stating: "Preview Mode. Accept the HIT to continue."
139
+ addPreviewSplash()
140
+ return
141
+ }
142
+
143
+ // Play the Graph:
144
+ const trace = await NodeKit.play(graph);
145
+
146
+ // Handle close down procedures, based on the inferred context:
147
+ if(turkContext && turkContext.previewMode === false){
148
+ submitToTurk(
149
+ trace,
150
+ turkContext.context.assignmentId,
151
+ turkContext.context.turkSubmitTo,
152
+ )
153
+ }
154
+ };
155
+ </script>
156
+ </head>
157
+ <body></body>
158
+ </html>
@@ -0,0 +1,51 @@
1
+ import collections
2
+ from typing import Dict
3
+
4
+ from nodekit._internal.types.graph import Graph
5
+ from nodekit._internal.types.node import Node
6
+ from nodekit._internal.types.transition import End, Go, Transition
7
+ from nodekit._internal.types.value import NodeId
8
+
9
+
10
+ # %%
11
+ def concat(
12
+ sequence: list[Graph | Node],
13
+ ids: list[str] | None = None,
14
+ ) -> Graph:
15
+ """
16
+ Returns a Graph which executes the given sequence of Node | Graph.
17
+ In the new Graph, the sequence items' RegisterIds and NodeIds are prepended ids '0', '1', ..., unless `ids` is given.
18
+ """
19
+
20
+ if len(sequence) == 0:
21
+ raise ValueError("Sequence must have at least one item.")
22
+
23
+ # Generate new IDs:
24
+ if ids and len(ids) != len(sequence):
25
+ raise ValueError("If ids are given, must be the same length as sequence.")
26
+ if ids is None:
27
+ ids: list[NodeId] = [f"{i}" for i in range(len(sequence))]
28
+ if len(set(ids)) != len(ids):
29
+ counts = collections.Counter(ids)
30
+ duplicates = [id_ for id_, count in counts.items() if count > 1]
31
+ raise ValueError(
32
+ f"If ids are given, they must be unique. Duplicates:\n{'\n'.join(duplicates)}"
33
+ )
34
+
35
+ # Assemble:
36
+ nodes: Dict[NodeId, Node | Graph] = {}
37
+ transitions: Dict[NodeId, Transition] = {}
38
+
39
+ for i, (node_id, node) in enumerate(zip(ids, sequence)):
40
+ nodes[node_id] = node
41
+
42
+ if i + 1 < len(ids):
43
+ transitions[node_id] = Go(to=ids[i + 1])
44
+ else:
45
+ transitions[node_id] = End()
46
+
47
+ return Graph(
48
+ nodes=nodes,
49
+ transitions=transitions,
50
+ start=ids[0],
51
+ )
@@ -0,0 +1,125 @@
1
+ import contextlib
2
+ import zipfile
3
+ from typing import ContextManager, IO
4
+
5
+ from nodekit._internal.types.assets import (
6
+ AssetLocator,
7
+ URL,
8
+ RelativePath,
9
+ ZipArchiveInnerPath,
10
+ FileSystemPath,
11
+ Asset,
12
+ )
13
+ import urllib.request
14
+ import urllib.error
15
+ from pathlib import Path
16
+
17
+ import os
18
+ import shutil
19
+ import tempfile
20
+
21
+
22
+ # %%
23
+ def open_asset(
24
+ asset: Asset,
25
+ ) -> ContextManager[IO[bytes]]:
26
+ """
27
+ Streams the bytes of the given Asset.
28
+ """
29
+
30
+ locator: AssetLocator = asset.locator
31
+
32
+ if isinstance(locator, FileSystemPath):
33
+ return open(locator.path, "rb")
34
+ elif isinstance(locator, URL):
35
+
36
+ @contextlib.contextmanager
37
+ def open_url_stream():
38
+ req = urllib.request.Request(locator.url)
39
+ try:
40
+ # Add a timeout to avoid hanging forever:
41
+ with urllib.request.urlopen(req, timeout=30) as resp:
42
+ # Raise on non-2xx if you want stricter behavior:
43
+ status = getattr(resp, "status", 200)
44
+ if not (200 <= status < 300):
45
+ raise urllib.error.HTTPError(
46
+ locator.url, status, "Bad HTTP status", resp.headers, None
47
+ )
48
+ yield resp # file-like, binary
49
+ except urllib.error.URLError as e:
50
+ raise RuntimeError(
51
+ f"Failed to stream Asset from URL: {locator.url}"
52
+ ) from e
53
+
54
+ return open_url_stream()
55
+
56
+ elif isinstance(locator, ZipArchiveInnerPath):
57
+
58
+ @contextlib.contextmanager
59
+ def open_stream():
60
+ with zipfile.ZipFile(locator.zip_archive_path, "r") as zf:
61
+ with zf.open(str(locator.inner_path), "r") as fh:
62
+ yield fh
63
+
64
+ return open_stream()
65
+ elif isinstance(locator, RelativePath):
66
+ # Try to open relative to current working directory:
67
+ return open(locator.relative_path, "rb")
68
+ else:
69
+ raise ValueError(f"Unsupported locator type: {locator.locator_type}")
70
+
71
+
72
+ # %%
73
+ def save_asset(
74
+ asset: Asset,
75
+ path: Path,
76
+ ) -> None:
77
+ """
78
+ Persist `asset` bytes to `path` atomically.
79
+ Raises:
80
+ - FileExistsError if target exists and `overwrite=False`
81
+ - ValueError for mismatched extension when `add_extension=True`
82
+ """
83
+ buffer_size: int = 1024 * 1024 # 1MB buffer for streaming copy
84
+
85
+ # --- ensure parent exists ---
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+
88
+ # --- exists check ---
89
+ if path.exists():
90
+ raise FileExistsError(f"Refusing to overwrite existing file: {path}")
91
+
92
+ # Fast path: hardlink local file if possible
93
+ loc = asset.locator
94
+ if isinstance(loc, FileSystemPath):
95
+ src = Path(loc.path)
96
+ try:
97
+ # Hardlink avoids IO; falls back to copy path if cross-device
98
+ os.link(src, path)
99
+ except OSError:
100
+ # Cross-device or FS limitation: fall through to streamed copy
101
+ pass
102
+ return
103
+
104
+ # Slow path: stream
105
+ tmp_file_descriptor, tmp_path_str = tempfile.mkstemp(
106
+ prefix=path.name + ".", dir=path.parent
107
+ )
108
+ tmp_path = Path(tmp_path_str)
109
+ try:
110
+ with os.fdopen(tmp_file_descriptor, "wb", closefd=True) as out_f:
111
+ # Stream from source to temp file
112
+ with open_asset(asset) as in_f:
113
+ shutil.copyfileobj(in_f, out_f, length=buffer_size)
114
+ out_f.flush()
115
+ os.fsync(out_f.fileno())
116
+
117
+ # Atomic move into place (overwrites if exists)
118
+ os.replace(tmp_path, path)
119
+ finally:
120
+ # If something failed before replace, cleanup temp
121
+ if tmp_path.exists():
122
+ try:
123
+ tmp_path.unlink()
124
+ except OSError:
125
+ pass
@@ -0,0 +1,4 @@
1
+ __all__ = [
2
+ "play",
3
+ ]
4
+ from nodekit._internal.ops.play.local_runner.main import play
File without changes