psiapp 0.0.1__tar.gz
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.
- psiapp-0.0.1/.github/workflows/publish-to-pypi.yml +37 -0
- psiapp-0.0.1/.gitignore +81 -0
- psiapp-0.0.1/LICENSE.txt +21 -0
- psiapp-0.0.1/PKG-INFO +10 -0
- psiapp-0.0.1/psiapp/api.py +6 -0
- psiapp-0.0.1/psiapp/experiment.enaml +130 -0
- psiapp-0.0.1/psiapp/process_manager.py +146 -0
- psiapp-0.0.1/psiapp/version.py +34 -0
- psiapp-0.0.1/psiapp/widgets.enaml +315 -0
- psiapp-0.0.1/psiapp.egg-info/PKG-INFO +10 -0
- psiapp-0.0.1/psiapp.egg-info/SOURCES.txt +14 -0
- psiapp-0.0.1/psiapp.egg-info/dependency_links.txt +1 -0
- psiapp-0.0.1/psiapp.egg-info/requires.txt +1 -0
- psiapp-0.0.1/psiapp.egg-info/top_level.txt +1 -0
- psiapp-0.0.1/pyproject.toml +27 -0
- psiapp-0.0.1/setup.cfg +4 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Publish Python distribution to PyPI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags:
|
|
5
|
+
- '[0-9].[0-9]+.[0-9]+'
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
pypi-publish:
|
|
9
|
+
name: Build and publish Python distribution to PyPI
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment:
|
|
12
|
+
name: pypi
|
|
13
|
+
url: https://pypi.org/p/psiapp
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@main
|
|
18
|
+
- name: Setup Python 3.10
|
|
19
|
+
uses: actions/setup-python@v3
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.10"
|
|
22
|
+
- name: Install pypa/build
|
|
23
|
+
run: >-
|
|
24
|
+
python -m
|
|
25
|
+
pip install
|
|
26
|
+
build
|
|
27
|
+
--user
|
|
28
|
+
- name: Build a binary wheel and a source tarball
|
|
29
|
+
run: >-
|
|
30
|
+
python -m
|
|
31
|
+
build
|
|
32
|
+
--sdist
|
|
33
|
+
--wheel
|
|
34
|
+
--outdir dist/
|
|
35
|
+
- name: Publish distribution to PyPI
|
|
36
|
+
if: startsWith(github.ref, 'refs/tags')
|
|
37
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
psiapp-0.0.1/.gitignore
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Automatically generated by setuptools_scm
|
|
2
|
+
psiapp/version.py
|
|
3
|
+
|
|
4
|
+
# Byte-compiled / optimized / DLL files
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*$py.class
|
|
8
|
+
|
|
9
|
+
# C extensions
|
|
10
|
+
*.so
|
|
11
|
+
|
|
12
|
+
# Distribution / packaging
|
|
13
|
+
.Python
|
|
14
|
+
env/
|
|
15
|
+
build/
|
|
16
|
+
develop-eggs/
|
|
17
|
+
dist/
|
|
18
|
+
downloads/
|
|
19
|
+
eggs/
|
|
20
|
+
.eggs/
|
|
21
|
+
lib/
|
|
22
|
+
lib64/
|
|
23
|
+
parts/
|
|
24
|
+
sdist/
|
|
25
|
+
var/
|
|
26
|
+
*.egg-info/
|
|
27
|
+
.installed.cfg
|
|
28
|
+
*.egg
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*,cover
|
|
49
|
+
.hypothesis/
|
|
50
|
+
.pytest_cache/
|
|
51
|
+
|
|
52
|
+
# Translations
|
|
53
|
+
*.mo
|
|
54
|
+
*.pot
|
|
55
|
+
|
|
56
|
+
# Django stuff:
|
|
57
|
+
*.log
|
|
58
|
+
|
|
59
|
+
# Sphinx documentation
|
|
60
|
+
docs/build/
|
|
61
|
+
docs/source/gallery/
|
|
62
|
+
docs/examples/
|
|
63
|
+
docs/source/gen_modules/
|
|
64
|
+
|
|
65
|
+
# PyBuilder
|
|
66
|
+
target/
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Vim global
|
|
70
|
+
[._]*.s[a-w][a-z]
|
|
71
|
+
[._]s[a-w][a-z]
|
|
72
|
+
*.un~
|
|
73
|
+
Session.vim
|
|
74
|
+
.netrwhist
|
|
75
|
+
*~
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__enamlcache__/
|
|
79
|
+
sandbox/
|
|
80
|
+
.ropeproject/
|
|
81
|
+
.ipynb_checkpoints/
|
psiapp-0.0.1/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 psiapp development team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
psiapp-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: psiapp
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Tools for supporting experiment launchers using psiexperiment
|
|
5
|
+
Author-email: Brad Buran <buran@ohsu.edu>
|
|
6
|
+
Maintainer-email: Brad Buran <buran@ohsu.edu>
|
|
7
|
+
Requires-Python: >=3.7
|
|
8
|
+
License-File: LICENSE.txt
|
|
9
|
+
Requires-Dist: enaml[qt6-pyside]>=0.13.0
|
|
10
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
log = logging.getLogger()
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from atom.api import Atom, Dict, Enum, List, Str, Value
|
|
7
|
+
|
|
8
|
+
from psi.experiment.api import (paradigm_manager, ParadigmNotFound)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Experiment(Atom):
|
|
12
|
+
|
|
13
|
+
paradigm = Value()
|
|
14
|
+
|
|
15
|
+
preference = Str()
|
|
16
|
+
|
|
17
|
+
#: Plugins selected for load
|
|
18
|
+
plugins = List(Str())
|
|
19
|
+
|
|
20
|
+
#: Supplemental note to append based on the button clicked.
|
|
21
|
+
mode_notes = Dict()
|
|
22
|
+
|
|
23
|
+
def iter_selectable_plugins(self):
|
|
24
|
+
for plugin in self.paradigm.plugins:
|
|
25
|
+
if plugin.required:
|
|
26
|
+
continue
|
|
27
|
+
if plugin.info.get('hide', False):
|
|
28
|
+
continue
|
|
29
|
+
yield plugin
|
|
30
|
+
|
|
31
|
+
def _default_mode_notes(self):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
def __init__(self, paradigm, plugins=None, preference=None, **kwargs):
|
|
35
|
+
if isinstance(paradigm, str):
|
|
36
|
+
paradigm = paradigm_manager.get_paradigm(paradigm)
|
|
37
|
+
self.paradigm = paradigm
|
|
38
|
+
|
|
39
|
+
# Make sure the plugins saved to the config file are valid plugins (we
|
|
40
|
+
# sometimes remove or rename plugins). If the plugin is no longer
|
|
41
|
+
# valid, remove it. If the plugin is required, remove it as a plugin
|
|
42
|
+
# that the user can select from in the GUI (since it automatically gets
|
|
43
|
+
# loaded).
|
|
44
|
+
plugins = set() if plugins is None else set(plugins)
|
|
45
|
+
valid_plugins = set(p.id for p in self.iter_selectable_plugins())
|
|
46
|
+
plugins = list(plugins & valid_plugins)
|
|
47
|
+
|
|
48
|
+
# We only save preference name, not the full path to the preference. We
|
|
49
|
+
# need to restore the full path to the preference by scanning through
|
|
50
|
+
# the list of avaialble preferences. This allows for portability across
|
|
51
|
+
# systems.
|
|
52
|
+
if preference is None:
|
|
53
|
+
preference = ''
|
|
54
|
+
else:
|
|
55
|
+
preference = Path(preference)
|
|
56
|
+
for valid_preference in paradigm.list_preferences():
|
|
57
|
+
if valid_preference.stem == preference.stem:
|
|
58
|
+
preference = str(valid_preference)
|
|
59
|
+
break
|
|
60
|
+
else:
|
|
61
|
+
log.warning('Invalid preference requested for %s: %s', paradigm,
|
|
62
|
+
preference)
|
|
63
|
+
preference = ''
|
|
64
|
+
|
|
65
|
+
super().__init__(paradigm=paradigm, plugins=plugins,
|
|
66
|
+
preference=preference, **kwargs)
|
|
67
|
+
|
|
68
|
+
def __getstate__(self):
|
|
69
|
+
state = super().__getstate__()
|
|
70
|
+
# Convert some keys to things that can be JSON-serialized. Don't save
|
|
71
|
+
# the full path to the preference because we want this to be portable
|
|
72
|
+
# across environments and installs.
|
|
73
|
+
state['preference'] = Path(state['preference']).name
|
|
74
|
+
state['paradigm'] = state['paradigm'].name
|
|
75
|
+
return state
|
|
76
|
+
|
|
77
|
+
def freeze(self, mode):
|
|
78
|
+
'''
|
|
79
|
+
Return experiment in which mode and ear are fixed. Used in running
|
|
80
|
+
sequences.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
mode : str
|
|
85
|
+
One of the modes the paradigm can be run under (e.g., 'run',
|
|
86
|
+
'ipsi', 'contra').
|
|
87
|
+
|
|
88
|
+
This is used for sequences where we may need to specify run mode (i.e.,
|
|
89
|
+
ipsi vs. contra).
|
|
90
|
+
'''
|
|
91
|
+
if mode is None:
|
|
92
|
+
mode = self.modes[0]
|
|
93
|
+
return FrozenExperiment(
|
|
94
|
+
paradigm=self.paradigm,
|
|
95
|
+
preference=self.preference,
|
|
96
|
+
plugins=self.plugins,
|
|
97
|
+
mode_notes=self.mode_notes,
|
|
98
|
+
mode=mode,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class FrozenExperiment(Experiment):
|
|
103
|
+
'''
|
|
104
|
+
Subclass of Experiment in which we have frozen the ear and mode (used for
|
|
105
|
+
sequences).
|
|
106
|
+
'''
|
|
107
|
+
mode = Str()
|
|
108
|
+
|
|
109
|
+
#: If either, then ear will be drawn from what is selected.
|
|
110
|
+
ear = Enum('selected', 'left', 'right')
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_experiments(seq, experiment_class=Experiment):
|
|
114
|
+
'''
|
|
115
|
+
Helper function for loading experiments from JSON file
|
|
116
|
+
'''
|
|
117
|
+
experiments = []
|
|
118
|
+
for s in seq:
|
|
119
|
+
# Remove obsolete label argument from saved paradigms and
|
|
120
|
+
# legacy modes since we changed how this is handled (it was
|
|
121
|
+
# always a hack to put in the file).
|
|
122
|
+
s.pop('label', None)
|
|
123
|
+
s.pop('modes', None)
|
|
124
|
+
mode_notes = s.get('mode_notes', {})
|
|
125
|
+
s['mode_notes'] = {k.lower(): v for k, v in mode_notes.items()}
|
|
126
|
+
try:
|
|
127
|
+
experiments.append(experiment_class(**s))
|
|
128
|
+
except ParadigmNotFound:
|
|
129
|
+
pass
|
|
130
|
+
return experiments
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
log = logging.getLogger(__name__)
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import threading
|
|
7
|
+
|
|
8
|
+
from atom.api import Atom, Bool, Float, List, Typed, Value
|
|
9
|
+
from enaml.application import timed_call
|
|
10
|
+
|
|
11
|
+
from psi.paradigms.core.websocket_mixins import WebsocketServerPlugin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def synchronized(fn):
|
|
15
|
+
'''
|
|
16
|
+
Decorator that ensures lock is obtained before calling method
|
|
17
|
+
'''
|
|
18
|
+
def wrapped(self, *args, **kw):
|
|
19
|
+
with self.lock:
|
|
20
|
+
return fn(self, *args, **kw)
|
|
21
|
+
return wrapped
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProcessManager(Atom):
|
|
25
|
+
'''
|
|
26
|
+
Manager for running one or more psiexperiments in sequence
|
|
27
|
+
'''
|
|
28
|
+
|
|
29
|
+
#: List of commands the order they should be executed.
|
|
30
|
+
commands = List()
|
|
31
|
+
|
|
32
|
+
#: List of subprocesses that are currently open.
|
|
33
|
+
subprocesses = List()
|
|
34
|
+
|
|
35
|
+
#: Current active subprocess (we retain references to all subprocesses that
|
|
36
|
+
#: are currently open since we do not auto-close the GUI).
|
|
37
|
+
current_subprocess = Value()
|
|
38
|
+
|
|
39
|
+
#: Should experiments be auto-started?
|
|
40
|
+
autostart = Bool(False)
|
|
41
|
+
|
|
42
|
+
#: Used for communicating with experiments invoked by `psi`.
|
|
43
|
+
ws_server = Typed(WebsocketServerPlugin)
|
|
44
|
+
|
|
45
|
+
#: Used to track runtime of individual experiments.
|
|
46
|
+
exp_start_time = Float()
|
|
47
|
+
|
|
48
|
+
#: Duration of last experiment from experiment_start to experiment_end event.
|
|
49
|
+
duration = Float()
|
|
50
|
+
|
|
51
|
+
lock = Value()
|
|
52
|
+
|
|
53
|
+
def __init__(self, *args, **kw):
|
|
54
|
+
super().__init__(*args, **kw)
|
|
55
|
+
# Set up a websocket server. Whenever clients (i.e., programs invoked
|
|
56
|
+
# by `psi`) connect they will be instructed to relay only a handful of
|
|
57
|
+
# events to keep load on the websocket low.
|
|
58
|
+
self.ws_server = WebsocketServerPlugin(
|
|
59
|
+
recv_cb=self.recv_cb,
|
|
60
|
+
connect_cb=self.connect_cb,
|
|
61
|
+
)
|
|
62
|
+
self.ws_server.start_thread()
|
|
63
|
+
log.error(self.ws_server.connected_uri)
|
|
64
|
+
self.lock = threading.Lock()
|
|
65
|
+
timed_call(1000, self.check_status)
|
|
66
|
+
|
|
67
|
+
def connect_cb(self):
|
|
68
|
+
events = ['plugins_started', 'experiment_start', 'experiment_end',
|
|
69
|
+
'window_closed']
|
|
70
|
+
self.ws_server.send_message({
|
|
71
|
+
'command': 'websocket.set_event_filter',
|
|
72
|
+
'parameters': {'event_filter': '|'.join(events)},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
def open_next_subprocess(self):
|
|
76
|
+
try:
|
|
77
|
+
cmd, env = self.commands.pop(0)
|
|
78
|
+
except IndexError:
|
|
79
|
+
log.info('No more commands queued')
|
|
80
|
+
return
|
|
81
|
+
process = subprocess.Popen(cmd, env=dict(os.environ, **env))
|
|
82
|
+
self.current_subprocess = {
|
|
83
|
+
'cmd': cmd,
|
|
84
|
+
'env': env,
|
|
85
|
+
'process': process,
|
|
86
|
+
'running': False,
|
|
87
|
+
'client_id': process.pid,
|
|
88
|
+
'state': None,
|
|
89
|
+
}
|
|
90
|
+
self.subprocesses.append(self.current_subprocess)
|
|
91
|
+
|
|
92
|
+
@synchronized
|
|
93
|
+
def recv_cb(self, message):
|
|
94
|
+
# Find which process the message is from.
|
|
95
|
+
for process in self.subprocesses:
|
|
96
|
+
if process['client_id'] == message['client_id']:
|
|
97
|
+
break
|
|
98
|
+
else:
|
|
99
|
+
raise ValueError(f'No process with client ID {process["client_id"]}')
|
|
100
|
+
|
|
101
|
+
if message['event'] == 'plugins_started':
|
|
102
|
+
process['state'] = 'connected'
|
|
103
|
+
# Start the subprocess if it is the first one in the list to run.
|
|
104
|
+
if self.autostart:
|
|
105
|
+
mesg = {'command': 'psi.show_window'}
|
|
106
|
+
self.ws_server.send_message(mesg, process['client_id'])
|
|
107
|
+
mesg = {'command': 'psi.controller.start'}
|
|
108
|
+
self.ws_server.send_message(mesg, process['client_id'])
|
|
109
|
+
elif message['event'] == 'experiment_start':
|
|
110
|
+
process['state'] = 'running'
|
|
111
|
+
self.exp_start_time = time.time()
|
|
112
|
+
elif message['event'] == 'experiment_end':
|
|
113
|
+
if process == self.current_subprocess:
|
|
114
|
+
self.current_subprocess = None
|
|
115
|
+
process['state'] = 'complete'
|
|
116
|
+
self.duration = round(time.time() - self.exp_start_time)
|
|
117
|
+
if message['info'].get('stop_reason') != '':
|
|
118
|
+
self.autostart = False
|
|
119
|
+
self.commands = []
|
|
120
|
+
# Now, start the next one if one exists.
|
|
121
|
+
if self.autostart:
|
|
122
|
+
self.open_next_subprocess()
|
|
123
|
+
elif message['event'] == 'window_closed':
|
|
124
|
+
# Window closed. Remove from the list of subprocesses.
|
|
125
|
+
if process == self.current_subprocess:
|
|
126
|
+
self.current_subprocess = None
|
|
127
|
+
self.subprocesses.remove(process)
|
|
128
|
+
|
|
129
|
+
@synchronized
|
|
130
|
+
def add_command(self, cmd, env):
|
|
131
|
+
log.info('Queueing command: %s', ' '.join(cmd))
|
|
132
|
+
self.commands.append((cmd, env))
|
|
133
|
+
|
|
134
|
+
@synchronized
|
|
135
|
+
def pause_sequence(self):
|
|
136
|
+
# TODO: What to do if we need to re-sequence an experiment?
|
|
137
|
+
self.autostart = False
|
|
138
|
+
|
|
139
|
+
@synchronized
|
|
140
|
+
def check_status(self):
|
|
141
|
+
self.subprocesses = [p for p in self.subprocesses if p['process'].poll() is None]
|
|
142
|
+
# This means the current subprocess has been closed.
|
|
143
|
+
if self.current_subprocess is not None:
|
|
144
|
+
if self.current_subprocess['process'].poll() is not None:
|
|
145
|
+
self.current_subprocess = None
|
|
146
|
+
timed_call(1000, self.check_status)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.0.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = 'g27f4fdb90'
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
log = logging.getLogger(__name__)
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .experiment import Experiment
|
|
7
|
+
|
|
8
|
+
from cftscal.plugins.widgets import AddItem
|
|
9
|
+
|
|
10
|
+
from enaml.core.api import Conditional, Looper
|
|
11
|
+
from enaml.layout.api import align, hbox, spacer, vbox
|
|
12
|
+
from enaml.drag_drop import DragData, DropAction
|
|
13
|
+
from enaml.stdlib.message_box import critical
|
|
14
|
+
from enaml.styling import StyleSheet, Style, Setter
|
|
15
|
+
from enaml.widgets.api import (
|
|
16
|
+
CheckBox, Container, Feature, Field, Form, HGroup, Label, ObjectCombo,
|
|
17
|
+
PopupView, PushButton, VGroup,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from psi.experiment.api import paradigm_manager
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
enamldef AddParadigmPopup(PopupView): popup:
|
|
24
|
+
|
|
25
|
+
attr experiment_box
|
|
26
|
+
attr mode = 'classic'
|
|
27
|
+
attr paradigms
|
|
28
|
+
arrow_size = 20
|
|
29
|
+
|
|
30
|
+
Container:
|
|
31
|
+
constraints = [vbox(combo, hbox(spacer(0), pb_add))]
|
|
32
|
+
ObjectCombo: combo:
|
|
33
|
+
items = paradigms
|
|
34
|
+
to_string = lambda x: x.title
|
|
35
|
+
PushButton: pb_add:
|
|
36
|
+
text = 'OK'
|
|
37
|
+
clicked ::
|
|
38
|
+
exp = Experiment(paradigm=combo.selected)
|
|
39
|
+
if mode == 'sequence':
|
|
40
|
+
exp = exp.freeze(exp.paradigm.info['modes'][0], 'selected')
|
|
41
|
+
seq = experiment_box.sequence[:]
|
|
42
|
+
seq.append(exp)
|
|
43
|
+
experiment_box.sequence = seq
|
|
44
|
+
popup.close()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
enamldef PluginPopup(PopupView): popup:
|
|
48
|
+
attr experiment
|
|
49
|
+
attr available_plugins
|
|
50
|
+
attr modes
|
|
51
|
+
arrow_size = 20
|
|
52
|
+
VGroup:
|
|
53
|
+
Form:
|
|
54
|
+
padding = 0
|
|
55
|
+
Looper:
|
|
56
|
+
iterable = modes
|
|
57
|
+
Label:
|
|
58
|
+
text = f'{loop_item.capitalize()} note'
|
|
59
|
+
Field:
|
|
60
|
+
text = experiment.mode_notes.get(loop_item, '')
|
|
61
|
+
text ::
|
|
62
|
+
experiment.mode_notes[loop_item] = text
|
|
63
|
+
Looper:
|
|
64
|
+
iterable = available_plugins
|
|
65
|
+
CheckBox:
|
|
66
|
+
checked = bool(loop_item.id in experiment.plugins)
|
|
67
|
+
checked ::
|
|
68
|
+
if checked:
|
|
69
|
+
if loop_item.id not in experiment.plugins:
|
|
70
|
+
experiment.plugins.append(loop_item.id)
|
|
71
|
+
else:
|
|
72
|
+
experiment.plugins.remove(loop_item.id)
|
|
73
|
+
text = loop_item.title
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
enamldef ExperimentSequence(Container): experiment_box:
|
|
77
|
+
|
|
78
|
+
attr start_enabled = True
|
|
79
|
+
attr save = True
|
|
80
|
+
attr mode = 'classic'
|
|
81
|
+
attr paradigm_type = 'ear'
|
|
82
|
+
|
|
83
|
+
attr sequence
|
|
84
|
+
attr edit_mode = False
|
|
85
|
+
attr settings
|
|
86
|
+
attr autostart = False
|
|
87
|
+
|
|
88
|
+
StyleSheet:
|
|
89
|
+
Style:
|
|
90
|
+
element = 'Container'
|
|
91
|
+
style_class = 'hover'
|
|
92
|
+
Setter:
|
|
93
|
+
field = 'background'
|
|
94
|
+
value = 'lightblue'
|
|
95
|
+
|
|
96
|
+
layout_constraints => ():
|
|
97
|
+
# Align the subwidgets vertically
|
|
98
|
+
widgets = self.visible_widgets()
|
|
99
|
+
subwidgets = [w.visible_widgets() for w in widgets]
|
|
100
|
+
return [
|
|
101
|
+
vbox(*widgets),
|
|
102
|
+
*[align('width', *c) for c in zip(*subwidgets)],
|
|
103
|
+
*[align('left', *c) for c in zip(*subwidgets)],
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
Looper: exp_loop:
|
|
107
|
+
iterable << experiment_box.sequence
|
|
108
|
+
|
|
109
|
+
Container: row:
|
|
110
|
+
share_layout = True
|
|
111
|
+
style_class = ''
|
|
112
|
+
|
|
113
|
+
features << (Feature.DropEnabled | Feature.DragEnabled)
|
|
114
|
+
|
|
115
|
+
drag_start => ():
|
|
116
|
+
if not edit_mode:
|
|
117
|
+
return
|
|
118
|
+
data = DragData()
|
|
119
|
+
data.supported_actions = DropAction.Copy
|
|
120
|
+
i = experiment_box.sequence.index(loop_item)
|
|
121
|
+
data.mime_data.set_data('text/plain', f'::experiment::{i}'.encode('utf-8'))
|
|
122
|
+
return data
|
|
123
|
+
|
|
124
|
+
drag_enter => (event):
|
|
125
|
+
if not edit_mode:
|
|
126
|
+
return
|
|
127
|
+
if event.mime_data().has_format('text/plain'):
|
|
128
|
+
data = event.mime_data().data('text/plain').decode('utf-8')
|
|
129
|
+
if not data.startswith('::experiment::'):
|
|
130
|
+
return
|
|
131
|
+
else:
|
|
132
|
+
self.style_class = 'hover'
|
|
133
|
+
event.accept_proposed_action()
|
|
134
|
+
|
|
135
|
+
drag_leave => ():
|
|
136
|
+
self.style_class = ''
|
|
137
|
+
|
|
138
|
+
drop => (event):
|
|
139
|
+
if not edit_mode:
|
|
140
|
+
return
|
|
141
|
+
self.style_class = ''
|
|
142
|
+
data = event.mime_data().data('text/plain').decode('utf-8')
|
|
143
|
+
i = int(data.rsplit('::', 1)[1])
|
|
144
|
+
j = experiment_box.sequence.index(loop_item)
|
|
145
|
+
sequence = experiment_box.sequence[:]
|
|
146
|
+
sequence.insert(j, sequence.pop(i))
|
|
147
|
+
experiment_box.sequence = sequence
|
|
148
|
+
|
|
149
|
+
layout_constraints => ():
|
|
150
|
+
widgets = self.visible_widgets()
|
|
151
|
+
return [
|
|
152
|
+
hbox(*widgets),
|
|
153
|
+
align('v_center', *widgets),
|
|
154
|
+
]
|
|
155
|
+
padding = 0
|
|
156
|
+
|
|
157
|
+
PushButton:
|
|
158
|
+
name = 'remove_paradigm'
|
|
159
|
+
visible << edit_mode
|
|
160
|
+
constraints = [width == 30]
|
|
161
|
+
text = '-'
|
|
162
|
+
clicked ::
|
|
163
|
+
seq = experiment_box.sequence[:]
|
|
164
|
+
seq.remove(loop_item)
|
|
165
|
+
experiment_box.sequence = seq
|
|
166
|
+
|
|
167
|
+
Label:
|
|
168
|
+
text = loop_item.paradigm.title
|
|
169
|
+
|
|
170
|
+
Conditional:
|
|
171
|
+
condition = experiment_box.mode == 'classic'
|
|
172
|
+
|
|
173
|
+
HGroup:
|
|
174
|
+
padding = 0
|
|
175
|
+
spacing = 0
|
|
176
|
+
enabled << (experiment_box.start_enabled or edit_mode) and \
|
|
177
|
+
settings.process_manager.current_subprocess is None
|
|
178
|
+
|
|
179
|
+
Looper: mode_loop:
|
|
180
|
+
attr experiment = loop_item
|
|
181
|
+
iterable << loop_item.paradigm.info.get('modes', ['Run'])
|
|
182
|
+
|
|
183
|
+
PushButton:
|
|
184
|
+
text = loop_item.capitalize()
|
|
185
|
+
clicked ::
|
|
186
|
+
try:
|
|
187
|
+
settings.run_experiment(mode_loop.experiment,
|
|
188
|
+
loop_item,
|
|
189
|
+
save=save,
|
|
190
|
+
autostart=autostart)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
log.exception(e)
|
|
193
|
+
critical(experiment_box, 'Error starting experiment', str(e))
|
|
194
|
+
# Update the preferences in case new ones were created
|
|
195
|
+
preferences.items = [str(p) for p in \
|
|
196
|
+
mode_loop.experiment.paradigm.list_preferences()]
|
|
197
|
+
|
|
198
|
+
Conditional:
|
|
199
|
+
condition << (mode == 'sequence' and edit_mode)
|
|
200
|
+
|
|
201
|
+
ObjectCombo:
|
|
202
|
+
items = loop_item.paradigm.info['modes']
|
|
203
|
+
selected := loop_item.mode
|
|
204
|
+
|
|
205
|
+
Conditional:
|
|
206
|
+
condition << (paradigm_type == 'ear')
|
|
207
|
+
|
|
208
|
+
ObjectCombo:
|
|
209
|
+
items = ['selected', 'left', 'right']
|
|
210
|
+
selected := loop_item.ear
|
|
211
|
+
|
|
212
|
+
Label:
|
|
213
|
+
text = f'ear'
|
|
214
|
+
|
|
215
|
+
Conditional:
|
|
216
|
+
condition << (mode == 'sequence' and not edit_mode)
|
|
217
|
+
|
|
218
|
+
Conditional:
|
|
219
|
+
condition << (paradigm_type == 'ear')
|
|
220
|
+
|
|
221
|
+
Label:
|
|
222
|
+
text = f'{loop_item.mode} {loop_item.ear} ear'
|
|
223
|
+
|
|
224
|
+
Conditional:
|
|
225
|
+
condition << (paradigm_type != 'ear')
|
|
226
|
+
|
|
227
|
+
Label:
|
|
228
|
+
text = loop_item.mode
|
|
229
|
+
|
|
230
|
+
ObjectCombo: preferences:
|
|
231
|
+
items = [str(p) for p in loop_item.paradigm.list_preferences()]
|
|
232
|
+
selected := loop_item.preference
|
|
233
|
+
to_string = lambda x: Path(x).stem
|
|
234
|
+
|
|
235
|
+
PushButton:
|
|
236
|
+
attr plugins = [p for p in loop_item.paradigm.plugins \
|
|
237
|
+
if not p.required and not p.info.get('hide', False)]
|
|
238
|
+
constraints = [width == 30]
|
|
239
|
+
text = '⚙'
|
|
240
|
+
clicked ::
|
|
241
|
+
popup = PluginPopup(
|
|
242
|
+
parent=self,
|
|
243
|
+
experiment=loop_item,
|
|
244
|
+
available_plugins=plugins,
|
|
245
|
+
modes=loop_item.paradigm.info.get('modes', [])
|
|
246
|
+
)
|
|
247
|
+
popup.show()
|
|
248
|
+
|
|
249
|
+
Container:
|
|
250
|
+
padding = 0
|
|
251
|
+
layout_constraints => ():
|
|
252
|
+
return [
|
|
253
|
+
hbox(*self.visible_widgets()),
|
|
254
|
+
align('v_center', *self.visible_widgets()),
|
|
255
|
+
]
|
|
256
|
+
PushButton: pb_add_seq:
|
|
257
|
+
visible << edit_mode
|
|
258
|
+
constraints = [width == 30]
|
|
259
|
+
text = '+'
|
|
260
|
+
clicked ::
|
|
261
|
+
popup = AddParadigmPopup(
|
|
262
|
+
parent=self,
|
|
263
|
+
paradigms=paradigm_manager.list_paradigms(paradigm_type),
|
|
264
|
+
experiment_box=experiment_box,
|
|
265
|
+
mode=experiment_box.mode
|
|
266
|
+
)
|
|
267
|
+
popup.show()
|
|
268
|
+
|
|
269
|
+
# A bit of a hack but enables us to ensure all columns lined up properly
|
|
270
|
+
Label:
|
|
271
|
+
pass
|
|
272
|
+
Label:
|
|
273
|
+
pass
|
|
274
|
+
Label:
|
|
275
|
+
pass
|
|
276
|
+
Label:
|
|
277
|
+
pass
|
|
278
|
+
Label:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
enamldef AddRemoveCombo(Container): container:
|
|
283
|
+
|
|
284
|
+
alias items: combo.items
|
|
285
|
+
alias selected: combo.selected
|
|
286
|
+
alias remove_clicked: pb_remove.clicked
|
|
287
|
+
|
|
288
|
+
attr regex = '.*'
|
|
289
|
+
attr editable = True
|
|
290
|
+
attr removed
|
|
291
|
+
|
|
292
|
+
padding = 0
|
|
293
|
+
|
|
294
|
+
constraints << [hbox(combo, hbox(pb_add, pb_remove, spacing=0))] if editable else [hbox(combo)]
|
|
295
|
+
|
|
296
|
+
ObjectCombo: combo:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
PushButton: pb_add:
|
|
300
|
+
text = '+'
|
|
301
|
+
constraints = [width == 30]
|
|
302
|
+
visible << editable
|
|
303
|
+
clicked ::
|
|
304
|
+
popup = AddItem(parent=self, combo=combo, regex=regex)
|
|
305
|
+
popup.show()
|
|
306
|
+
|
|
307
|
+
PushButton: pb_remove:
|
|
308
|
+
text = '-'
|
|
309
|
+
constraints = [width == 30]
|
|
310
|
+
visible << editable
|
|
311
|
+
enabled << bool(combo.items)
|
|
312
|
+
clicked ::
|
|
313
|
+
items = combo.items[:]
|
|
314
|
+
items.remove(combo.selected)
|
|
315
|
+
combo.items = items
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: psiapp
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Tools for supporting experiment launchers using psiexperiment
|
|
5
|
+
Author-email: Brad Buran <buran@ohsu.edu>
|
|
6
|
+
Maintainer-email: Brad Buran <buran@ohsu.edu>
|
|
7
|
+
Requires-Python: >=3.7
|
|
8
|
+
License-File: LICENSE.txt
|
|
9
|
+
Requires-Dist: enaml[qt6-pyside]>=0.13.0
|
|
10
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.gitignore
|
|
2
|
+
LICENSE.txt
|
|
3
|
+
pyproject.toml
|
|
4
|
+
.github/workflows/publish-to-pypi.yml
|
|
5
|
+
psiapp/api.py
|
|
6
|
+
psiapp/experiment.enaml
|
|
7
|
+
psiapp/process_manager.py
|
|
8
|
+
psiapp/version.py
|
|
9
|
+
psiapp/widgets.enaml
|
|
10
|
+
psiapp.egg-info/PKG-INFO
|
|
11
|
+
psiapp.egg-info/SOURCES.txt
|
|
12
|
+
psiapp.egg-info/dependency_links.txt
|
|
13
|
+
psiapp.egg-info/requires.txt
|
|
14
|
+
psiapp.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
enaml[qt6-pyside]>=0.13.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
psiapp
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "psiapp"
|
|
3
|
+
description = "Tools for supporting experiment launchers using psiexperiment"
|
|
4
|
+
requires-python = ">=3.7"
|
|
5
|
+
license = {file = "license.txt"}
|
|
6
|
+
authors = [
|
|
7
|
+
{name = "Brad Buran", email="buran@ohsu.edu"},
|
|
8
|
+
]
|
|
9
|
+
maintainers = [
|
|
10
|
+
{name = "Brad Buran", email="buran@ohsu.edu"},
|
|
11
|
+
]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"enaml[qt6-pyside] >=0.13.0",
|
|
14
|
+
]
|
|
15
|
+
dynamic = ["version"]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["setuptools>=61.2", "wheel", "setuptools_scm[toml]>=3.4.3"]
|
|
19
|
+
build-backend = "setuptools.build_meta"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools_scm]
|
|
22
|
+
write_to = "psiapp/version.py"
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
markers = [
|
|
26
|
+
"slow: runs tests that will otherwise be skipped because they are slow"
|
|
27
|
+
]
|
psiapp-0.0.1/setup.cfg
ADDED