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.
- psyexp_core/__init__.py +19 -0
- psyexp_core/diagnostics.py +21 -0
- psyexp_core/instructions.py +70 -0
- psyexp_core/keyboard.py +150 -0
- psyexp_core/manifest.py +151 -0
- psyexp_core/recording.py +26 -0
- psyexp_core/rundir.py +17 -0
- psyexp_core/screen.py +101 -0
- psyexp_core/wizard.py +177 -0
- psyexp_core-0.5.1.dist-info/METADATA +92 -0
- psyexp_core-0.5.1.dist-info/RECORD +12 -0
- psyexp_core-0.5.1.dist-info/WHEEL +4 -0
psyexp_core/__init__.py
ADDED
|
@@ -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
|
psyexp_core/keyboard.py
ADDED
|
@@ -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()
|
psyexp_core/manifest.py
ADDED
|
@@ -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
|
psyexp_core/recording.py
ADDED
|
@@ -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,,
|