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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """expdeploy — modern Python deploy tool for jsPsych v8 experiments."""
2
+
3
+ __version__ = "0.1.0"
expdeploy/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m expdeploy`."""
2
+
3
+ from expdeploy.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
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()