psyexp-core 0.5.1__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.
@@ -0,0 +1,19 @@
1
+ """
2
+ psyexp-core: task-agnostic harness for PsychoPy experiments.
3
+
4
+ Submodules are import-tiered: ``diagnostics``, ``rundir``, ``recording``, and
5
+ ``manifest`` are PsychoPy-free and safe to import anywhere; ``screen``,
6
+ ``keyboard``, ``instructions``, and ``wizard`` pull in PsychoPy / GL / terminal
7
+ machinery and should be imported by the task entry point. Import from the
8
+ submodules directly to keep startup lean.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from importlib.metadata import PackageNotFoundError, version
13
+
14
+ try:
15
+ __version__ = version("psyexp-core")
16
+ except PackageNotFoundError: # running from a raw checkout without install
17
+ __version__ = "0.0.0+unknown"
18
+
19
+ __all__ = ["__version__"]
@@ -0,0 +1,21 @@
1
+ """
2
+ The ScreenDiagnostics dataclass, kept in its own import-light module (no PsychoPy
3
+ / pyglet) so manifest writing and tests can use it without pulling in the GL
4
+ stack. ``screen.setup_screen`` populates it; ``manifest.write_manifest`` reads it.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass
12
+ class ScreenDiagnostics:
13
+ gl_vendor: str
14
+ gl_renderer: str
15
+ win_type: str
16
+ pyglet_version: str
17
+ platform_str: str
18
+ calib_median_ms: float
19
+ calib_p99_ms: float
20
+ calib_max_ms: float
21
+ calib_n: int
@@ -0,0 +1,70 @@
1
+ """
2
+ A self-paced, keypress-driven instruction pager. The task supplies the pages and
3
+ a *draw_page* callback that renders one page (the task owns its stimuli and
4
+ layout); this module owns the navigation loop — forward / optional back / quit —
5
+ and the flip + key polling, reusing the shared keyboard abstraction.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable, Sequence
10
+ from typing import TYPE_CHECKING, TypeVar
11
+
12
+ from psyexp_core.keyboard import clear_events, get_keys
13
+
14
+ if TYPE_CHECKING:
15
+ from psychopy import visual
16
+ from psychopy.hardware.keyboard import Keyboard
17
+
18
+ _Page = TypeVar("_Page")
19
+
20
+
21
+ def _default_quit() -> None:
22
+ from psychopy import core
23
+
24
+ core.quit()
25
+
26
+
27
+ def page_through(
28
+ win: visual.Window,
29
+ pages: Sequence[_Page],
30
+ draw_page: Callable[[_Page, bool], None],
31
+ *,
32
+ forward_keys: Sequence[str],
33
+ back_keys: Sequence[str] = (),
34
+ quit_keys: Sequence[str] = (),
35
+ kb: Keyboard | None = None,
36
+ on_quit: Callable[[], None] | None = None,
37
+ ) -> None:
38
+ """Page through *pages* one at a time until the operator advances past the last.
39
+
40
+ *draw_page(page, is_last)* draws (but does not flip) a single page. Forward
41
+ keys advance — and return once past the last page; back keys step back (no-op
42
+ on the first page); quit keys call *on_quit* (defaults to ``psychopy.core.quit``).
43
+ """
44
+ if not pages:
45
+ return
46
+
47
+ quit_fn = on_quit or _default_quit
48
+ keys = [*forward_keys, *back_keys, *quit_keys]
49
+
50
+ clear_events(kb)
51
+ page_idx = 0
52
+ while True:
53
+ # Poll (rather than block on waitKeys) so the window keeps flipping each
54
+ # frame; on macOS a window that flips once and then blocks may never come
55
+ # to the foreground to receive keypresses.
56
+ draw_page(pages[page_idx], page_idx == len(pages) - 1)
57
+ win.flip()
58
+
59
+ pressed = get_keys(kb, keys)
60
+ if not pressed:
61
+ continue
62
+ key_name = pressed[0]
63
+ if key_name in quit_keys:
64
+ quit_fn()
65
+ elif key_name in back_keys and page_idx > 0:
66
+ page_idx -= 1
67
+ elif key_name in forward_keys:
68
+ if page_idx == len(pages) - 1:
69
+ return
70
+ page_idx += 1
@@ -0,0 +1,150 @@
1
+ """Keyboard helpers that work with either PTB or PsychoPy's event backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ try:
9
+ import psychtoolbox # noqa: F401
10
+ except ImportError:
11
+ KEYBOARD_BACKEND = "event"
12
+ else:
13
+ KEYBOARD_BACKEND = "ptb"
14
+
15
+ # PsychoPy is imported lazily inside the functions that need it so this module —
16
+ # and the PsychoPy-free timed-press / clock helpers below — stay importable (and
17
+ # unit-testable) in headless/CI environments without the PsychoPy stack.
18
+
19
+ if TYPE_CHECKING:
20
+ from psychopy.hardware.keyboard import Keyboard
21
+
22
+
23
+ def configure_psychopy_backend() -> None:
24
+ from psychopy import prefs
25
+
26
+ prefs.hardware["keyboardBackend"] = KEYBOARD_BACKEND
27
+ if KEYBOARD_BACKEND != "ptb":
28
+ warn_degraded_backend()
29
+
30
+
31
+ def warn_degraded_backend() -> None:
32
+ """Loudly flag, at startup, that psychtoolbox is missing and the keyboard has
33
+ fallen back to PsychoPy's ``event`` backend — which only captures keys while the
34
+ PsychoPy window holds OS focus. Without this, the experimenter doesn't discover
35
+ that keypresses (e.g. the start key) silently aren't registering until mid-run."""
36
+ from rich.console import Console
37
+ from rich.panel import Panel
38
+
39
+ Console(stderr=True).print(
40
+ Panel(
41
+ "[bold]psychtoolbox is not installed[/bold] — the keyboard is using "
42
+ "PsychoPy's\n[bold]event[/bold] backend, which only captures keypresses "
43
+ "while the PsychoPy\nwindow has OS focus. If keys don't register "
44
+ "mid-run, click the window\nfirst — or install psychtoolbox for the "
45
+ "robust PTB backend.",
46
+ title="[bold red]Keyboard: degraded backend[/bold red]",
47
+ border_style="red",
48
+ expand=False,
49
+ padding=(1, 2),
50
+ )
51
+ )
52
+
53
+
54
+ def build_keyboard() -> Keyboard | None:
55
+ if KEYBOARD_BACKEND == "ptb":
56
+ from psychopy.hardware import keyboard
57
+
58
+ kb = keyboard.Keyboard()
59
+ # PsychoPy ≥2024.1 defaults muteOutsidePsychopy=True on macOS, silently
60
+ # dropping keypresses when the PsychoPy window isn't the focused/registered
61
+ # app (common when launched from a terminal or IDE). Disable it so keys are
62
+ # accepted without first clicking the window to give it focus.
63
+ try:
64
+ kb.device.muteOutsidePsychopy = False
65
+ except AttributeError:
66
+ pass
67
+ return kb
68
+ return None
69
+
70
+
71
+ def clear_events(kb: Keyboard | None) -> None:
72
+ if KEYBOARD_BACKEND == "ptb":
73
+ if kb is not None:
74
+ kb.clearEvents()
75
+ return
76
+ from psychopy import event
77
+
78
+ event.clearEvents(eventType="keyboard")
79
+
80
+
81
+ def wait_for_keys(kb: Keyboard | None, key_list: list[str]) -> list[str]:
82
+ if KEYBOARD_BACKEND == "ptb":
83
+ if kb is None:
84
+ return []
85
+ return [key_press.name for key_press in kb.waitKeys(keyList=key_list, waitRelease=False)]
86
+ from psychopy import event
87
+
88
+ pressed = event.waitKeys(keyList=key_list)
89
+ return [str(key_name) for key_name in (pressed or [])]
90
+
91
+
92
+ def get_keys(kb: Keyboard | None, key_list: list[str]) -> list[str]:
93
+ if KEYBOARD_BACKEND == "ptb":
94
+ if kb is None:
95
+ return []
96
+ return [key_press.name for key_press in kb.getKeys(keyList=key_list, waitRelease=False)]
97
+ from psychopy import event
98
+
99
+ return [str(key_name) for key_name in event.getKeys(keyList=key_list)]
100
+
101
+
102
+ # ── Timed presses + keyboard-clock helpers (PTB timing) ───────────────────────
103
+ #
104
+ # The functions above return key *names*, which is all most prompts need. A
105
+ # timing-critical response window also needs the per-press reaction time and the
106
+ # ability to anchor that clock to a stimulus onset flip. These helpers read the
107
+ # Keyboard object directly (so they require the robust PTB backend / a real
108
+ # device) and expose its hardware-timestamped clock.
109
+
110
+
111
+ @dataclass
112
+ class KeyPress:
113
+ """One key press with its reaction time off the keyboard's own clock."""
114
+
115
+ name: str
116
+ rt: float # seconds since the keyboard clock was last reset
117
+
118
+
119
+ def get_presses(kb: Keyboard | None, key_list: list[str]) -> list[KeyPress]:
120
+ """Return timed presses (name + rt) read from the keyboard's own clock.
121
+
122
+ Unlike :func:`get_keys`, this preserves each press's hardware reaction time,
123
+ measured against the keyboard clock (reset via :func:`reset_clock_on_flip`).
124
+ Requires a real Keyboard object; returns ``[]`` if *kb* is ``None``.
125
+ """
126
+ if kb is None:
127
+ return []
128
+ return [
129
+ KeyPress(name=key_press.name, rt=key_press.rt)
130
+ for key_press in kb.getKeys(keyList=key_list, waitRelease=False)
131
+ ]
132
+
133
+
134
+ def reset_clock_on_flip(kb: Keyboard, win) -> None:
135
+ """Queue the keyboard clock to reset on the next ``win.flip()``.
136
+
137
+ Anchors reaction times to a stimulus onset: presses read after the flip carry
138
+ an ``rt`` measured from the moment the stimulus landed on the glass.
139
+ """
140
+ win.callOnFlip(kb.clock.reset)
141
+
142
+
143
+ def reset_clock(kb: Keyboard) -> None:
144
+ """Reset the keyboard clock to zero immediately."""
145
+ kb.clock.reset()
146
+
147
+
148
+ def clock_time(kb: Keyboard) -> float:
149
+ """Seconds elapsed on the keyboard clock since its last reset."""
150
+ return kb.clock.getTime()
@@ -0,0 +1,151 @@
1
+ """
2
+ Run manifest (manifest.json) writing plus the best-effort system/hardware
3
+ diagnostics it embeds. ``write_manifest`` is task-agnostic: the task supplies its
4
+ own top-level fields via *header* and its parameters via *study_params*; the
5
+ system / display / process blocks and the resolved psyexp-core version are
6
+ filled in here so every task records the same reproducibility metadata.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import platform
12
+ import socket
13
+ import subprocess
14
+ import sys
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ if TYPE_CHECKING:
20
+ from psyexp_core.diagnostics import ScreenDiagnostics
21
+
22
+ __all__ = ["system_info", "write_manifest"]
23
+
24
+
25
+ def _git_commit() -> str:
26
+ """Short HEAD of the *caller's* working directory (i.e. the task repo)."""
27
+ try:
28
+ out = subprocess.check_output(
29
+ ["git", "rev-parse", "--short", "HEAD"],
30
+ stderr=subprocess.DEVNULL,
31
+ text=True,
32
+ )
33
+ return out.strip()
34
+ except Exception: # noqa: BLE001 — diagnostic only
35
+ return "unknown"
36
+
37
+
38
+ def _cpu_name() -> str:
39
+ """Best-effort friendly CPU name.
40
+
41
+ ``platform.processor()`` returns the raw CPUID descriptor on Windows
42
+ (e.g. "Intel64 Family 6 Model 158 Stepping 11") and the bare arch on
43
+ macOS, neither of which is a marketing name. Read the registry on
44
+ Windows; otherwise fall back to ``platform.processor()``.
45
+ """
46
+ if platform.system() == "Windows":
47
+ try:
48
+ import winreg
49
+
50
+ key = winreg.OpenKey(
51
+ winreg.HKEY_LOCAL_MACHINE,
52
+ r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
53
+ )
54
+ try:
55
+ name, _ = winreg.QueryValueEx(key, "ProcessorNameString")
56
+ finally:
57
+ winreg.CloseKey(key)
58
+ if name:
59
+ return str(name).strip()
60
+ except Exception: # noqa: BLE001 — diagnostic only
61
+ pass
62
+ return platform.processor() or "unknown"
63
+
64
+
65
+ def _psychopy_version() -> str:
66
+ try:
67
+ import psychopy # type: ignore
68
+
69
+ return getattr(psychopy, "__version__", "unknown")
70
+ except Exception: # noqa: BLE001
71
+ return "unknown"
72
+
73
+
74
+ def system_info() -> dict[str, Any]:
75
+ return {
76
+ "hostname": socket.gethostname(),
77
+ "platform": platform.platform(),
78
+ "machine": platform.machine(),
79
+ "processor": _cpu_name(),
80
+ "os_name": platform.system(),
81
+ "os_release": platform.release(),
82
+ "python_version": platform.python_version(),
83
+ "psychopy_version": _psychopy_version(),
84
+ "git_commit": _git_commit(),
85
+ }
86
+
87
+
88
+ def write_manifest(
89
+ run_dir: Path,
90
+ *,
91
+ header: dict[str, Any],
92
+ session_time: datetime,
93
+ screen_diag: ScreenDiagnostics | None = None,
94
+ win_res: list[int] | None = None,
95
+ study_params: dict[str, Any] | None = None,
96
+ frame_rate: float | None = None,
97
+ n_trials: int | None = None,
98
+ frame_dur_s: float | None = None,
99
+ frame_dur_source: str | None = None,
100
+ extra_process: dict[str, Any] | None = None,
101
+ ) -> Path:
102
+ """Write ``run_dir/manifest.json`` and return its path.
103
+
104
+ *header* carries the task's own top-level fields (subject_id, run, plus the
105
+ task's own version key, e.g. ``{"medoc_version": ...}``) and is merged in
106
+ verbatim. *study_params* is the task's parameter block. The ``system`` /
107
+ ``display`` / ``process`` blocks and ``psyexp_core_version`` are added here.
108
+ """
109
+ from psyexp_core import __version__
110
+
111
+ manifest: dict[str, Any] = {"psyexp_core_version": __version__}
112
+ manifest.update(header)
113
+ manifest["session_time"] = session_time.isoformat(timespec="seconds")
114
+ if frame_rate is not None:
115
+ manifest["frame_rate_hz"] = round(frame_rate, 3)
116
+ if n_trials is not None:
117
+ manifest["n_trials"] = n_trials
118
+ if study_params is not None:
119
+ manifest["study_params"] = study_params
120
+
121
+ manifest["system"] = system_info()
122
+
123
+ if screen_diag is not None:
124
+ display: dict[str, Any] = {
125
+ "gl_vendor": screen_diag.gl_vendor,
126
+ "gl_renderer": screen_diag.gl_renderer,
127
+ "win_type": screen_diag.win_type,
128
+ "pyglet_version": screen_diag.pyglet_version,
129
+ "resolution": [int(x) for x in win_res] if win_res is not None else None,
130
+ "vsync_calibration": {
131
+ "median_ms": screen_diag.calib_median_ms,
132
+ "p99_ms": screen_diag.calib_p99_ms,
133
+ "max_ms": screen_diag.calib_max_ms,
134
+ "n_samples": screen_diag.calib_n,
135
+ },
136
+ }
137
+ if frame_dur_s is not None:
138
+ display["frame_dur_ms"] = round(frame_dur_s * 1000, 4)
139
+ if frame_dur_source is not None:
140
+ display["frame_dur_source"] = frame_dur_source
141
+ manifest["display"] = display
142
+
143
+ process: dict[str, Any] = {"argv": sys.argv}
144
+ if extra_process:
145
+ process.update(extra_process)
146
+ manifest["process"] = process
147
+
148
+ path = run_dir / "manifest.json"
149
+ with open(path, "w") as f:
150
+ json.dump(manifest, f, indent=2)
151
+ return path
@@ -0,0 +1,26 @@
1
+ """
2
+ The generic CSV writer. A task defines its own record dataclasses and column
3
+ lists, then either uses ``CsvWriter`` directly or subclasses it to bind a fixed
4
+ schema. Each ``append`` pulls the named attributes off the record and flushes,
5
+ so a crash mid-run still leaves a complete file up to the last trial.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ from pathlib import Path
11
+
12
+
13
+ class CsvWriter:
14
+ def __init__(self, path: Path, columns: list[str]) -> None:
15
+ self._file = open(path, "w", newline="")
16
+ self._writer = csv.DictWriter(self._file, fieldnames=columns)
17
+ self._writer.writeheader()
18
+ self._columns = columns
19
+
20
+ def append(self, record: object) -> None:
21
+ row = {name: getattr(record, name) for name in self._columns}
22
+ self._writer.writerow(row)
23
+ self._file.flush()
24
+
25
+ def close(self) -> None:
26
+ self._file.close()
psyexp_core/rundir.py ADDED
@@ -0,0 +1,17 @@
1
+ """Timestamped output-directory creation, shared by every task."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+
8
+ def make_run_dir(data_dir: Path, label: str, session_time: datetime) -> Path:
9
+ """Create and return ``data_dir/{label}_{YYYYMMDDTHHMMSS}``.
10
+
11
+ *label* is the task-specific stem (e.g. ``"XXX000_run1"`` or
12
+ ``"XXX000_example"``); the timestamp keeps repeated runs from colliding.
13
+ """
14
+ ts = session_time.strftime("%Y%m%dT%H%M%S")
15
+ run_dir = data_dir / f"{label}_{ts}"
16
+ run_dir.mkdir(parents=True, exist_ok=True)
17
+ return run_dir
psyexp_core/screen.py ADDED
@@ -0,0 +1,101 @@
1
+ """
2
+ Screen + frame-timing setup: open a fullscreen PsychoPy window on the last
3
+ display, enable VSYNC, run a short flip-interval calibration, and return the
4
+ window alongside a ScreenDiagnostics snapshot for the manifest.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import platform
9
+ import statistics
10
+
11
+ import pyglet
12
+ from psychopy import core, monitors, visual
13
+
14
+ from psyexp_core.diagnostics import ScreenDiagnostics
15
+
16
+ __all__ = ["ScreenDiagnostics", "setup_screen"]
17
+
18
+
19
+ def setup_screen(
20
+ *,
21
+ color: tuple[float, float, float] = (-1, -1, -1),
22
+ units: str = "height",
23
+ warmup_flips: int = 30,
24
+ calib_flips: int = 120,
25
+ ) -> tuple[list[int], visual.Window, ScreenDiagnostics]:
26
+ """Open a fullscreen window on the last screen and calibrate frame timing.
27
+
28
+ Returns ``(win_res, win, diagnostics)``. The diagnostics carry GL/driver
29
+ identifiers and a flip-interval calibration (median / p99 / max in ms) so
30
+ timing spikes can be correlated with the compositor in post-hoc analysis.
31
+ """
32
+ display = pyglet.canvas.get_display()
33
+ screens = display.get_screens()
34
+ win_res = [screens[-1].width, screens[-1].height]
35
+ exp_mon = monitors.Monitor("exp_mon")
36
+ exp_mon.setSizePix(win_res)
37
+ win = visual.Window(
38
+ size=win_res,
39
+ screen=len(screens) - 1,
40
+ allowGUI=True,
41
+ fullscr=True,
42
+ monitor=exp_mon,
43
+ units=units,
44
+ color=color,
45
+ waitBlanking=True,
46
+ )
47
+
48
+ # Explicitly enable VSYNC on the pyglet window.
49
+ handle = getattr(win, "winHandle", None)
50
+ if handle is not None and hasattr(handle, "set_vsync"):
51
+ handle.set_vsync(True)
52
+
53
+ # Collect backend identifiers so timing spikes can be correlated with
54
+ # driver/compositor in post-hoc analysis.
55
+ try:
56
+ gl_info = pyglet.gl.current_context.get_info()
57
+ gl_vendor = gl_info.get_vendor()
58
+ gl_renderer = gl_info.get_renderer()
59
+ except Exception: # noqa: BLE001 — diagnostic only
60
+ gl_vendor = "?"
61
+ gl_renderer = "?"
62
+
63
+ # VSYNC calibration: flip ~120 times and measure intervals. If the 99th
64
+ # percentile is well above one frame period, vsync is not actually blocking
65
+ # — typical on Windows under DWM composition or borderless fullscreen.
66
+ intervals_ms: list[float] = []
67
+ # Warm-up flips before measurement: PsychoPy's detectingFrameDrops doc notes
68
+ # drops are common during startup as the GPU/driver/compositor settle. Run
69
+ # these before the calibration loop so the median feeding frame_dur_s is
70
+ # measured on a settled context, not a cold one.
71
+ for _ in range(warmup_flips):
72
+ win.flip()
73
+ last_t = core.getTime()
74
+ for _ in range(calib_flips):
75
+ win.flip()
76
+ now = core.getTime()
77
+ intervals_ms.append((now - last_t) * 1000)
78
+ last_t = now
79
+ intervals_ms.sort()
80
+ median = statistics.median(intervals_ms)
81
+ p99 = intervals_ms[int(0.99 * len(intervals_ms)) - 1]
82
+ mx = intervals_ms[-1]
83
+
84
+ # Enable PsychoPy's frame interval recording so callers can read
85
+ # win.nDroppedFrames and isolate on-screen drops from measurement artifacts.
86
+ win.refreshThreshold = (median / 1000.0) * 1.5
87
+ win.recordFrameIntervals = True
88
+
89
+ diagnostics = ScreenDiagnostics(
90
+ gl_vendor=gl_vendor,
91
+ gl_renderer=gl_renderer,
92
+ win_type=str(getattr(win, "winType", "?")),
93
+ pyglet_version=str(getattr(pyglet, "version", "?")),
94
+ platform_str=platform.platform(),
95
+ calib_median_ms=round(median, 3),
96
+ calib_p99_ms=round(p99, 3),
97
+ calib_max_ms=round(mx, 3),
98
+ calib_n=len(intervals_ms),
99
+ )
100
+
101
+ return win_res, win, diagnostics
psyexp_core/wizard.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ Reusable building blocks for terminal setup wizards: a shared questionary /
3
+ prompt_toolkit colour palette, thin ``ask_*`` helpers that apply the palette and
4
+ treat Ctrl-C / Esc as "quit the task", a positive-float validator, and a
5
+ filename prompt that guards against clobbering an existing file. Tasks compose
6
+ their own wizard from these; task-specific fields (e.g. a frame-stepping RT
7
+ prompt) stay in the task repo.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Sequence
12
+ from pathlib import Path
13
+ from typing import NoReturn
14
+
15
+ import questionary
16
+ from prompt_toolkit import prompt as _pt_prompt
17
+ from prompt_toolkit.application.current import get_app
18
+ from prompt_toolkit.formatted_text import HTML, FormattedText
19
+ from prompt_toolkit.styles import Style as PtStyle
20
+ from prompt_toolkit.validation import ValidationError, Validator
21
+ from rich.console import Console
22
+
23
+ _rcon = Console(stderr=True)
24
+
25
+ # ── Styles ──────────────────────────────────────────────────────────────────
26
+
27
+ # Match questionary's default palette so everything looks cohesive.
28
+ QSTYLE = questionary.Style(
29
+ [
30
+ ("qmark", "fg:#5f819d bold"),
31
+ ("question", "bold"),
32
+ ("answer", "fg:#ff9d00 bold"),
33
+ ("pointer", "fg:#ff9d00 bold"),
34
+ ("highlighted", "fg:#ff9d00 bold"),
35
+ ("selected", "fg:#cc5454"),
36
+ ("separator", "fg:#6c6c6c"),
37
+ ("instruction", "fg:#858585 italic"),
38
+ ("placeholder", "fg:#6c6c6c"),
39
+ ]
40
+ )
41
+
42
+ # prompt_toolkit style for the custom prompts.
43
+ PT_STYLE = PtStyle.from_dict(
44
+ {
45
+ "prompt": "#ff9d00 bold", # ❯ arrow: matches questionary answer
46
+ "placeholder": "#6c6c6c italic", # greyed-out example, not submitted
47
+ "bottom-toolbar": "bg:#1e1e1e #888888",
48
+ "bottom-toolbar.text": "bg:#1e1e1e",
49
+ }
50
+ )
51
+
52
+
53
+ # ── Quit / cancel ─────────────────────────────────────────────────────────────
54
+
55
+
56
+ def quit_app() -> NoReturn:
57
+ """Quit PsychoPy + the process. Called when an operator cancels a prompt."""
58
+ from psychopy import core # late import — avoids slow startup / circulars
59
+
60
+ core.quit()
61
+ raise SystemExit(0) # unreachable; tells the type checker this never returns
62
+
63
+
64
+ # ── Thin questionary helpers (apply QSTYLE; cancel ⇒ quit_app) ─────────────────
65
+
66
+
67
+ def ask_text(
68
+ message: str,
69
+ *,
70
+ placeholder: str | None = None,
71
+ default: str = "",
72
+ validate: Callable[[str], bool | str] | None = None,
73
+ ) -> str:
74
+ ph = HTML(f"<placeholder>{placeholder}</placeholder>") if placeholder else None
75
+ answer = questionary.text(
76
+ message,
77
+ default=default,
78
+ placeholder=ph,
79
+ validate=validate,
80
+ style=QSTYLE,
81
+ ).ask()
82
+ if answer is None:
83
+ quit_app()
84
+ return answer
85
+
86
+
87
+ def ask_select(message: str, choices: Sequence[questionary.Choice | str]):
88
+ answer = questionary.select(message, choices=list(choices), style=QSTYLE).ask()
89
+ if answer is None:
90
+ quit_app()
91
+ return answer
92
+
93
+
94
+ def ask_confirm(message: str, *, default: bool = True) -> bool:
95
+ answer = questionary.confirm(message, default=default, style=QSTYLE).ask()
96
+ if answer is None:
97
+ quit_app()
98
+ return answer
99
+
100
+
101
+ # ── Validators ────────────────────────────────────────────────────────────────
102
+
103
+
104
+ class PosFloatValidator(Validator):
105
+ """prompt_toolkit validator: text must parse to a float > 0."""
106
+
107
+ def __init__(self, unit: str = "") -> None:
108
+ self._suffix = f" ({unit})" if unit else ""
109
+
110
+ def validate(self, document) -> None:
111
+ text = document.text.strip()
112
+ try:
113
+ value = float(text)
114
+ except ValueError:
115
+ raise ValidationError(
116
+ message=f"Enter a number{self._suffix}", cursor_position=len(text)
117
+ ) from None
118
+ if value <= 0:
119
+ raise ValidationError(
120
+ message=f"Value must be > 0{self._suffix}", cursor_position=len(text)
121
+ )
122
+
123
+
124
+ # ── Filename prompt with overwrite guard ──────────────────────────────────────
125
+
126
+
127
+ def prompt_unique_name(
128
+ label: str,
129
+ target_dir: Path,
130
+ filename_for: Callable[[str], str],
131
+ *,
132
+ example: str = "1",
133
+ ) -> str:
134
+ """Prompt for a NAME and guard against clobbering an existing file.
135
+
136
+ *filename_for* maps the entered NAME to the target filename inside
137
+ *target_dir*. Re-prompts until the resulting path is free or the operator
138
+ confirms an overwrite, then returns the chosen NAME.
139
+ """
140
+ _rcon.print(
141
+ f"[bold #5f819d]?[/bold #5f819d] [bold]{label}[/bold] "
142
+ "[dim]NAME is only part of the saved file[/dim]",
143
+ highlight=False,
144
+ )
145
+
146
+ def _toolbar() -> FormattedText:
147
+ typed = get_app().current_buffer.text.strip()
148
+ if not typed:
149
+ return FormattedText([("fg:ansired bold", " ✗ Name cannot be empty")])
150
+ preview = target_dir / filename_for(typed)
151
+ return FormattedText([("fg:ansigreen bold", f" → saves as {preview}")])
152
+
153
+ while True:
154
+ try:
155
+ raw = _pt_prompt(
156
+ FormattedText([("class:prompt", "❯ ")]),
157
+ placeholder=HTML(f"<placeholder>e.g. {example}</placeholder>"),
158
+ bottom_toolbar=_toolbar,
159
+ style=PT_STYLE,
160
+ )
161
+ except (KeyboardInterrupt, EOFError):
162
+ quit_app()
163
+ name = raw.strip()
164
+ if not name:
165
+ _rcon.print("[red]Name cannot be empty.[/red]")
166
+ continue
167
+
168
+ target = target_dir / filename_for(name)
169
+ if not target.exists():
170
+ return name
171
+
172
+ if ask_confirm(
173
+ f"{target.name} already exists in {target_dir}/ — overwrite?",
174
+ default=False,
175
+ ):
176
+ return name
177
+ # else: loop and re-prompt for a different NAME
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: psyexp-core
3
+ Version: 0.5.1
4
+ Summary: Task-agnostic harness for PsychoPy experiments: screen/frame-timing setup, run manifests, CSV writers, setup-wizard primitives, instruction pager, and keyboard abstraction.
5
+ Project-URL: Homepage, https://github.com/HAPNlab/psyexp-core
6
+ Project-URL: Repository, https://github.com/HAPNlab/psyexp-core
7
+ Author: Eric Wang
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: prompt-toolkit>=3.0
10
+ Requires-Dist: psychopy>=2025.1
11
+ Requires-Dist: pyobjc-framework-quartz>=10; sys_platform == 'darwin'
12
+ Requires-Dist: questionary>=2.0
13
+ Requires-Dist: rich>=14.3.3
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Requires-Dist: ruff>=0.4; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # psyexp-core
20
+
21
+ Task-agnostic harness for PsychoPy experiments, shared across the lab's task
22
+ repos (`heat-task`, `mid-task`, `mid-task-deterministic`). It owns the *plumbing*
23
+ that every task duplicates; each task repo keeps only its own stimuli, trial
24
+ logic, and record schemas.
25
+
26
+ ## What's in here
27
+
28
+ | Module | Responsibility |
29
+ | --- | --- |
30
+ | `screen` | `setup_screen()` — open a fullscreen PsychoPy window, enable VSYNC, run a frame-timing calibration, and return a `ScreenDiagnostics`. |
31
+ | `diagnostics` | The `ScreenDiagnostics` dataclass (import-light; no PsychoPy). |
32
+ | `rundir` | `make_run_dir(data_dir, label, session_time)` — timestamped output directory. |
33
+ | `manifest` | `write_manifest(...)` + `system_info()` — JSON run manifest with system/display/process diagnostics and the resolved `psyexp_core_version`. App-specific fields are injected via `header` / `study_params`. |
34
+ | `recording` | `CsvWriter` base class (maps a dataclass record onto a fixed column schema). |
35
+ | `wizard` | questionary / prompt_toolkit setup-wizard primitives: shared styles, `ask_text` / `ask_select` / `ask_confirm`, `PosFloatValidator`, `prompt_unique_name`, `quit_app`. |
36
+ | `instructions` | `page_through(...)` — a self-paced, keypress-driven instruction pager. |
37
+ | `keyboard` | PTB / PsychoPy-event keyboard abstraction: `build_keyboard` / `get_keys` / `wait_for_keys` / `clear_events`, plus the timed-press API for response windows — `get_presses` (name + rt), `reset_clock_on_flip` / `reset_clock` / `clock_time`. |
38
+
39
+ ## Use from a task repo
40
+
41
+ Add it as a dependency. For day-to-day development, point at a local checkout so
42
+ edits are live without reinstalling:
43
+
44
+ ```toml
45
+ # your-task/pyproject.toml
46
+ dependencies = ["psyexp-core"]
47
+
48
+ [tool.uv.sources]
49
+ psyexp-core = { path = "../psyexp-core", editable = true }
50
+ ```
51
+
52
+ For a reproducible release build, pin a tagged ref instead:
53
+
54
+ ```toml
55
+ [tool.uv.sources]
56
+ psyexp-core = { git = "ssh://git@github.com/<you>/psyexp-core.git", tag = "v0.1.0" }
57
+ ```
58
+
59
+ `write_manifest` records the resolved `psyexp_core_version` so each run is
60
+ traceable back to a core version.
61
+
62
+ ### Co-developing core while a task repo keeps the git pin
63
+
64
+ Lab task repos (e.g. `heat-task`) commit the **git-tag** source above so clones
65
+ reproduce exactly, then overlay a local editable install for development:
66
+
67
+ ```bash
68
+ uv pip install -e ../psyexp-core
69
+ ```
70
+
71
+ **Gotcha:** `uv run` re-syncs the task venv from its `uv.lock` on every launch,
72
+ which reverts that editable install straight back to the pinned tag (symptoms:
73
+ your local core edits silently don't take effect). Set `UV_NO_SYNC=1` in the task
74
+ repo (export it in your shell, or use `uv run --no-sync`) so the editable overlay
75
+ sticks; run a manual `uv sync` only when you change other deps, then re-run the
76
+ editable install. See heat-task's README ("Co-developing `psyexp-core` locally")
77
+ for the full workflow.
78
+
79
+ ## Releasing
80
+
81
+ Tagging and publishing are deliberately separate, so tags stay cheap to iterate on:
82
+
83
+ 1. **Bump + lock + changelog**, then tag `vX.Y.Z`. The tag runs the checks and
84
+ creates a **draft** GitHub Release — it does **not** publish anything.
85
+ 2. **Review the draft** Release and publish it. That triggers
86
+ [`publish.yml`](.github/workflows/publish.yml), which uploads to
87
+ [PyPI](https://pypi.org/project/psyexp-core/) via **Trusted Publishing** (OIDC;
88
+ no API token stored).
89
+
90
+ PyPI versions are immutable, so retagging never republishes; bump the version to
91
+ ship new code. See **[docs/releasing.md](docs/releasing.md)** for the full process,
92
+ SemVer policy, pre-releases, retag semantics, and the one-time PyPI setup.
@@ -0,0 +1,12 @@
1
+ psyexp_core/__init__.py,sha256=vHzPYmGJAI7WxyJeAEwcf8bRyJtBsU0VUqZ8GFSj6P8,697
2
+ psyexp_core/diagnostics.py,sha256=44PYqSctMHAi1KcVyStcrX0x2UQGUA9p3DJh3oM9zlY,554
3
+ psyexp_core/instructions.py,sha256=zKvUf18gCbnE_qEw3L3JpLHMqAEQucl38erZlTPmXcU,2225
4
+ psyexp_core/keyboard.py,sha256=2bFTHa04IwOuhzpzMmJmnQxj0zmAPffElnhV9lMF0RQ,5308
5
+ psyexp_core/manifest.py,sha256=UdPkPKrMhUJU1XXgKrlVeC-bO29kCro2-Sr7jGX4hYQ,5127
6
+ psyexp_core/recording.py,sha256=0s0pMyZ1gc36y87Nz2-TbAI6nahBUGRSR746u1QcW_g,891
7
+ psyexp_core/rundir.py,sha256=sF7PPmUr6LoaJCbiMcLn_mspCAGWxfRmq2-rcT-lKE4,609
8
+ psyexp_core/screen.py,sha256=SBL6USxizh6fJr8J6L5RWBZZrlUC15RtAO3U1J8XLgk,3629
9
+ psyexp_core/wizard.py,sha256=suTC9DhMQ1Y2eM5C97CDPitNkfT9RdzZQHzqyhhdGYk,6251
10
+ psyexp_core-0.5.1.dist-info/METADATA,sha256=IMvvecAZZ1lFdRnJiiUe3xHt5lpUyUl-75EpCM4Lgdk,4347
11
+ psyexp_core-0.5.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ psyexp_core-0.5.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any