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.
- expdeploy/__init__.py +3 -0
- expdeploy/__main__.py +6 -0
- expdeploy/app.py +256 -0
- expdeploy/battery/__init__.py +0 -0
- expdeploy/battery/counterbalance.py +86 -0
- expdeploy/battery/orchestrator.py +48 -0
- expdeploy/cli.py +580 -0
- expdeploy/importmap.py +76 -0
- expdeploy/jspsych_assets/8.2.3/jspsych.css +524 -0
- expdeploy/jspsych_assets/8.2.3/jspsych.js +5535 -0
- expdeploy/jspsych_assets/8.2.3/manifest.json +53 -0
- expdeploy/jspsych_assets/8.2.3/plugins/call-function.js +69 -0
- expdeploy/jspsych_assets/8.2.3/plugins/fullscreen.js +164 -0
- expdeploy/jspsych_assets/8.2.3/plugins/html-button-response.js +223 -0
- expdeploy/jspsych_assets/8.2.3/plugins/html-keyboard-response.js +183 -0
- expdeploy/jspsych_assets/8.2.3/plugins/image-button-response.js +326 -0
- expdeploy/jspsych_assets/8.2.3/plugins/image-keyboard-response.js +270 -0
- expdeploy/jspsych_assets/8.2.3/plugins/instructions.js +341 -0
- expdeploy/jspsych_assets/8.2.3/plugins/preload.js +384 -0
- expdeploy/jspsych_assets/8.2.3/plugins/survey-text.js +244 -0
- expdeploy/jspsych_assets/__init__.py +1 -0
- expdeploy/loader.py +44 -0
- expdeploy/manifest.py +127 -0
- expdeploy/renderer.py +37 -0
- expdeploy/session.py +96 -0
- expdeploy/storage/__init__.py +1 -0
- expdeploy/storage/base.py +57 -0
- expdeploy/storage/fs.py +193 -0
- expdeploy/storage/sqlite.py +171 -0
- expdeploy/storage/supabase.py +104 -0
- expdeploy/storage/supabase_schema.sql +26 -0
- expdeploy/templates/deploy.html.j2 +46 -0
- expdeploy-0.1.0.dist-info/METADATA +65 -0
- expdeploy-0.1.0.dist-info/RECORD +37 -0
- expdeploy-0.1.0.dist-info/WHEEL +4 -0
- expdeploy-0.1.0.dist-info/entry_points.txt +2 -0
- 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}
|