Skip to content

Commit 1399fd0

Browse files
committed
harden task config checks and audio stim typing
1 parent e6f27a0 commit 1399fd0

4 files changed

Lines changed: 106 additions & 9 deletions

File tree

ChangLog.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# psyflow change log
22

3+
## 0.1.5 (2026-02-12)
4+
5+
### Summary
6+
- `StimUnit.add_stim(...)` now recognizes PsychoPy runtime audio backends (via `_SoundBase` and resolved backend classes), fixing false rejections of valid sound stimuli.
7+
- Unsupported stimulus objects in `StimUnit.add_stim(...)` now emit a warning and raise a clearer `TypeError` that lists supported classes.
8+
- `TaskSettings.from_dict(...)` was hardened:
9+
- validates that input config is a dict,
10+
- passes only `init=True` dataclass fields into constructor,
11+
- validates `trial_per_block` / `trials_per_block` aliases for consistency,
12+
- enforces declared trials-per-block matches `ceil(total_trials / total_blocks)` when provided.
13+
- `initialize_exp(...)` now defaults to `settings.screen` when `screen_id` is not explicitly passed.
14+
15+
### Files
16+
- `psyflow/StimUnit.py`
17+
- `psyflow/TaskSettings.py`
18+
- `psyflow/utils/experiment.py`
19+
320
## 0.1.4 (2026-02-12)
421

522
### Summary

psyflow/StimUnit.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,36 @@
11
from psychopy import core, visual, logging, sound
22
from psychopy.hardware.keyboard import Keyboard
3-
from typing import Callable, Optional, List, Dict, Any, Union
3+
from typing import Callable, Optional, List, Dict, Any, Sequence, TypeAlias, Union
4+
import importlib
45
import random
56
from .qa.context import get_context
67
from .io.events import TriggerEvent
8+
from psychopy.sound._base import _SoundBase
9+
10+
11+
def _resolve_audio_stim_types() -> tuple[type, ...]:
12+
"""Resolve concrete PsychoPy sound classes used at runtime."""
13+
types: list[type] = [_SoundBase]
14+
candidates = (
15+
("psychopy.sound.backend_ptb", "SoundPTB"),
16+
("psychopy.sound.backend_sounddevice", "SoundDeviceSound"),
17+
("psychopy.sound.backend_pyo", "SoundPyo"),
18+
("psychopy.sound.backend_pygame", "SoundPygame"),
19+
)
20+
for module_name, class_name in candidates:
21+
try:
22+
module = importlib.import_module(module_name)
23+
cls = getattr(module, class_name, None)
24+
if isinstance(cls, type) and cls not in types:
25+
types.append(cls)
26+
except Exception:
27+
continue
28+
return tuple(types)
29+
30+
31+
AUDIO_STIM_TYPES = _resolve_audio_stim_types()
32+
SUPPORTED_STIM_TYPES = (visual.BaseVisualStim,) + AUDIO_STIM_TYPES
33+
SupportedStim: TypeAlias = Union[visual.BaseVisualStim, _SoundBase]
734

835
class StimUnit:
936
"""
@@ -78,7 +105,7 @@ def _qa_scale_duration(self, nominal_s: float) -> tuple[float, int, bool]:
78105
used = max(self.frame_time, n_frames * self.frame_time)
79106
return used, n_frames, True
80107

81-
def add_stim(self, *stims: Union[visual.BaseVisualStim, sound.Sound, List[Union[visual.BaseVisualStim, sound.Sound]]]) -> "StimUnit":
108+
def add_stim(self, *stims: Union[SupportedStim, Sequence[SupportedStim]]) -> "StimUnit":
82109
"""
83110
Add one or more visual or sound stimuli to the trial.
84111
@@ -101,8 +128,14 @@ def add_stim(self, *stims: Union[visual.BaseVisualStim, sound.Sound, List[Union[
101128
stims = stims[0]
102129

103130
for stim in stims:
104-
if not isinstance(stim, (visual.BaseVisualStim, sound.Sound)):
105-
raise TypeError(f"add_stim expects visual or sound stimuli, got {type(stim)}")
131+
if not isinstance(stim, SUPPORTED_STIM_TYPES):
132+
supported = ", ".join(sorted({t.__name__ for t in SUPPORTED_STIM_TYPES}))
133+
msg = (
134+
"add_stim got unsupported object type "
135+
f"{type(stim).__name__}. Supported types include: {supported}"
136+
)
137+
logging.warning(f"[StimUnit] {msg}")
138+
raise TypeError(msg)
106139
self.stimuli.append(stim)
107140

108141
return self

psyflow/TaskSettings.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,57 @@ def from_dict(cls, config: dict):
178178
>>> cfg = {'total_blocks': 2, 'total_trials': 20}
179179
>>> TaskSettings.from_dict(cfg)
180180
"""
181-
known_keys = set(f.name for f in cls.__dataclass_fields__.values())
181+
if not isinstance(config, dict):
182+
raise TypeError(f"config must be a dict, got {type(config)}")
183+
184+
# Only fields with init=True can be passed to the dataclass constructor.
185+
known_keys = {
186+
name for name, field_def in cls.__dataclass_fields__.items()
187+
if field_def.init
188+
}
182189
init_args = {k: v for k, v in config.items() if k in known_keys}
183190
extras = {k: v for k, v in config.items() if k not in known_keys}
184191

185192
settings = cls(**init_args)
193+
194+
# Optional config alias guardrail:
195+
# if trial_per_block is provided in task config, it must match the derived value.
196+
tpb_declared = None
197+
if "trial_per_block" in extras:
198+
tpb_declared = extras.pop("trial_per_block")
199+
if "trials_per_block" in extras:
200+
tpb_alt = extras.pop("trials_per_block")
201+
if tpb_declared is None:
202+
tpb_declared = tpb_alt
203+
elif int(tpb_declared) != int(tpb_alt):
204+
raise ValueError(
205+
"Inconsistent task config: both 'trial_per_block' and "
206+
f"'trials_per_block' were provided but differ "
207+
f"({tpb_declared} vs {tpb_alt})."
208+
)
209+
210+
if tpb_declared is not None:
211+
try:
212+
tpb_declared_i = int(tpb_declared)
213+
except Exception as exc:
214+
raise TypeError(
215+
"task.trial_per_block must be an int when provided, got "
216+
f"{tpb_declared!r}"
217+
) from exc
218+
219+
if tpb_declared_i != settings.trials_per_block:
220+
raise ValueError(
221+
"Inconsistent task config: "
222+
f"trial_per_block={tpb_declared_i} but "
223+
"ceil(total_trials/total_blocks)="
224+
f"{settings.trials_per_block} "
225+
f"(total_trials={settings.total_trials}, "
226+
f"total_blocks={settings.total_blocks})."
227+
)
228+
# Keep both names available for task code that reads either alias.
229+
settings.trial_per_block = tpb_declared_i
230+
settings.trials_per_block = tpb_declared_i
231+
186232
for k, v in extras.items():
187233
setattr(settings, k, v)
188234
return settings

psyflow/utils/experiment.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
"""Experiment/window bootstrap utilities."""
22

3-
from typing import Tuple
3+
from typing import Optional, Tuple
44

55
from psychopy import core, event, logging, monitors
66
from psychopy.hardware import keyboard
77
from psychopy.visual import Window
88

99

10-
def initialize_exp(settings, screen_id: int = 1) -> Tuple[Window, keyboard.Keyboard]:
10+
def initialize_exp(settings, screen_id: Optional[int] = None) -> Tuple[Window, keyboard.Keyboard]:
1111
"""Set up the PsychoPy window, keyboard and logging."""
1212
mon = monitors.Monitor("tempMonitor")
1313
mon.setWidth(getattr(settings, "monitor_width_cm", 35.5))
1414
mon.setDistance(getattr(settings, "monitor_distance_cm", 60))
1515
mon.setSizePix(getattr(settings, "size", [1024, 768]))
1616

17+
resolved_screen = getattr(settings, "screen", 0) if screen_id is None else screen_id
18+
1719
win = Window(
1820
size=getattr(settings, "size", [1024, 768]),
1921
fullscr=getattr(settings, "fullscreen", False),
20-
screen=screen_id,
22+
screen=resolved_screen,
2123
monitor=mon,
2224
units=getattr(settings, "units", "pix"),
2325
color=getattr(settings, "bg_color", [0, 0, 0]),
@@ -53,4 +55,3 @@ def initialize_exp(settings, screen_id: int = 1) -> Tuple[Window, keyboard.Keybo
5355
logging.console.setLevel(logging.INFO)
5456

5557
return win, kb
56-

0 commit comments

Comments
 (0)