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/__init__.py
ADDED
expdeploy/__main__.py
ADDED
expdeploy/app.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""FastAPI app factory and route handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI, HTTPException
|
|
10
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
|
|
13
|
+
from expdeploy import __version__
|
|
14
|
+
from expdeploy.battery.counterbalance import CounterbalanceStrategy
|
|
15
|
+
from expdeploy.battery.orchestrator import BatteryOrchestrator
|
|
16
|
+
from expdeploy.importmap import ImportMapBuilder
|
|
17
|
+
from expdeploy.loader import LoadedExperiment
|
|
18
|
+
from expdeploy.manifest import BatteryManifest
|
|
19
|
+
from expdeploy.renderer import render_experiment_html
|
|
20
|
+
from expdeploy.session import RunSession
|
|
21
|
+
from expdeploy.storage.base import RunRecord, StorageAdapter
|
|
22
|
+
|
|
23
|
+
COMPLETE_HTML = """<!DOCTYPE html>
|
|
24
|
+
<html><body style="text-align:center;margin-top:4em;font-family:system-ui,sans-serif;">
|
|
25
|
+
<h1>Battery complete</h1>
|
|
26
|
+
<p>All experiments finished. You may close this tab.</p>
|
|
27
|
+
</body></html>
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class AppConfig:
|
|
33
|
+
"""A single-experiment OR battery deployment config.
|
|
34
|
+
|
|
35
|
+
Exactly one of `experiment` or `(battery_manifest, experiments_by_id)` is set.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
vendored_root: Path
|
|
39
|
+
storage: StorageAdapter # primary always-on (FSAdapter)
|
|
40
|
+
catalog: StorageAdapter | None # SQLiteCatalog or None
|
|
41
|
+
state_dir: Path # for RunSession
|
|
42
|
+
subject_id: str
|
|
43
|
+
session_num: str | None
|
|
44
|
+
run_num: str | None
|
|
45
|
+
|
|
46
|
+
# Single-experiment mode
|
|
47
|
+
experiment: LoadedExperiment | None = None
|
|
48
|
+
|
|
49
|
+
# Battery mode
|
|
50
|
+
battery_manifest: BatteryManifest | None = None
|
|
51
|
+
experiments_by_id: dict[str, LoadedExperiment] | None = None
|
|
52
|
+
counterbalance_strategy: CounterbalanceStrategy | None = None
|
|
53
|
+
|
|
54
|
+
# Remote adapters (mirrors; best-effort)
|
|
55
|
+
remotes: tuple[StorageAdapter, ...] = ()
|
|
56
|
+
|
|
57
|
+
def is_battery(self) -> bool:
|
|
58
|
+
return self.battery_manifest is not None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_app(config: AppConfig) -> FastAPI:
|
|
62
|
+
app = FastAPI(title="expdeploy", version=__version__)
|
|
63
|
+
|
|
64
|
+
builder = ImportMapBuilder(vendored_root=config.vendored_root)
|
|
65
|
+
|
|
66
|
+
# Mount vendored jsPsych once (single version for now).
|
|
67
|
+
if config.is_battery():
|
|
68
|
+
assert config.experiments_by_id is not None
|
|
69
|
+
any_exp = next(iter(config.experiments_by_id.values()))
|
|
70
|
+
else:
|
|
71
|
+
assert config.experiment is not None
|
|
72
|
+
any_exp = config.experiment
|
|
73
|
+
jspsych_version = any_exp.manifest.jspsych.version
|
|
74
|
+
jspsych_dir = config.vendored_root / jspsych_version
|
|
75
|
+
if not jspsych_dir.is_dir():
|
|
76
|
+
msg = f"jsPsych version {jspsych_version} not vendored"
|
|
77
|
+
raise LookupError(msg)
|
|
78
|
+
app.mount(
|
|
79
|
+
f"/static/jspsych/{jspsych_version}",
|
|
80
|
+
StaticFiles(directory=str(jspsych_dir)),
|
|
81
|
+
name="static-jspsych",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Mount each experiment dir.
|
|
85
|
+
if config.is_battery():
|
|
86
|
+
assert config.experiments_by_id is not None
|
|
87
|
+
for exp_id, loaded in config.experiments_by_id.items():
|
|
88
|
+
app.mount(
|
|
89
|
+
f"/static/exp/{exp_id}",
|
|
90
|
+
StaticFiles(directory=str(loaded.path)),
|
|
91
|
+
name=f"static-experiment-{exp_id}",
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
assert config.experiment is not None
|
|
95
|
+
exp_id_single = config.experiment.manifest.experiment.exp_id
|
|
96
|
+
app.mount(
|
|
97
|
+
f"/static/exp/{exp_id_single}",
|
|
98
|
+
StaticFiles(directory=str(config.experiment.path)),
|
|
99
|
+
name="static-experiment",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Orchestrator (battery mode only)
|
|
103
|
+
orchestrator: BatteryOrchestrator | None = None
|
|
104
|
+
if config.is_battery():
|
|
105
|
+
assert config.battery_manifest is not None and config.counterbalance_strategy is not None
|
|
106
|
+
session = RunSession(state_dir=config.state_dir, subject_id=config.subject_id)
|
|
107
|
+
orchestrator = BatteryOrchestrator(
|
|
108
|
+
manifest=config.battery_manifest,
|
|
109
|
+
strategy=config.counterbalance_strategy,
|
|
110
|
+
session=session,
|
|
111
|
+
)
|
|
112
|
+
orchestrator.start()
|
|
113
|
+
|
|
114
|
+
def _render_for(loaded: LoadedExperiment) -> str:
|
|
115
|
+
exp_id = loaded.manifest.experiment.exp_id
|
|
116
|
+
import_map = builder.build(
|
|
117
|
+
loaded,
|
|
118
|
+
jspsych_url_prefix=f"/static/jspsych/{jspsych_version}",
|
|
119
|
+
experiment_url_prefix=f"/static/exp/{exp_id}",
|
|
120
|
+
)
|
|
121
|
+
style_url = (
|
|
122
|
+
f"/static/exp/{exp_id}/{loaded.manifest.experiment.style}"
|
|
123
|
+
if loaded.manifest.experiment.style
|
|
124
|
+
else None
|
|
125
|
+
)
|
|
126
|
+
return render_experiment_html(
|
|
127
|
+
exp_id=exp_id,
|
|
128
|
+
experiment_entry_url=f"/static/exp/{exp_id}/{loaded.manifest.experiment.entry}",
|
|
129
|
+
style_url=style_url,
|
|
130
|
+
jspsych_css_url=f"/static/jspsych/{jspsych_version}/jspsych.css",
|
|
131
|
+
import_map=import_map,
|
|
132
|
+
runtime_globals={
|
|
133
|
+
"expId": exp_id,
|
|
134
|
+
"subjectId": config.subject_id,
|
|
135
|
+
"sessionNum": config.session_num,
|
|
136
|
+
"runNum": config.run_num,
|
|
137
|
+
"deployVersion": __version__,
|
|
138
|
+
},
|
|
139
|
+
post_url="/api/data",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@app.get("/", response_class=HTMLResponse)
|
|
143
|
+
def serve_root() -> HTMLResponse:
|
|
144
|
+
if orchestrator is not None:
|
|
145
|
+
ref = orchestrator.next_experiment()
|
|
146
|
+
if ref is None:
|
|
147
|
+
return HTMLResponse(content=COMPLETE_HTML)
|
|
148
|
+
assert config.experiments_by_id is not None
|
|
149
|
+
loaded = config.experiments_by_id[ref.exp_id]
|
|
150
|
+
return HTMLResponse(content=_render_for(loaded))
|
|
151
|
+
assert config.experiment is not None
|
|
152
|
+
return HTMLResponse(content=_render_for(config.experiment))
|
|
153
|
+
|
|
154
|
+
@app.get("/healthz")
|
|
155
|
+
def healthz() -> dict[str, str]:
|
|
156
|
+
return {"status": "ok"}
|
|
157
|
+
|
|
158
|
+
@app.get("/readyz")
|
|
159
|
+
def readyz() -> dict[str, str]:
|
|
160
|
+
return {"status": "ready"}
|
|
161
|
+
|
|
162
|
+
@app.get("/api/state")
|
|
163
|
+
def get_state() -> JSONResponse:
|
|
164
|
+
if orchestrator is None:
|
|
165
|
+
assert config.experiment is not None
|
|
166
|
+
exp_id = config.experiment.manifest.experiment.exp_id
|
|
167
|
+
return JSONResponse({"current": exp_id, "completed": [], "order": [exp_id]})
|
|
168
|
+
session = orchestrator.session
|
|
169
|
+
return JSONResponse(
|
|
170
|
+
{
|
|
171
|
+
"current": session.current(),
|
|
172
|
+
"completed": session.completed(),
|
|
173
|
+
"order": [e.exp_id for e in orchestrator.manifest.experiments],
|
|
174
|
+
"battery_id": orchestrator.battery_id,
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
@app.post("/api/state")
|
|
179
|
+
def update_state(action: dict[str, Any]) -> JSONResponse:
|
|
180
|
+
if orchestrator is None:
|
|
181
|
+
raise HTTPException(status_code=400, detail="not a battery deployment")
|
|
182
|
+
op = action.get("op")
|
|
183
|
+
if op == "reset":
|
|
184
|
+
orchestrator.session.reset()
|
|
185
|
+
orchestrator.start()
|
|
186
|
+
elif op == "skip":
|
|
187
|
+
orchestrator.advance()
|
|
188
|
+
else:
|
|
189
|
+
raise HTTPException(status_code=400, detail=f"unknown op {op!r}")
|
|
190
|
+
return JSONResponse({"current": orchestrator.session.current()})
|
|
191
|
+
|
|
192
|
+
@app.post("/api/data")
|
|
193
|
+
def post_data(payload: dict[str, Any]) -> JSONResponse:
|
|
194
|
+
try:
|
|
195
|
+
record = RunRecord(
|
|
196
|
+
exp_id=payload.get("exp_id", ""),
|
|
197
|
+
subject_id=payload.get("subject_id") or config.subject_id,
|
|
198
|
+
session_num=payload.get("session_num") or config.session_num,
|
|
199
|
+
run_num=payload.get("run_num") or config.run_num,
|
|
200
|
+
battery_id=(orchestrator.battery_id if orchestrator else None),
|
|
201
|
+
started_at=payload["started_at"],
|
|
202
|
+
ended_at=payload["ended_at"],
|
|
203
|
+
status=payload.get("status", "finished"),
|
|
204
|
+
trials=payload.get("trials", []),
|
|
205
|
+
interaction_data=payload.get("interaction_data", []),
|
|
206
|
+
jspsych_version=payload.get("jspsych_version"),
|
|
207
|
+
deploy_version=payload.get("deploy_version"),
|
|
208
|
+
client_user_agent=payload.get("client_user_agent"),
|
|
209
|
+
raw_payload=payload,
|
|
210
|
+
)
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
213
|
+
result = config.storage.save(record)
|
|
214
|
+
if not result.ok:
|
|
215
|
+
raise HTTPException(status_code=500, detail=result.error or "save failed")
|
|
216
|
+
# SQLite catalog write (best-effort but synchronous)
|
|
217
|
+
if config.catalog is not None:
|
|
218
|
+
config.catalog.save(record)
|
|
219
|
+
# Remote adapter writes (best-effort mirrors)
|
|
220
|
+
for remote in config.remotes:
|
|
221
|
+
rresult = remote.save(record)
|
|
222
|
+
if config.catalog is not None:
|
|
223
|
+
from expdeploy.storage.sqlite import SQLiteCatalog
|
|
224
|
+
|
|
225
|
+
if isinstance(config.catalog, SQLiteCatalog):
|
|
226
|
+
config.catalog.record_remote_attempt(
|
|
227
|
+
record.run_id,
|
|
228
|
+
remote.name,
|
|
229
|
+
"synced" if rresult.ok else "failed",
|
|
230
|
+
remote_uri=rresult.path if rresult.ok else None,
|
|
231
|
+
error=None if rresult.ok else rresult.error,
|
|
232
|
+
)
|
|
233
|
+
# BIDS write if applicable
|
|
234
|
+
loaded = _find_loaded(config, record.exp_id)
|
|
235
|
+
if (
|
|
236
|
+
loaded is not None
|
|
237
|
+
and loaded.manifest.bids is not None
|
|
238
|
+
and hasattr(config.storage, "save_bids")
|
|
239
|
+
):
|
|
240
|
+
config.storage.save_bids(record, loaded.manifest)
|
|
241
|
+
# Advance battery
|
|
242
|
+
if orchestrator is not None:
|
|
243
|
+
orchestrator.advance()
|
|
244
|
+
return JSONResponse({"ok": True, "path": result.path})
|
|
245
|
+
|
|
246
|
+
return app
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _find_loaded(config: AppConfig, exp_id: str | None) -> LoadedExperiment | None:
|
|
250
|
+
if exp_id is None:
|
|
251
|
+
return None
|
|
252
|
+
if config.experiments_by_id is not None:
|
|
253
|
+
return config.experiments_by_id.get(exp_id)
|
|
254
|
+
if config.experiment is not None and config.experiment.manifest.experiment.exp_id == exp_id:
|
|
255
|
+
return config.experiment
|
|
256
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Counterbalance strategies — 4 implementations of CounterbalanceStrategy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import random
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class CounterbalanceStrategy(Protocol):
|
|
13
|
+
name: str
|
|
14
|
+
|
|
15
|
+
def order_for(self, subject_id: str, experiments: list[str]) -> list[str]: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FixedStrategy:
|
|
19
|
+
name = "fixed"
|
|
20
|
+
|
|
21
|
+
def order_for(self, subject_id: str, experiments: list[str]) -> list[str]:
|
|
22
|
+
return list(experiments)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LatinSquareStrategy:
|
|
26
|
+
"""Balanced KxK Latin square; subject row = int(subject_id) mod K."""
|
|
27
|
+
|
|
28
|
+
name = "latin_square"
|
|
29
|
+
|
|
30
|
+
def order_for(self, subject_id: str, experiments: list[str]) -> list[str]:
|
|
31
|
+
try:
|
|
32
|
+
n = int(subject_id)
|
|
33
|
+
except ValueError as exc:
|
|
34
|
+
msg = f"latin_square requires integer-parseable subject_id; got {subject_id!r}"
|
|
35
|
+
raise ValueError(msg) from exc
|
|
36
|
+
k = len(experiments)
|
|
37
|
+
row = n % k
|
|
38
|
+
# Williams-style Latin square: position j of row i = (i + j) mod K
|
|
39
|
+
return [experiments[(row + j) % k] for j in range(k)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SeededRandomStrategy:
|
|
43
|
+
"""Per-subject deterministic shuffle. Seed derived from subject_id."""
|
|
44
|
+
|
|
45
|
+
name = "seeded_random"
|
|
46
|
+
|
|
47
|
+
def order_for(self, subject_id: str, experiments: list[str]) -> list[str]:
|
|
48
|
+
rng = random.Random(subject_id)
|
|
49
|
+
out = list(experiments)
|
|
50
|
+
rng.shuffle(out)
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UserSuppliedStrategy:
|
|
55
|
+
"""Reads order_csv; rows are subject_id, pos_1, pos_2, ..."""
|
|
56
|
+
|
|
57
|
+
name = "user_supplied"
|
|
58
|
+
|
|
59
|
+
def __init__(self, order_csv: Path) -> None:
|
|
60
|
+
self.order_csv = Path(order_csv)
|
|
61
|
+
self._cache: dict[str, list[str]] | None = None
|
|
62
|
+
|
|
63
|
+
def _load(self) -> dict[str, list[str]]:
|
|
64
|
+
if self._cache is not None:
|
|
65
|
+
return self._cache
|
|
66
|
+
out: dict[str, list[str]] = {}
|
|
67
|
+
with self.order_csv.open() as f:
|
|
68
|
+
reader = csv.reader(f)
|
|
69
|
+
header = next(reader, None)
|
|
70
|
+
if header is None or len(header) < 2:
|
|
71
|
+
msg = f"order_csv {self.order_csv} missing header / columns"
|
|
72
|
+
raise ValueError(msg)
|
|
73
|
+
for row in reader:
|
|
74
|
+
if not row:
|
|
75
|
+
continue
|
|
76
|
+
subject = row[0]
|
|
77
|
+
out[subject] = [c for c in row[1:] if c]
|
|
78
|
+
self._cache = out
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
def order_for(self, subject_id: str, experiments: list[str]) -> list[str]:
|
|
82
|
+
loaded = self._load()
|
|
83
|
+
if subject_id not in loaded:
|
|
84
|
+
msg = f"subject_id {subject_id!r} not in {self.order_csv}"
|
|
85
|
+
raise KeyError(msg)
|
|
86
|
+
return loaded[subject_id]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""BatteryOrchestrator — ties manifest + strategy + session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from expdeploy.battery.counterbalance import CounterbalanceStrategy
|
|
9
|
+
from expdeploy.manifest import BatteryExperimentRef, BatteryManifest
|
|
10
|
+
from expdeploy.session import RunSession
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BatteryOrchestrator:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
manifest: BatteryManifest,
|
|
18
|
+
strategy: CounterbalanceStrategy,
|
|
19
|
+
session: RunSession,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.manifest = manifest
|
|
22
|
+
self.strategy = strategy
|
|
23
|
+
self.session = session
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def battery_id(self) -> str:
|
|
27
|
+
"""Stable id derived from the manifest. Subject-independent."""
|
|
28
|
+
payload = self.manifest.model_dump(mode="json")
|
|
29
|
+
digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
|
|
30
|
+
return digest[:16]
|
|
31
|
+
|
|
32
|
+
def start(self) -> None:
|
|
33
|
+
experiments_by_id = {e.exp_id: e for e in self.manifest.experiments}
|
|
34
|
+
order = self.strategy.order_for(self.session.subject_id, list(experiments_by_id.keys()))
|
|
35
|
+
self.session.initialize(battery_id=self.battery_id, order=order)
|
|
36
|
+
|
|
37
|
+
def next_experiment(self) -> BatteryExperimentRef | None:
|
|
38
|
+
current_id = self.session.current()
|
|
39
|
+
if current_id is None:
|
|
40
|
+
return None
|
|
41
|
+
for e in self.manifest.experiments:
|
|
42
|
+
if e.exp_id == current_id:
|
|
43
|
+
return e
|
|
44
|
+
msg = f"current experiment {current_id!r} not in manifest"
|
|
45
|
+
raise LookupError(msg)
|
|
46
|
+
|
|
47
|
+
def advance(self) -> None:
|
|
48
|
+
self.session.advance()
|