expdeploy 0.1.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 (37) hide show
  1. expdeploy/__init__.py +3 -0
  2. expdeploy/__main__.py +6 -0
  3. expdeploy/app.py +256 -0
  4. expdeploy/battery/__init__.py +0 -0
  5. expdeploy/battery/counterbalance.py +86 -0
  6. expdeploy/battery/orchestrator.py +48 -0
  7. expdeploy/cli.py +580 -0
  8. expdeploy/importmap.py +76 -0
  9. expdeploy/jspsych_assets/8.2.3/jspsych.css +524 -0
  10. expdeploy/jspsych_assets/8.2.3/jspsych.js +5535 -0
  11. expdeploy/jspsych_assets/8.2.3/manifest.json +53 -0
  12. expdeploy/jspsych_assets/8.2.3/plugins/call-function.js +69 -0
  13. expdeploy/jspsych_assets/8.2.3/plugins/fullscreen.js +164 -0
  14. expdeploy/jspsych_assets/8.2.3/plugins/html-button-response.js +223 -0
  15. expdeploy/jspsych_assets/8.2.3/plugins/html-keyboard-response.js +183 -0
  16. expdeploy/jspsych_assets/8.2.3/plugins/image-button-response.js +326 -0
  17. expdeploy/jspsych_assets/8.2.3/plugins/image-keyboard-response.js +270 -0
  18. expdeploy/jspsych_assets/8.2.3/plugins/instructions.js +341 -0
  19. expdeploy/jspsych_assets/8.2.3/plugins/preload.js +384 -0
  20. expdeploy/jspsych_assets/8.2.3/plugins/survey-text.js +244 -0
  21. expdeploy/jspsych_assets/__init__.py +1 -0
  22. expdeploy/loader.py +44 -0
  23. expdeploy/manifest.py +127 -0
  24. expdeploy/renderer.py +37 -0
  25. expdeploy/session.py +96 -0
  26. expdeploy/storage/__init__.py +1 -0
  27. expdeploy/storage/base.py +57 -0
  28. expdeploy/storage/fs.py +193 -0
  29. expdeploy/storage/sqlite.py +171 -0
  30. expdeploy/storage/supabase.py +104 -0
  31. expdeploy/storage/supabase_schema.sql +26 -0
  32. expdeploy/templates/deploy.html.j2 +46 -0
  33. expdeploy-0.1.0.dist-info/METADATA +65 -0
  34. expdeploy-0.1.0.dist-info/RECORD +37 -0
  35. expdeploy-0.1.0.dist-info/WHEEL +4 -0
  36. expdeploy-0.1.0.dist-info/entry_points.txt +2 -0
  37. expdeploy-0.1.0.dist-info/licenses/LICENSE +21 -0
expdeploy/cli.py ADDED
@@ -0,0 +1,580 @@
1
+ """expdeploy CLI — Typer-based."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import socket
8
+ import subprocess
9
+ import webbrowser
10
+ from pathlib import Path
11
+ from typing import Annotated, Any
12
+
13
+ import typer
14
+ import uvicorn
15
+
16
+ from expdeploy import __version__
17
+ from expdeploy.app import AppConfig, create_app
18
+ from expdeploy.battery.counterbalance import (
19
+ CounterbalanceStrategy,
20
+ FixedStrategy,
21
+ LatinSquareStrategy,
22
+ SeededRandomStrategy,
23
+ UserSuppliedStrategy,
24
+ )
25
+ from expdeploy.loader import ExperimentLoader, LoadedExperiment
26
+ from expdeploy.manifest import BatteryExperimentRef, BatteryInfo, BatteryManifest, load_battery
27
+ from expdeploy.storage.base import RunRecord
28
+ from expdeploy.storage.fs import FSAdapter
29
+ from expdeploy.storage.sqlite import SQLiteCatalog
30
+
31
+ app = typer.Typer(no_args_is_help=True, help="expdeploy — deploy jsPsych v8 experiments.")
32
+ init_app = typer.Typer(help="Scaffold a new experiment or battery.")
33
+ app.add_typer(init_app, name="init")
34
+
35
+ supabase_app = typer.Typer(help="Manage Supabase remote adapter.")
36
+ app.add_typer(supabase_app, name="supabase")
37
+
38
+
39
+ @supabase_app.command("migrate")
40
+ def supabase_migrate() -> None:
41
+ """Apply idempotent DDL to the configured Supabase Postgres."""
42
+ try:
43
+ from expdeploy.storage.supabase import SupabaseAdapter, SupabaseConfig
44
+
45
+ cfg = SupabaseConfig.from_env()
46
+ adapter = SupabaseAdapter(config=cfg)
47
+ adapter.apply_migrations()
48
+ except Exception as exc:
49
+ typer.echo(f"Migration failed: {exc}", err=True)
50
+ raise typer.Exit(code=1) from exc
51
+ typer.echo("Schema applied.")
52
+
53
+
54
+ @supabase_app.command("test-connection")
55
+ def supabase_test_connection() -> None:
56
+ """Verify the configured Supabase credentials and bucket access."""
57
+ try:
58
+ from expdeploy.storage.supabase import SupabaseAdapter, SupabaseConfig
59
+
60
+ cfg = SupabaseConfig.from_env()
61
+ adapter = SupabaseAdapter(config=cfg)
62
+ client = adapter._get_client()
63
+ # A trivial read against the configured schema.
64
+ client.schema(cfg.schema).table("runs").select("run_id").limit(1).execute()
65
+ typer.echo(f"Connected to {cfg.url} (schema={cfg.schema}, bucket={cfg.bucket}).")
66
+ except Exception as exc:
67
+ typer.echo(f"Connection failed: {exc}", err=True)
68
+ raise typer.Exit(code=1) from exc
69
+
70
+
71
+ @supabase_app.command("drop")
72
+ def supabase_drop(
73
+ confirm: Annotated[
74
+ bool, typer.Option("--confirm", help="Required for destructive operation")
75
+ ] = False,
76
+ ) -> None:
77
+ """DROP the expdeploy schema. Test envs only."""
78
+ if not confirm:
79
+ typer.echo("Refusing without --confirm.", err=True)
80
+ raise typer.Exit(code=2)
81
+ try:
82
+ from expdeploy.storage.supabase import SupabaseAdapter, SupabaseConfig
83
+
84
+ cfg = SupabaseConfig.from_env()
85
+ adapter = SupabaseAdapter(config=cfg)
86
+ client = adapter._get_client()
87
+ client.postgrest.rpc(
88
+ "exec_sql", {"sql": f"DROP SCHEMA IF EXISTS {cfg.schema} CASCADE;"}
89
+ ).execute()
90
+ typer.echo(f"Dropped schema {cfg.schema}.")
91
+ except Exception as exc:
92
+ typer.echo(f"Drop failed: {exc}", err=True)
93
+ raise typer.Exit(code=1) from exc
94
+
95
+
96
+ @app.command()
97
+ def version() -> None:
98
+ """Print the expdeploy version."""
99
+ typer.echo(f"expdeploy {__version__}")
100
+
101
+
102
+ @app.command()
103
+ def validate(
104
+ path: Annotated[Path, typer.Argument(exists=True, file_okay=False, dir_okay=True)],
105
+ ) -> None:
106
+ """Validate an experiment manifest at PATH."""
107
+ try:
108
+ ExperimentLoader().load(path)
109
+ except Exception as exc:
110
+ typer.echo(f"INVALID: {exc}", err=True)
111
+ raise typer.Exit(code=1) from exc
112
+ typer.echo("ok")
113
+
114
+
115
+ def _port_is_free(port: int, host: str = "127.0.0.1") -> bool:
116
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
117
+ try:
118
+ sock.bind((host, port))
119
+ except OSError:
120
+ return False
121
+ return True
122
+
123
+
124
+ def _next_free_port(start: int, host: str = "127.0.0.1", attempts: int = 100) -> int | None:
125
+ for candidate in range(start + 1, start + 1 + attempts):
126
+ if _port_is_free(candidate, host):
127
+ return candidate
128
+ return None
129
+
130
+
131
+ def _build_strategy(name: str, order_csv: Path | None) -> CounterbalanceStrategy:
132
+ if name == "fixed":
133
+ return FixedStrategy()
134
+ if name == "latin_square":
135
+ return LatinSquareStrategy()
136
+ if name == "seeded_random":
137
+ return SeededRandomStrategy()
138
+ if name == "user_supplied":
139
+ if order_csv is None:
140
+ msg = "user_supplied counterbalance requires --order-csv or [battery] order_csv"
141
+ raise typer.BadParameter(msg)
142
+ return UserSuppliedStrategy(order_csv=order_csv)
143
+ msg = f"unknown counterbalance {name!r}"
144
+ raise typer.BadParameter(msg)
145
+
146
+
147
+ def _classify_target(path: Path) -> str:
148
+ """Returns 'experiment', 'battery', or raises."""
149
+ if path.is_file() and path.suffix == ".toml":
150
+ return "battery"
151
+ if path.is_dir() and (path / "manifest.toml").exists():
152
+ return "experiment"
153
+ msg = f"{path} is neither an experiment dir nor a battery.toml"
154
+ raise typer.BadParameter(msg)
155
+
156
+
157
+ @app.command()
158
+ def run(
159
+ target: Annotated[Path | None, typer.Argument(help="Experiment dir or battery.toml")] = None,
160
+ exps: Annotated[
161
+ str | None,
162
+ typer.Option("--exps", help="Comma-delimited list of experiment dirs (inline battery)"),
163
+ ] = None,
164
+ counterbalance: Annotated[
165
+ str,
166
+ typer.Option(
167
+ "--counterbalance", help="fixed | latin_square | seeded_random | user_supplied"
168
+ ),
169
+ ] = "fixed",
170
+ order_csv: Annotated[
171
+ Path | None, typer.Option("--order-csv", help="For --counterbalance user_supplied")
172
+ ] = None,
173
+ subject: Annotated[str, typer.Option("--subject")] = "",
174
+ session: Annotated[str | None, typer.Option("--session")] = None,
175
+ run_num: Annotated[str | None, typer.Option("--run")] = None,
176
+ data_dir: Annotated[Path, typer.Option("--data-dir")] = Path("./data"),
177
+ port: Annotated[int, typer.Option("--port")] = 8080,
178
+ no_browser: Annotated[bool, typer.Option("--no-browser")] = False,
179
+ remote: Annotated[
180
+ list[str] | None,
181
+ typer.Option("--remote", help="Remote adapter name(s) to mirror data (e.g. supabase)"),
182
+ ] = None,
183
+ ) -> None:
184
+ """Serve an experiment or battery on a local port."""
185
+ if not subject:
186
+ typer.echo("--subject is required", err=True)
187
+ raise typer.Exit(code=2)
188
+
189
+ if target is None and not exps:
190
+ typer.echo("provide either a target path or --exps a,b,c", err=True)
191
+ raise typer.Exit(code=2)
192
+ if target is not None and exps:
193
+ typer.echo("cannot combine target path and --exps", err=True)
194
+ raise typer.Exit(code=2)
195
+
196
+ if not _port_is_free(port):
197
+ suggestion = _next_free_port(port)
198
+ msg = f"Port {port} is in use."
199
+ if suggestion is not None:
200
+ msg += f" Try --port {suggestion}."
201
+ typer.echo(msg, err=True)
202
+ raise typer.Exit(code=2)
203
+
204
+ from expdeploy import jspsych_assets
205
+
206
+ vendored_root = Path(jspsych_assets.__file__).resolve().parent
207
+ fs = FSAdapter(data_dir=data_dir)
208
+ catalog = SQLiteCatalog(db_path=data_dir / "catalog.sqlite")
209
+ catalog.init_schema()
210
+ state_dir = data_dir / "state"
211
+
212
+ from expdeploy.storage.base import StorageAdapter
213
+
214
+ remotes_list: list[StorageAdapter] = []
215
+ for r in remote or []:
216
+ if r == "supabase":
217
+ from expdeploy.storage.supabase import SupabaseAdapter, SupabaseConfig
218
+
219
+ remotes_list.append(SupabaseAdapter(config=SupabaseConfig.from_env()))
220
+ else:
221
+ typer.echo(f"Unknown --remote adapter: {r}", err=True)
222
+ raise typer.Exit(code=2)
223
+
224
+ if target is not None and _classify_target(target) == "experiment":
225
+ # Single-experiment mode
226
+ experiment = ExperimentLoader().load(target)
227
+ config = AppConfig(
228
+ vendored_root=vendored_root,
229
+ storage=fs,
230
+ catalog=catalog,
231
+ state_dir=state_dir,
232
+ subject_id=subject,
233
+ session_num=session,
234
+ run_num=run_num,
235
+ experiment=experiment,
236
+ remotes=tuple(remotes_list),
237
+ )
238
+ else:
239
+ # Battery mode: from manifest OR inline --exps
240
+ if target is not None:
241
+ battery = load_battery(target)
242
+ battery_dir = target.resolve().parent
243
+ exp_paths = [(e.exp_id, (battery_dir / e.path).resolve()) for e in battery.experiments]
244
+ else:
245
+ assert exps is not None
246
+ paths = [Path(p).expanduser().resolve() for p in exps.split(",") if p.strip()]
247
+ experiments_loaded = [(ExperimentLoader().load(p), p) for p in paths]
248
+ battery = BatteryManifest(
249
+ battery=BatteryInfo(name="inline", counterbalance=counterbalance), # type: ignore[arg-type]
250
+ experiments=[
251
+ BatteryExperimentRef(exp_id=loaded.manifest.experiment.exp_id, path=str(p))
252
+ for loaded, p in experiments_loaded
253
+ ],
254
+ )
255
+ exp_paths = [(loaded.manifest.experiment.exp_id, p) for loaded, p in experiments_loaded]
256
+
257
+ experiments_by_id: dict[str, LoadedExperiment] = {
258
+ exp_id: ExperimentLoader().load(p) for exp_id, p in exp_paths
259
+ }
260
+ strategy = _build_strategy(
261
+ battery.battery.counterbalance,
262
+ order_csv
263
+ if order_csv
264
+ else (Path(battery.battery.order_csv) if battery.battery.order_csv else None),
265
+ )
266
+ config = AppConfig(
267
+ vendored_root=vendored_root,
268
+ storage=fs,
269
+ catalog=catalog,
270
+ state_dir=state_dir,
271
+ subject_id=subject,
272
+ session_num=session,
273
+ run_num=run_num,
274
+ battery_manifest=battery,
275
+ experiments_by_id=experiments_by_id,
276
+ counterbalance_strategy=strategy,
277
+ remotes=tuple(remotes_list),
278
+ )
279
+
280
+ fastapi_app = create_app(config)
281
+ host = os.environ.get("EXPDEPLOY_HOST", "127.0.0.1")
282
+ url = f"http://{host}:{port}/"
283
+ if config.is_battery():
284
+ assert config.battery_manifest is not None
285
+ label = f"battery {config.battery_manifest.battery.name}"
286
+ else:
287
+ assert config.experiment is not None
288
+ label = config.experiment.manifest.experiment.exp_id
289
+ typer.echo(f"Serving {label} at {url}")
290
+ if not no_browser:
291
+ webbrowser.open(url)
292
+ uvicorn.run(fastapi_app, host=host, port=port, log_level="info")
293
+
294
+
295
+ @app.command()
296
+ def status(
297
+ data_dir: Annotated[Path, typer.Option("--data-dir")] = Path("./data"),
298
+ subject: Annotated[str | None, typer.Option("--subject")] = None,
299
+ limit: Annotated[int, typer.Option("--limit")] = 25,
300
+ ) -> None:
301
+ """Show recent runs from the SQLite catalog."""
302
+ from rich.console import Console
303
+ from rich.table import Table
304
+
305
+ catalog = SQLiteCatalog(db_path=data_dir / "catalog.sqlite")
306
+ if not catalog.db_path.exists():
307
+ typer.echo("no runs (catalog not yet created)")
308
+ return
309
+ catalog.init_schema()
310
+ rows = (
311
+ catalog.runs_for_subject(subject)[:limit] if subject else catalog.recent_runs(limit=limit)
312
+ )
313
+ if not rows:
314
+ typer.echo("no runs")
315
+ return
316
+ table = Table(title=f"Recent runs ({len(rows)})")
317
+ for col in ("started_at", "subject_id", "exp_id", "status", "run_id"):
318
+ table.add_column(col)
319
+ for row in rows:
320
+ table.add_row(
321
+ str(row["started_at"]),
322
+ str(row["subject_id"]),
323
+ str(row["exp_id"]),
324
+ str(row["status"]),
325
+ str(row["run_id"]),
326
+ )
327
+ Console().print(table)
328
+
329
+
330
+ def _row_to_run_record(row: dict[str, Any]) -> RunRecord:
331
+ import json as _json
332
+
333
+ return RunRecord(
334
+ run_id=row["run_id"],
335
+ exp_id=row["exp_id"],
336
+ exp_version=row["exp_version"],
337
+ subject_id=row["subject_id"],
338
+ session_num=row["session_num"],
339
+ run_num=row["run_num"],
340
+ battery_id=row["battery_id"],
341
+ group_index=row["group_index"],
342
+ started_at=row["started_at"],
343
+ ended_at=row["ended_at"],
344
+ status=row["status"],
345
+ trials=_json.loads(row["trials_json"] or "[]"),
346
+ interaction_data=_json.loads(row["interaction_data_json"] or "[]"),
347
+ jspsych_version=row["jspsych_version"],
348
+ deploy_version=row["deploy_version"],
349
+ client_user_agent=row["client_user_agent"],
350
+ )
351
+
352
+
353
+ @app.command()
354
+ def sync(
355
+ adapter: Annotated[str, typer.Option("--adapter")] = "supabase",
356
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
357
+ data_dir: Annotated[Path, typer.Option("--data-dir")] = Path("./data"),
358
+ ) -> None:
359
+ """Replay failed remote-storage writes against the configured adapter."""
360
+ catalog = SQLiteCatalog(db_path=data_dir / "catalog.sqlite")
361
+ if not catalog.db_path.exists():
362
+ typer.echo(f"No catalog at {catalog.db_path}", err=True)
363
+ raise typer.Exit(code=1)
364
+ pending = catalog.pending_remote_writes(adapter=adapter)
365
+ if not pending:
366
+ typer.echo("Nothing to sync.")
367
+ return
368
+
369
+ if dry_run:
370
+ typer.echo(f"Would replay {len(pending)} run(s) against {adapter}:")
371
+ for r in pending:
372
+ typer.echo(f" - {r['run_id']} ({r['subject_id']} / {r['exp_id']})")
373
+ return
374
+
375
+ if adapter != "supabase":
376
+ typer.echo(f"Unknown adapter {adapter!r}", err=True)
377
+ raise typer.Exit(code=2)
378
+
379
+ from expdeploy.storage.supabase import SupabaseAdapter, SupabaseConfig
380
+
381
+ try:
382
+ cfg = SupabaseConfig.from_env()
383
+ except Exception as exc:
384
+ typer.echo(f"Supabase config error: {exc}", err=True)
385
+ raise typer.Exit(code=1) from exc
386
+
387
+ sa = SupabaseAdapter(config=cfg)
388
+ ok_count = 0
389
+ fail_count = 0
390
+ for row in pending:
391
+ record = _row_to_run_record(row)
392
+ result = sa.save(record)
393
+ if result.ok:
394
+ catalog.record_remote_attempt(record.run_id, adapter, "synced", remote_uri=result.path)
395
+ ok_count += 1
396
+ else:
397
+ catalog.record_remote_attempt(record.run_id, adapter, "failed", error=result.error)
398
+ fail_count += 1
399
+
400
+ typer.echo(f"Synced {ok_count} / Failed {fail_count}.")
401
+ if fail_count:
402
+ raise typer.Exit(code=1)
403
+
404
+
405
+ @app.command()
406
+ def build(
407
+ target: Annotated[
408
+ Path, typer.Argument(exists=True, help="Path to battery.toml or experiment dir")
409
+ ],
410
+ tag: Annotated[
411
+ str, typer.Option("--tag", help="OCI image tag, e.g. ghcr.io/you/study:2026-05-17")
412
+ ],
413
+ base_tag: Annotated[
414
+ str, typer.Option("--base-tag", help="Base image tag to FROM")
415
+ ] = "ghcr.io/lobennett/expdeploy:latest",
416
+ engine: Annotated[str, typer.Option("--engine", help="docker | podman")] = "docker",
417
+ push: Annotated[bool, typer.Option("--push/--no-push")] = False,
418
+ output: Annotated[
419
+ Path | None,
420
+ typer.Option("--output", help="Where to write study.Dockerfile (default: next to target)"),
421
+ ] = None,
422
+ ) -> None:
423
+ """Build a study-specific OCI image with experiments baked in."""
424
+ target = target.resolve()
425
+ if target.is_file() and target.suffix == ".toml":
426
+ battery = load_battery(target)
427
+ battery_dir = target.parent
428
+ copies = [
429
+ (
430
+ e.exp_id,
431
+ Path(e.path) if Path(e.path).is_absolute() else (battery_dir / e.path).resolve(),
432
+ )
433
+ for e in battery.experiments
434
+ ]
435
+ battery_file = target.name
436
+ elif target.is_dir() and (target / "manifest.toml").exists():
437
+ loaded = ExperimentLoader().load(target)
438
+ battery = None
439
+ copies = [(loaded.manifest.experiment.exp_id, target)]
440
+ battery_file = None
441
+ battery_dir = target.parent
442
+ else:
443
+ typer.echo(f"{target} is neither battery.toml nor an experiment dir", err=True)
444
+ raise typer.Exit(code=2)
445
+
446
+ # Validate each experiment
447
+ for _eid, p in copies:
448
+ ExperimentLoader().load(p)
449
+
450
+ # Compute manifest hash (content provenance label)
451
+ h = hashlib.sha256()
452
+ for _eid, p in copies:
453
+ for f in sorted(p.rglob("*")):
454
+ if f.is_file():
455
+ h.update(f.read_bytes())
456
+ if battery_file:
457
+ h.update((battery_dir / battery_file).read_bytes())
458
+ manifest_hash = h.hexdigest()[:16]
459
+
460
+ # Build the Dockerfile body
461
+ lines = [f"FROM {base_tag}"]
462
+ for eid, p in copies:
463
+ # Use COPY <relative-to-Dockerfile> /experiments/<eid>
464
+ rel = p.relative_to(battery_dir) if p.is_relative_to(battery_dir) else p
465
+ lines.append(f"COPY ./{rel} /experiments/{eid}")
466
+ if battery_file:
467
+ lines.append(f"COPY ./{battery_file} /experiments/{battery_file}")
468
+ lines.append(f"ENV EXPDEPLOY_BATTERY=/experiments/{battery_file}")
469
+ cmd = f'CMD ["run", "/experiments/{battery_file}"]'
470
+ else:
471
+ eid = copies[0][0]
472
+ cmd = f'CMD ["run", "/experiments/{eid}"]'
473
+ lines.append(f'LABEL org.expdeploy.manifest_hash="{manifest_hash}"')
474
+ lines.append(f'LABEL org.expdeploy.deploy_version="{__version__}"')
475
+ lines.append(cmd)
476
+
477
+ dockerfile_path = (output if output else (battery_dir / "study.Dockerfile")).resolve()
478
+ dockerfile_path.write_text("\n".join(lines) + "\n")
479
+ typer.echo(f"Wrote {dockerfile_path}")
480
+
481
+ # Invoke the engine
482
+ build_cmd = [engine, "build", "-f", str(dockerfile_path), "-t", tag, str(battery_dir)]
483
+ typer.echo(" ".join(build_cmd))
484
+ proc = subprocess.run(build_cmd, check=False)
485
+ if proc.returncode != 0:
486
+ typer.echo(f"{engine} build failed (exit {proc.returncode})", err=True)
487
+ raise typer.Exit(code=proc.returncode)
488
+
489
+ if push:
490
+ push_cmd = [engine, "push", tag]
491
+ typer.echo(" ".join(push_cmd))
492
+ proc = subprocess.run(push_cmd, check=False)
493
+ if proc.returncode != 0:
494
+ typer.echo(f"{engine} push failed (exit {proc.returncode})", err=True)
495
+ raise typer.Exit(code=proc.returncode)
496
+
497
+ typer.echo(f"Built {tag}")
498
+
499
+
500
+ @init_app.command("experiment")
501
+ def init_experiment(
502
+ target: Annotated[Path, typer.Argument(help="Directory to create")],
503
+ task: Annotated[str, typer.Option("--task", help="BIDS task label (alphanumeric)")] = "task",
504
+ type_: Annotated[str, typer.Option("--type", help="behavioral | fmri | none")] = "none",
505
+ ) -> None:
506
+ target.mkdir(parents=True, exist_ok=False)
507
+ manifest_lines = [
508
+ "[experiment]",
509
+ f'exp_id = "{task}"',
510
+ f'name = "{task.capitalize()}"',
511
+ 'version = "0.1.0"',
512
+ 'entry = "index.js"',
513
+ 'style = "style.css"',
514
+ "",
515
+ "[jspsych]",
516
+ 'version = "8.2.3"',
517
+ 'plugins = ["@jspsych/plugin-html-keyboard-response@2.1.0"]',
518
+ ]
519
+ if type_ in ("behavioral", "fmri"):
520
+ manifest_lines += [
521
+ "",
522
+ "[bids]",
523
+ f'type = "{type_}"',
524
+ f'task = "{task}"',
525
+ ]
526
+ (target / "manifest.toml").write_text("\n".join(manifest_lines) + "\n")
527
+ (target / "index.js").write_text(
528
+ """import { initJsPsych } from "jspsych";
529
+ import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response";
530
+
531
+ export default function build() {
532
+ const startedAt = new Date().toISOString();
533
+ const jsPsych = initJsPsych({
534
+ on_finish: () => {
535
+ window.expdeploy.submit({
536
+ exp_id: window.expdeploy.expId,
537
+ subject_id: window.expdeploy.subjectId,
538
+ started_at: startedAt,
539
+ ended_at: new Date().toISOString(),
540
+ trials: jsPsych.data.get().values(),
541
+ status: "finished",
542
+ });
543
+ },
544
+ });
545
+ jsPsych.run([
546
+ { type: htmlKeyboardResponse, stimulus: "<h1>"""
547
+ + task
548
+ + """</h1><p>Press any key.</p>" },
549
+ ]);
550
+ }
551
+ """
552
+ )
553
+ (target / "style.css").write_text(
554
+ "body { font-family: system-ui, sans-serif; text-align: center; margin-top: 4em; }\n"
555
+ )
556
+ typer.echo(f"Created {target}")
557
+
558
+
559
+ @init_app.command("battery")
560
+ def init_battery(
561
+ target: Annotated[Path, typer.Argument(help="Directory to create")],
562
+ experiments: Annotated[
563
+ str, typer.Option("--experiments", help="Comma-delimited experiment dirs")
564
+ ],
565
+ counterbalance: Annotated[str, typer.Option("--counterbalance")] = "latin_square",
566
+ ) -> None:
567
+ target.mkdir(parents=True, exist_ok=False)
568
+ paths = [Path(p).expanduser().resolve() for p in experiments.split(",") if p.strip()]
569
+ rows: list[str] = []
570
+ for p in paths:
571
+ loaded = ExperimentLoader().load(p)
572
+ eid = loaded.manifest.experiment.exp_id
573
+ rows.append(f'[[experiments]]\nexp_id = "{eid}"\npath = "{p}"\n')
574
+ body = (
575
+ "[battery]\n"
576
+ f'name = "{target.name}"\n'
577
+ f'counterbalance = "{counterbalance}"\n\n' + "\n".join(rows)
578
+ )
579
+ (target / "battery.toml").write_text(body)
580
+ typer.echo(f"Created {target / 'battery.toml'}")
expdeploy/importmap.py ADDED
@@ -0,0 +1,76 @@
1
+ """Build browser-native import maps for served experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from expdeploy.loader import LoadedExperiment
10
+
11
+ PLUGIN_NAME_RE = re.compile(r"^(@jspsych/plugin-[a-zA-Z0-9-]+)(?:@([\d.]+))?$")
12
+
13
+
14
+ class ImportMapBuilder:
15
+ """Constructs a browser import map for a LoadedExperiment.
16
+
17
+ The vendored_root is `src/expdeploy/jspsych_assets/`. Inside it, each
18
+ available jsPsych version has its own subdir (e.g., `8.2.3/`) with a
19
+ `manifest.json` describing what's available.
20
+ """
21
+
22
+ def __init__(self, vendored_root: Path) -> None:
23
+ self.vendored_root = Path(vendored_root).resolve()
24
+
25
+ def build(
26
+ self,
27
+ loaded: LoadedExperiment,
28
+ *,
29
+ jspsych_url_prefix: str,
30
+ experiment_url_prefix: str,
31
+ ) -> dict[str, dict[str, str]]:
32
+ manifest = loaded.manifest
33
+ version = manifest.jspsych.version
34
+ version_dir = self.vendored_root / version
35
+ if not version_dir.is_dir():
36
+ available = sorted(p.name for p in self.vendored_root.iterdir() if p.is_dir())
37
+ msg = f"jsPsych version {version} is not vendored. Available versions: {available}"
38
+ raise LookupError(msg)
39
+
40
+ vendored_manifest_path = version_dir / "manifest.json"
41
+ if not vendored_manifest_path.is_file():
42
+ msg = f"Vendored manifest missing at {vendored_manifest_path}"
43
+ raise LookupError(msg)
44
+ vendored = json.loads(vendored_manifest_path.read_text())
45
+
46
+ imports: dict[str, str] = {}
47
+
48
+ # Core jsPsych
49
+ imports["jspsych"] = f"{jspsych_url_prefix}/{vendored['jspsych']['esm']}"
50
+
51
+ # Plugins declared in the experiment manifest
52
+ for plugin_spec in manifest.jspsych.plugins:
53
+ match = PLUGIN_NAME_RE.match(plugin_spec)
54
+ if match is None:
55
+ msg = f"Unrecognized plugin spec: {plugin_spec!r}"
56
+ raise ValueError(msg)
57
+ name = match.group(1)
58
+ if name not in vendored:
59
+ available_plugins = sorted(k for k in vendored if k.startswith("@jspsych/"))
60
+ msg = f"Plugin not vendored: {name}. Available: {available_plugins}"
61
+ raise LookupError(msg)
62
+ imports[name] = f"{jspsych_url_prefix}/{vendored[name]['esm']}"
63
+
64
+ # Experiment-local imports for bare ./ paths via the import map
65
+ # (relative imports in index.js resolve automatically; this exposes
66
+ # any aliases declared in import_map_extras)
67
+ for alias, relative in manifest.import_map_extras.items():
68
+ if relative.startswith("./"):
69
+ url = f"{experiment_url_prefix}/{relative[2:]}"
70
+ elif relative.startswith("/") or relative.startswith("http"):
71
+ url = relative
72
+ else:
73
+ url = f"{experiment_url_prefix}/{relative}"
74
+ imports[alias] = url
75
+
76
+ return {"imports": imports}