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.
@@ -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
@@ -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/
@@ -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,6 @@
1
+ from .experiment import load_experiments, Experiment, FrozenExperiment
2
+ from .process_manager import ProcessManager
3
+
4
+ import enaml
5
+ with enaml.imports():
6
+ from .widgets import AddRemoveCombo, ExperimentSequence
@@ -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
+ 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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+