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,219 @@
1
+ import pydantic
2
+
3
+ import boto3
4
+ import botocore.client
5
+ import botocore.exceptions
6
+ from typing import BinaryIO
7
+ import hashlib
8
+ import mimetypes
9
+
10
+ from nodekit._internal.types.value import SHA256, MediaType
11
+ import os
12
+ from pathlib import Path
13
+
14
+ from urllib.parse import quote
15
+
16
+
17
+ # %%
18
+ class UploadAssetResult(pydantic.BaseModel):
19
+ sha256: SHA256
20
+ mime_type: MediaType
21
+ asset_url: pydantic.HttpUrl
22
+
23
+
24
+ class S3Client:
25
+ class Config(pydantic.BaseModel):
26
+ bucket_name: str
27
+ region_name: str
28
+ aws_access_key_id: str
29
+ aws_secret_access_key: pydantic.SecretStr
30
+
31
+ def __init__(
32
+ self,
33
+ bucket_name: str,
34
+ region_name: str,
35
+ aws_access_key_id: str,
36
+ aws_secret_access_key: str,
37
+ ):
38
+ self.config = self.Config(
39
+ bucket_name=bucket_name,
40
+ region_name=region_name,
41
+ aws_access_key_id=aws_access_key_id,
42
+ aws_secret_access_key=pydantic.SecretStr(aws_secret_access_key),
43
+ )
44
+ self._client: botocore.client.BaseClient = boto3.client(
45
+ "s3",
46
+ region_name=self.config.region_name,
47
+ aws_access_key_id=self.config.aws_access_key_id,
48
+ aws_secret_access_key=self.config.aws_secret_access_key.get_secret_value(),
49
+ )
50
+
51
+ @staticmethod
52
+ def _derive_s3_key(
53
+ sha256: SHA256,
54
+ mime_type: MediaType,
55
+ ) -> str:
56
+ ext = mimetypes.guess_extension(mime_type)
57
+ if ext is None:
58
+ raise ValueError(
59
+ f"Could not determine file extension for mime type {mime_type}"
60
+ )
61
+ return f"assets/{mime_type}/{sha256}{ext}"
62
+
63
+ def _assemble_s3_url(self, key: str) -> pydantic.HttpUrl:
64
+ url = f"https://{self.config.bucket_name}.s3.{self.config.region_name}.amazonaws.com/{key}"
65
+ return pydantic.HttpUrl(url)
66
+
67
+ def maybe_resolve_asset(
68
+ self,
69
+ sha256: SHA256,
70
+ mime_type: MediaType,
71
+ ) -> UploadAssetResult | None:
72
+ # Derive S3 key
73
+ key = self._derive_s3_key(sha256=sha256, mime_type=mime_type)
74
+
75
+ # Check if it exists
76
+ try:
77
+ self._client.head_object(Bucket=self.config.bucket_name, Key=key)
78
+ except botocore.exceptions.ClientError as e:
79
+ if e.response["Error"]["Code"] == "404":
80
+ return None
81
+ else:
82
+ raise RuntimeError(
83
+ f"S3 head_object failed: {e.response.get('Error', {}).get('Message', str(e))}"
84
+ ) from e
85
+
86
+ # Return resolved asset ref
87
+ return UploadAssetResult(
88
+ mime_type=mime_type,
89
+ sha256=sha256,
90
+ asset_url=self._assemble_s3_url(key),
91
+ )
92
+
93
+ def upload_asset(
94
+ self,
95
+ claimed_sha256: SHA256,
96
+ file: BinaryIO,
97
+ mime_type: MediaType,
98
+ ) -> UploadAssetResult:
99
+ """
100
+ Stream `file` directly to S3 with the given MIME type, and returns it public link.
101
+ """
102
+ # ---- derive a safe S3 key
103
+ ext = mimetypes.guess_extension(mime_type)
104
+ if ext is None:
105
+ raise ValueError(
106
+ f"Could not determine file extension for mime type {mime_type}"
107
+ )
108
+
109
+ # First I/O pass: compute SHA256
110
+ try:
111
+ file.seek(0)
112
+ except Exception:
113
+ pass
114
+
115
+ sha256 = hashlib.sha256()
116
+ try:
117
+ file.seek(0)
118
+ except Exception:
119
+ pass
120
+ while True:
121
+ chunk = file.read(8192) # 8 MB
122
+ if not chunk:
123
+ break
124
+ sha256.update(chunk)
125
+ sha256 = sha256.hexdigest()
126
+
127
+ if not claimed_sha256 == sha256:
128
+ raise ValueError(
129
+ f"SHA256 mismatch: claimed {claimed_sha256}, computed {sha256}"
130
+ )
131
+
132
+ # Return immediately if already uploaded:
133
+ key = self._derive_s3_key(sha256=sha256, mime_type=mime_type)
134
+ asset_url = self._assemble_s3_url(key)
135
+
136
+ # Second I/O pass: upload to S3
137
+ file.seek(0)
138
+
139
+ try:
140
+ self._client.upload_fileobj(
141
+ Fileobj=file,
142
+ Bucket=self.config.bucket_name,
143
+ Key=key,
144
+ ExtraArgs={
145
+ "ContentType": str(mime_type),
146
+ "ACL": "public-read",
147
+ },
148
+ )
149
+ except botocore.exceptions.ClientError as e:
150
+ raise RuntimeError(
151
+ f"S3 upload failed: {e.response.get('Error', {}).get('Message', str(e))}"
152
+ ) from e
153
+
154
+ # Package return model
155
+ return UploadAssetResult(
156
+ sha256=sha256,
157
+ asset_url=asset_url,
158
+ mime_type=mime_type,
159
+ )
160
+
161
+ def sync_file(
162
+ self,
163
+ local_path: os.PathLike | str,
164
+ local_root: os.PathLike | str,
165
+ bucket_root: os.PathLike | str,
166
+ force: bool = False,
167
+ ) -> str:
168
+ """
169
+ Upload a single file to S3 under the key derived from its path relative to `local_root`,
170
+ prefixed by `bucket_root`. Skips upload if an object already exists with the same size,
171
+ unless `force=True`. Makes the object public-read and sets ContentType.
172
+
173
+ Returns the public URL of the object.
174
+ """
175
+ p = Path(local_path)
176
+ local_root = Path(local_root)
177
+ if not p.is_file() or p.is_symlink():
178
+ raise ValueError(f"Unsupported file type: {p}")
179
+
180
+ # Derive S3 key (prefix + rel path), then URL-encode it (keep '/' separators)
181
+ rel = p.relative_to(local_root).as_posix()
182
+ prefix = str(bucket_root).strip("/")
183
+ key = f"{prefix}/{rel}" if prefix else rel
184
+ encoded_key = quote(key, safe="/")
185
+
186
+ # Compute URL (prefer virtual-hosted-style for AWS, path-style for custom endpoints)
187
+ endpoint = self._client.meta.endpoint_url.rstrip("/")
188
+ if "amazonaws.com" in endpoint:
189
+ url = f"https://{self.config.bucket_name}.s3.{self.config.region_name}.amazonaws.com/{encoded_key}"
190
+ else:
191
+ # e.g., MinIO or custom S3-compatible endpoint
192
+ url = f"{endpoint}/{self.config.bucket_name}/{encoded_key}"
193
+
194
+ # Skip if already present with same size (unless force)
195
+ try:
196
+ head = self._client.head_object(Bucket=self.config.bucket_name, Key=key)
197
+ if head.get("ContentLength") == p.stat().st_size and not force:
198
+ return url
199
+ except self._client.exceptions.ClientError as e:
200
+ if e.response.get("Error", {}).get("Code") != "404":
201
+ raise # unexpected error → bubble up
202
+
203
+ # Upload
204
+ mime, _ = mimetypes.guess_type(p.name)
205
+ extra = {
206
+ "ContentType": mime or "application/octet-stream",
207
+ "ACL": "public-read",
208
+ }
209
+ with p.open("rb") as f:
210
+ self._client.upload_fileobj(
211
+ Fileobj=f,
212
+ Bucket=self.config.bucket_name,
213
+ Key=key,
214
+ ExtraArgs=extra,
215
+ )
216
+
217
+ print(f"Uploaded {p.name} to S3")
218
+
219
+ return url
@@ -0,0 +1,223 @@
1
+ import os
2
+ import uuid
3
+ from decimal import Decimal
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+ import glob
7
+ import pydantic
8
+
9
+ import nodekit as nk
10
+ from nodekit.experimental.recruitment_services.base import (
11
+ RecruiterServiceClient,
12
+ CreateHitRequest,
13
+ SendBonusPaymentRequest,
14
+ )
15
+ from nodekit.experimental.s3 import S3Client
16
+
17
+ # %%
18
+ type HitId = str
19
+ type AssignmentId = str
20
+ type WorkerId = str
21
+
22
+
23
+ # %%
24
+ class TraceResult(pydantic.BaseModel):
25
+ hit_id: HitId
26
+ assignment_id: AssignmentId
27
+ worker_id: WorkerId
28
+ trace: nk.Trace | None # If None, validation failed
29
+
30
+
31
+ class HitRequest(pydantic.BaseModel):
32
+ graph: nk.Graph
33
+ num_assignments: int
34
+ base_payment_usd: str
35
+ title: str
36
+ duration_sec: int = pydantic.Field(gt=0)
37
+ unique_request_token: str | None
38
+ hit_id: HitId
39
+
40
+
41
+ class Helper:
42
+ """
43
+ Experimental; this might be moved to PsyHub / PsychoScope.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ recruiter_service_client: RecruiterServiceClient,
49
+ s3_client: S3Client,
50
+ local_cachedir: os.PathLike | str,
51
+ ):
52
+ self.recruiter_service_client = recruiter_service_client
53
+ self.s3_client = s3_client
54
+ self.local_cachedir = Path(local_cachedir)
55
+
56
+ def _get_hit_cachedir(self) -> Path:
57
+ return (
58
+ self.local_cachedir
59
+ / "hits"
60
+ / self.recruiter_service_client.get_recruiter_service_name()
61
+ )
62
+
63
+ def create_hit(
64
+ self,
65
+ graph: nk.Graph,
66
+ num_assignments: int,
67
+ base_payment_usd: str,
68
+ title: str,
69
+ duration_sec: int,
70
+ project_name: str,
71
+ unique_request_token: str | None = None,
72
+ ) -> HitId:
73
+ """
74
+ Creates a HIT based on the given Graph.
75
+ Automatically ensures a public site for the Graph exists on S3.
76
+ Caches the HIT (and its Graph) in the local cache.
77
+ """
78
+
79
+ graph_site_url = self.upload_graph_site(graph=graph)
80
+
81
+ if unique_request_token is None:
82
+ unique_request_token = uuid.uuid4().hex
83
+
84
+ response = self.recruiter_service_client.create_hit(
85
+ request=CreateHitRequest(
86
+ entrypoint_url=graph_site_url,
87
+ title=title,
88
+ description=title,
89
+ keywords=["psychology", "task", "cognitive", "science", "game"],
90
+ num_assignments=num_assignments,
91
+ duration_sec=duration_sec,
92
+ completion_reward_usd=Decimal(base_payment_usd),
93
+ unique_request_token=unique_request_token,
94
+ allowed_participant_ids=[],
95
+ )
96
+ )
97
+ hit_id: HitId = response.hit_id
98
+
99
+ # Just save the raw wire model, and hope the asset refs don't change. Todo: !
100
+ try:
101
+ hit_request = HitRequest(
102
+ graph=graph,
103
+ num_assignments=num_assignments,
104
+ base_payment_usd=base_payment_usd,
105
+ title=title,
106
+ duration_sec=duration_sec,
107
+ unique_request_token=unique_request_token,
108
+ hit_id=hit_id,
109
+ )
110
+ savepath = self._get_hit_cachedir() / project_name / f"{hit_id}.json"
111
+ if not savepath.parent.exists():
112
+ savepath.parent.mkdir(parents=True)
113
+ savepath.write_text(hit_request.model_dump_json(indent=2))
114
+ except Exception as e:
115
+ raise Exception(
116
+ f"Could not save Graph for HIT ({hit_id}) to local cache."
117
+ ) from e
118
+
119
+ return hit_id
120
+
121
+ def list_hits(self, project_name: str | None = None) -> list[HitId]:
122
+ # Just read off the local cache
123
+ savedir = self._get_hit_cachedir()
124
+ savedir.mkdir(parents=True, exist_ok=True)
125
+ hit_ids: list[HitId] = []
126
+
127
+ if project_name is None:
128
+ search_results = glob.glob(str(savedir / "**/*.json"), recursive=True)
129
+ else:
130
+ search_results = glob.glob(str(savedir / f"{project_name}/*.json"))
131
+
132
+ for path in search_results:
133
+ hit_ids.append(Path(path).stem.split("*.json")[0])
134
+ return hit_ids
135
+
136
+ def upload_graph_site(self, graph: nk.Graph) -> str:
137
+ """
138
+ Returns a URL to a public Graph site.
139
+ """
140
+
141
+ # Build the Graph site
142
+ build_site_result = nk.build_site(graph=graph, savedir=self.local_cachedir)
143
+
144
+ # Ensure index is sync'd
145
+ index_path = build_site_result.site_root / build_site_result.entrypoint
146
+ index_url = self.s3_client.sync_file(
147
+ local_path=index_path,
148
+ local_root=build_site_result.site_root,
149
+ bucket_root="",
150
+ force=False,
151
+ )
152
+
153
+ # Ensure deps are sync'd
154
+ for dep in build_site_result.dependencies:
155
+ self.s3_client.sync_file(
156
+ local_path=build_site_result.site_root / dep,
157
+ local_root=build_site_result.site_root,
158
+ bucket_root="",
159
+ force=False,
160
+ )
161
+
162
+ return index_url
163
+
164
+ def iter_traces(
165
+ self,
166
+ hit_id: HitId,
167
+ ) -> Iterable[TraceResult]:
168
+ """
169
+ Iterate the Traces collected under the given HIT ID.
170
+ Automatically approves any unapproved assignments.
171
+ """
172
+
173
+ # Pull new assignments
174
+ for asn in self.recruiter_service_client.iter_assignments(hit_id=hit_id):
175
+ # Ensure assignment is approved
176
+ if asn.status != "Approved":
177
+ self.recruiter_service_client.approve_assignment(
178
+ assignment_id=asn.assignment_id,
179
+ )
180
+ try:
181
+ trace = nk.Trace.model_validate_json(asn.submission_payload)
182
+ except pydantic.ValidationError:
183
+ print(
184
+ f"\n\n{asn.assignment_id}: Error validating submission payload:",
185
+ asn.submission_payload,
186
+ )
187
+ trace = None
188
+
189
+ yield TraceResult(
190
+ hit_id=hit_id,
191
+ assignment_id=asn.assignment_id,
192
+ worker_id=asn.worker_id,
193
+ trace=trace,
194
+ )
195
+
196
+ def pay_bonus(
197
+ self,
198
+ worker_id: WorkerId,
199
+ assignment_id: AssignmentId,
200
+ amount_usd: str,
201
+ ) -> None:
202
+ self.recruiter_service_client.send_bonus_payment(
203
+ request=SendBonusPaymentRequest(
204
+ assignment_id=assignment_id,
205
+ amount_usd=Decimal(amount_usd),
206
+ worker_id=worker_id,
207
+ )
208
+ )
209
+
210
+ def get_hit(
211
+ self,
212
+ hit_id: HitId,
213
+ ) -> HitRequest:
214
+ """
215
+ Loads the Graph associated with the given HIT ID.
216
+ (Hit the local cache)
217
+ """
218
+ savepath = self._get_hit_cachedir() / f"{hit_id}.json"
219
+ if not savepath.parent.exists():
220
+ raise Exception(f"Could not save Graph for HIT {hit_id}.")
221
+
222
+ hit_request = HitRequest.model_validate_json(savepath.read_text())
223
+ return hit_request
File without changes