Skip to content

Commit c76ca7f

Browse files
committed
Fix lazy utils imports for #21
1 parent 22197b3 commit c76ca7f

7 files changed

Lines changed: 188 additions & 53 deletions

File tree

ChangLog.md

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

3+
## 0.1.22 (2026-04-05)
4+
5+
### Summary
6+
- Made `psyflow.utils` lazy-load PsychoPy-dependent helpers so importing `psyflow.utils` no longer eagerly imports `display` or `experiment`.
7+
- Removed the duplicate sim-side deadline helper and reused `psyflow.utils.trials.resolve_deadline()` from `psyflow.sim.context_helpers`.
8+
- Added smoke coverage for the import boundary and sim deadline resolution.
9+
- Addresses issue #21: `utils/__init__.py eager psychopy imports block cross-module reuse`.
10+
11+
### Validation
12+
- `python -m unittest discover -s tests -p "test_smoke.py" -v` passed.
13+
- `python -m unittest discover -s tests -p "test_sim_golden.py" -v` passed.
14+
315
## 0.1.21 (2026-03-04)
416

517
### Summary

psyflow/sim/context_helpers.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,7 @@
33
from typing import Any
44

55

6-
def _resolve_deadline(value: Any) -> float | None:
7-
if isinstance(value, (int, float)):
8-
return float(value)
9-
if isinstance(value, (list, tuple)) and value:
10-
try:
11-
return float(max(value))
12-
except (ValueError, TypeError):
13-
return None
14-
return None
6+
from ..utils.trials import resolve_deadline
157

168

179
def set_trial_context(
@@ -35,7 +27,7 @@ def set_trial_context(
3527
return unit.set_state(
3628
trial_id=trial_id,
3729
phase=phase,
38-
deadline_s=_resolve_deadline(deadline_s),
30+
deadline_s=resolve_deadline(deadline_s),
3931
valid_keys=list(valid_keys or []),
4032
block_id=block_id,
4133
condition_id=condition_id,

psyflow/utils/__init__.py

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
"""Utility package for psyflow.
22
3-
Public usage:
4-
5-
from psyflow.utils import ...
3+
Keep this module lightweight: importing ``psyflow.utils`` should not eagerly
4+
import PsychoPy or other optional dependencies. Public symbols are exposed via
5+
lazy attribute access.
66
"""
77

8-
from .config import load_config, validate_config
9-
from .display import count_down
10-
from .experiment import initialize_exp
11-
from .ports import show_ports
12-
from .templates import taps
13-
from .trials import (
14-
next_trial_id,
15-
reset_trial_counter,
16-
resolve_deadline,
17-
resolve_trial_id,
18-
)
19-
from .voices import list_supported_voices
8+
from __future__ import annotations
9+
10+
import importlib
11+
from typing import TYPE_CHECKING, Any
12+
13+
_LAZY_ATTRS: dict[str, tuple[str, str]] = {
14+
"count_down": ("psyflow.utils.display", "count_down"),
15+
"initialize_exp": ("psyflow.utils.experiment", "initialize_exp"),
16+
"list_supported_voices": ("psyflow.utils.voices", "list_supported_voices"),
17+
"load_config": ("psyflow.utils.config", "load_config"),
18+
"next_trial_id": ("psyflow.utils.trials", "next_trial_id"),
19+
"reset_trial_counter": ("psyflow.utils.trials", "reset_trial_counter"),
20+
"resolve_deadline": ("psyflow.utils.trials", "resolve_deadline"),
21+
"resolve_trial_id": ("psyflow.utils.trials", "resolve_trial_id"),
22+
"show_ports": ("psyflow.utils.ports", "show_ports"),
23+
"taps": ("psyflow.utils.templates", "taps"),
24+
"validate_config": ("psyflow.utils.config", "validate_config"),
25+
}
2026

2127
__all__ = [
2228
"count_down",
@@ -31,3 +37,35 @@
3137
"taps",
3238
"validate_config",
3339
]
40+
41+
42+
if TYPE_CHECKING:
43+
from .config import load_config as load_config
44+
from .config import validate_config as validate_config
45+
from .display import count_down as count_down
46+
from .experiment import initialize_exp as initialize_exp
47+
from .ports import show_ports as show_ports
48+
from .templates import taps as taps
49+
from .trials import (
50+
next_trial_id as next_trial_id,
51+
reset_trial_counter as reset_trial_counter,
52+
resolve_deadline as resolve_deadline,
53+
resolve_trial_id as resolve_trial_id,
54+
)
55+
from .voices import list_supported_voices as list_supported_voices
56+
57+
58+
def __getattr__(name: str) -> Any:
59+
spec = _LAZY_ATTRS.get(name)
60+
if spec is None:
61+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
62+
63+
module_name, attr_name = spec
64+
module = importlib.import_module(module_name)
65+
value = getattr(module, attr_name)
66+
globals()[name] = value # cache for subsequent access
67+
return value
68+
69+
70+
def __dir__() -> list[str]:
71+
return sorted(set(globals().keys()) | set(_LAZY_ATTRS.keys()))

scripts/build-site-data.py

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,22 +132,47 @@ def source_url(module_name: str) -> str:
132132

133133

134134
def resolve_symbol_source(module_name: str, symbol: str) -> str:
135-
try:
136-
import_map = parse_import_map(module_name)
137-
except FileNotFoundError:
138-
return module_name
139-
return import_map.get(symbol, module_name)
135+
current_module = module_name
136+
current_symbol = symbol
137+
seen: set[tuple[str, str]] = set()
138+
139+
while (current_module, current_symbol) not in seen:
140+
seen.add((current_module, current_symbol))
141+
142+
source_module = None
143+
try:
144+
import_map = parse_import_map(current_module)
145+
except FileNotFoundError:
146+
import_map = {}
147+
source_module = import_map.get(current_symbol)
148+
149+
if source_module is None:
150+
try:
151+
lazy_map = {
152+
item.name: item.source_module
153+
for item in parse_lazy_attrs(current_module)
154+
}
155+
except FileNotFoundError:
156+
lazy_map = {}
157+
source_module = lazy_map.get(current_symbol)
158+
159+
if source_module is None or source_module == current_module:
160+
return current_module
161+
162+
current_module = source_module
140163

164+
return current_module
141165

142-
def parse_root_lazy_attrs() -> list[ExportSpec]:
143-
text = read_text(ROOT / "psyflow" / "__init__.py")
166+
167+
def parse_lazy_attrs(module_name: str) -> list[ExportSpec]:
168+
text = read_text(module_file(module_name))
144169
pattern = re.compile(r'"([^"]+)":\s*\("([^"]+)",\s*"([^"]+)"\)')
145170
items: list[ExportSpec] = []
146171
for name, source_module, _attr_name in pattern.findall(text):
147172
items.append(
148173
ExportSpec(
149174
name=name,
150-
module="psyflow",
175+
module=module_name,
151176
source_module=source_module,
152177
)
153178
)
@@ -157,7 +182,7 @@ def parse_root_lazy_attrs() -> list[ExportSpec]:
157182
def build_inventory() -> list[dict[str, Any]]:
158183
groups: list[dict[str, Any]] = []
159184

160-
root_exports = parse_root_lazy_attrs()
185+
root_exports = parse_lazy_attrs("psyflow")
161186
groups.append(
162187
{
163188
"module": "psyflow",
@@ -175,19 +200,31 @@ def build_inventory() -> list[dict[str, Any]]:
175200
)
176201

177202
for module_name in ["psyflow.utils", "psyflow.io", "psyflow.qa", "psyflow.sim"]:
178-
export_names = parse_dunder_all(module_name)
179-
import_map = parse_import_map(module_name)
180-
exports = []
181-
for name in export_names:
182-
source_module = import_map.get(name, module_name)
183-
exports.append(
203+
lazy_exports = parse_lazy_attrs(module_name)
204+
if lazy_exports:
205+
exports = [
184206
{
185-
"name": name,
186-
"source_module": source_module,
187-
"summary": doc_summary(source_module, name),
188-
"source_url": source_url(source_module),
207+
"name": item.name,
208+
"source_module": item.source_module,
209+
"summary": doc_summary(item.source_module, item.name),
210+
"source_url": source_url(item.source_module),
189211
}
190-
)
212+
for item in lazy_exports
213+
]
214+
else:
215+
export_names = parse_dunder_all(module_name)
216+
import_map = parse_import_map(module_name)
217+
exports = []
218+
for name in export_names:
219+
source_module = import_map.get(name, module_name)
220+
exports.append(
221+
{
222+
"name": name,
223+
"source_module": source_module,
224+
"summary": doc_summary(source_module, name),
225+
"source_url": source_url(source_module),
226+
}
227+
)
191228
groups.append(
192229
{
193230
"module": module_name,

tests/test_smoke.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,60 @@
33

44

55
class TestImport(unittest.TestCase):
6+
def _clear_psyflow_and_psychopy_modules(self) -> None:
7+
for name in [
8+
module_name
9+
for module_name in list(sys.modules)
10+
if module_name == "psyflow"
11+
or module_name.startswith("psyflow.")
12+
or module_name == "psychopy"
13+
or module_name.startswith("psychopy.")
14+
]:
15+
sys.modules.pop(name, None)
16+
617
def test_import_psyflow_does_not_import_psychopy(self):
718
# The package should be importable without pulling in PsychoPy by default.
8-
sys.modules.pop("psyflow", None)
9-
sys.modules.pop("psychopy", None)
19+
self._clear_psyflow_and_psychopy_modules()
1020

1121
import psyflow # noqa: F401
1222

1323
self.assertNotIn("psychopy", sys.modules)
1424

25+
def test_import_psyflow_utils_does_not_import_psychopy(self):
26+
self._clear_psyflow_and_psychopy_modules()
27+
28+
from psyflow.utils import resolve_deadline
29+
30+
self.assertEqual(resolve_deadline([0.25, 0.5]), 0.5)
31+
self.assertNotIn("psychopy", sys.modules)
32+
self.assertNotIn("psyflow.utils.display", sys.modules)
33+
self.assertNotIn("psyflow.utils.experiment", sys.modules)
34+
35+
36+
class TestSimContextHelpers(unittest.TestCase):
37+
def test_set_trial_context_resolves_deadline_sequences(self):
38+
from psyflow.sim.context_helpers import set_trial_context
39+
40+
class DummyUnit:
41+
def __init__(self) -> None:
42+
self.state: dict[str, object] | None = None
43+
44+
def set_state(self, **kwargs):
45+
self.state = kwargs
46+
return kwargs
47+
48+
unit = DummyUnit()
49+
result = set_trial_context(
50+
unit,
51+
trial_id=1,
52+
phase="target",
53+
deadline_s=[0.2, 0.5],
54+
valid_keys=["space"],
55+
)
56+
57+
self.assertEqual(result["deadline_s"], 0.5)
58+
self.assertEqual(unit.state["deadline_s"], 0.5)
59+
1560

1661
class TestTaskSettings(unittest.TestCase):
1762
def test_from_dict_sets_derived_fields(self):

website/src/data/generated/changelog.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
[
2+
{
3+
"version": "0.1.22",
4+
"date": "2026-04-05",
5+
"summary": [
6+
"Made `psyflow.utils` lazy-load PsychoPy-dependent helpers so importing `psyflow.utils` no longer eagerly imports `display` or `experiment`.",
7+
"Removed the duplicate sim-side deadline helper and reused `psyflow.utils.trials.resolve_deadline()` from `psyflow.sim.context_helpers`.",
8+
"Added smoke coverage for the import boundary and sim deadline resolution.",
9+
"Addresses issue #21: `utils/__init__.py eager psychopy imports block cross-module reuse`."
10+
]
11+
},
212
{
313
"version": "0.1.21",
414
"date": "2026-03-04",

website/src/data/generated/site-data.json

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@
1313
}
1414
},
1515
"latest_release": {
16-
"version": "0.1.21",
17-
"date": "2026-03-04",
16+
"version": "0.1.22",
17+
"date": "2026-04-05",
1818
"summary": [
19-
"Removed in-repo `skills/task-build` from `psyflow`.",
20-
"Extracted `task-build` as a standalone canonical repository at `e:/Taskbeacon/skills/task-build`.",
21-
"No backward-compatibility shim is kept in `psyflow`; skill ownership now lives outside the framework repo."
19+
"Made `psyflow.utils` lazy-load PsychoPy-dependent helpers so importing `psyflow.utils` no longer eagerly imports `display` or `experiment`.",
20+
"Removed the duplicate sim-side deadline helper and reused `psyflow.utils.trials.resolve_deadline()` from `psyflow.sim.context_helpers`.",
21+
"Added smoke coverage for the import boundary and sim deadline resolution.",
22+
"Addresses issue #21: `utils/__init__.py eager psychopy imports block cross-module reuse`."
2223
]
2324
},
24-
"release_count": 19,
25+
"release_count": 20,
2526
"module_count": 5
2627
}

0 commit comments

Comments
 (0)