Skip to content

Commit

Permalink
Add some misc classes for working with psychopy
Browse files Browse the repository at this point in the history
  • Loading branch information
pablomm committed Sep 14, 2024
1 parent da55956 commit addef95
Show file tree
Hide file tree
Showing 8 changed files with 961 additions and 0 deletions.
4 changes: 4 additions & 0 deletions dmf/psy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .config import Config, load_config
from .display import Display, load_display
__all__ = ["Config", "load_config", "load_display", "Display"]

72 changes: 72 additions & 0 deletions dmf/psy/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@

from typing import Union, Optional
from pathlib import Path
from ..io import load
from ..env import getenv


DEFAULT_CONFIG_VARIABLE = "DMF_CONFIG"

CONFIG : Optional["Config"] = None


def load_config(filename: Optional[Union[str, Path]]=None, force: bool = False) -> "Config":
"""Load the configuration file."""
global CONFIG
if CONFIG is None or force:
CONFIG = Config(filename)

return CONFIG

class Config:
"""General class for configuration parameters."""
def __init__(self, filename: Union[str, Path] = None):
self._config = {}

filename = getenv(DEFAULT_CONFIG_VARIABLE, None)
self.filename = filename
if filename is not None:
loaded = load(filename)
self._config.update(loaded)

def get(self, key: str, default=None):
"""Get a value from the content."""
# The key is a path separated by dots.
# For example, "experiment.instructions.welcome"
keys = key.split(".")
value = self._config
for k in keys:
if isinstance(value, dict) and k in value:
value = value[k]
else:
return default
return value

def __getitem__(self, key):
return self._config[key]

def __setitem__(self, key, value):
self._config[key] = value

def __contains__(self, key):
return key in self._config

def keys(self):
return self._config.keys()

def values(self):
return self._config.values()

def items(self):
return self._config.items()

def update(self, other):
self._config.update(other)

def __repr__(self):
return f"Config('{self.filename}')"

def setdefault(self, key, value):
return self._config.setdefault(key, value)


120 changes: 120 additions & 0 deletions dmf/psy/dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import re
from typing import Optional, Dict, Any, List
from psychopy import gui
from .exceptions import ExperimentStopped

def dialog_form(
information: Dict[str, Dict[str, Any]],
title: Optional[str] = None,
cancel_message: str = "User cancelled input information.",
) -> Dict[str, Any]:
"""
Display a dialog to request subject information based on a given information dictionary.
This function creates a dialog window using PsychoPy's GUI module, allowing the user to input
or select information as specified in the `information` dictionary. It supports text input,
fixed fields, dropdown selections, and regex validation.
Parameters
----------
information : dict
A dictionary where each key corresponds to a field in the dialog. The value for each key
is another dictionary that can contain the following optional keys:
- `'label'`: str, the label to display for this field (default is the key itself).
- `'default'`: Any, the default value for this field (default is an empty string).
- `'regex'`: str, a regular expression pattern to validate the input (default is `None`).
- `'fixed'`: bool, whether the field is read-only (default is `False`).
- `'choices'`: list, if provided, the field will be a dropdown menu with these choices.
title : str, optional
The title of the dialog window. If not provided, no title will be displayed.
cancel_message : str, optional
The message of the exception if the user cancels the dialog or if input validation fails.
Defaults to "User cancelled input information."
Returns
-------
dict
A dictionary containing the user's input, with keys corresponding to those in the
`information` dictionary.
Raises
------
ExperimentStopped
If the user cancels the dialog or if input validation fails due to regex mismatch.
"""

# Separate fields with and without choices
info_choices = {k: v for k, v in information.items() if "choices" in v}
info_fields = {k: v for k, v in information.items() if "choices" not in v}

# Prepare the dialog data
info = {k: v.get("default", "") for k, v in info_fields.items()}
labels = {k: v.get("label", k) for k, v in info_fields.items()}
labels.update({k: v.get("label", k) for k, v in info_choices.items()})
regex_patterns = {k: v.get("regex") for k, v in info_fields.items()}
fixed_fields = [k for k, v in info_fields.items() if v.get("fixed", False)]

try:
# Create the dialog
dialog = gui.DlgFromDict(
dictionary=info,
title=title,
fixed=fixed_fields,
labels=labels,
show=False
)

# Add choice fields
for k, v in info_choices.items():
choices = v.get("choices", ["-"])
initial = v.get("default", choices[0])
# label = v.get("label", k)
dialog.addField(k, initial=initial, choices=choices)

dialog.show()

except KeyboardInterrupt:
raise ExperimentStopped(cancel_message)

if not dialog.OK:
raise ExperimentStopped(cancel_message)

# Validate input using regex patterns
for k, pattern in regex_patterns.items():
if pattern is not None and not re.match(pattern, info[k]):
raise ExperimentStopped(f"Invalid {k} format: {info[k]}.")


return info

def dialog_accept(message: str, title: Optional[str] = None, **kwargs) -> Optional[bool]:
"""
Display a dialog with a message and Yes/No buttons.
Returns `True` for 'Yes', `False` for 'No', and `None` if the window is closed.
"""
dialog = gui.Dlg(title=title, **kwargs)
dialog.addText(message)
user_response = dialog.show()

if dialog.OK:
return True
elif user_response is None:
# Dialog was closed using the window's close button
return None
else:
return False

class DialogMixin:
"""Mixin class for dialogs in PsychoPy."""

def dialog_form(self, information: Dict[str, Dict[str, Any]], title: Optional[str] = None, **kwargs) -> Dict[str, Any]:
"""Display an information dialog."""
return dialog_form(information, title=title, **kwargs)

def dialog_accept(self, message: str, title: Optional[str] = None, **kwargs) -> Optional[bool]:
"""Display an accept dialog."""
return dialog_accept(message, title=title, **kwargs)

Loading

0 comments on commit addef95

Please sign in to comment.