Skip to content

Commit 1540325

Browse files
committed
Merge branch 'psychopy'
# Conflicts: # bidscoin/plugins/events2bids.py
2 parents 28a7a4e + f69b0fa commit 1540325

23 files changed

+147698
-182
lines changed

CONTRIBUTING.rst

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ The preferred way to contribute to the BIDScoin code base or documentation is to
2626

2727
1. Set up a clone of the repository on your local machine and connect it to both the “official” and your copy of the repository on GitHub::
2828

29-
git clone git://github.com/Donders-Institute/bidscoin
29+
git clone https://github.com/Donders-Institute/bidscoin.git
3030
cd bidscoin
3131
git remote rename origin official
3232
git remote add origin git://github.com/[YOUR_GITHUB_USERNAME]/bidscoin
@@ -35,15 +35,14 @@ The preferred way to contribute to the BIDScoin code base or documentation is to
3535

3636
python -m venv venv # Or use any other tool (such as conda)
3737
source venv/bin/activate # On Linux, see the documentation for other operating systems
38-
pip install bidscoin[dev] # Unfortunately pyproject.toml has no way to install BIDScoin's dependencies only
39-
pip uninstall bidscoin # Hence we need to retrospectively remove BIDScoin from site-packages
38+
pip install -e .[dev]
4039
# NB: Install dcm2niix yourself (see the online installation instructions)
4140

42-
3. When you wish to start a new contribution, create a new branch::
41+
3. When you wish to start working on your contribution, create a new branch::
4342

4443
git checkout -b [topic_of_your_contribution]
4544

46-
4. When you are done making the changes you wish to contribute, test, commit and push them to GitHub::
45+
4. When you are done with coding, you should then test, commit and push your work to GitHub::
4746

4847
tox # Run this command from the bidscoin directory (make sure the venv is activated)
4948
git commit -am "A SHORT DESCRIPTION OF THE CHANGES" # Run this command every time you have made a set of changes that belong together

bidscoin/bids.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def ignore_aliases(self, data):
5757

5858

5959
class DataSource:
60-
"""Reads properties, attributes and BIDS-related features to sourcefiles of a supported dataformat (e.g. DICOM or PAR)"""
60+
"""Reads properties, attributes and BIDS-related features to source files of a supported dataformat (e.g. DICOM or PAR)"""
6161

6262
def __init__(self, sourcefile: Union[str, Path]='', plugins: Plugins=None, dataformat: str='', options: Options=None):
6363
"""
@@ -669,7 +669,7 @@ def eventsparser(self) -> EventsParser:
669669

670670
for name in self.plugins:
671671
if plugin := bcoin.import_plugin(name, (f"{self.dataformat}Events",)):
672-
return getattr(plugin, f"{self.dataformat}Events")(self.provenance, self.events, self.plugins[name])
672+
return getattr(plugin, f"{self.dataformat}Events")(Path(self.provenance), self.events, self.plugins[name])
673673

674674

675675
class DataType:
@@ -815,7 +815,7 @@ def __hash__(self):
815815
def participant(self) -> dict:
816816
"""The data to populate the participants.tsv table"""
817817

818-
return self._data['participant']
818+
return self._data.get('participant', {})
819819

820820
@participant.setter
821821
def participant(self, value: dict):
@@ -1040,14 +1040,14 @@ def plugins(self, plugins: Plugins):
10401040

10411041
@property
10421042
def dataformats(self):
1043-
"""Gets a list of the DataFormat objects in the bidsmap (e.g. DICOM)"""
1043+
"""Gets a list of the non-empty DataFormat objects in the bidsmap (e.g. DICOM)"""
10441044

10451045
return [DataFormat(dataformat, self._data[dataformat], self.options, self.plugins) for dataformat in self._data if dataformat not in ('$schema', 'Options')]
10461046

10471047
def dataformat(self, dataformat: str) -> DataFormat:
10481048
"""Gets the DataFormat object from the bidsmap"""
10491049

1050-
return DataFormat(dataformat, self._data[dataformat], self.options, self.plugins)
1050+
return DataFormat(dataformat, self._data.get(dataformat,''), self.options, self.plugins)
10511051

10521052
def add_dataformat(self, dataformat: Union[str, DataFormat]):
10531053
"""Adds a DataFormat to the bidsmap"""
@@ -1311,6 +1311,11 @@ def get_matching_run(self, sourcefile: Union[str, Path], dataformat: str='', run
13111311
if provenance: break
13121312
return runitem, provenance
13131313

1314+
# Check if the dataformat is present
1315+
if dataformat not in self.dataformats:
1316+
LOGGER.error(f"Cannot find a matching run-item for {sourcefile} due to a missing '{dataformat}' section in {self}")
1317+
return RunItem(), ''
1318+
13141319
# Defaults
13151320
datasource = DataSource(sourcefile, self.plugins, dataformat, options=self.options)
13161321
unknowndatatypes = self.options.get('unknowntypes') or ['unknown_data']

bidscoin/bidseditor.py

Lines changed: 67 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
args: Argument string that is passed to dcm2niix. Click [Test] and see the terminal output for usage
7272
Tip: SPM users may want to use '-z n', which produces unzipped NIfTI's
7373
74-
meta: The file extensions of the associated / equally named (meta)data sourcefiles that are copied over as
74+
meta: The file extensions of the associated / equally named (meta)data source files that are copied over as
7575
BIDS (sidecar) files, such as ['.json', '.tsv', '.tsv.gz']. You can use this to enrich json sidecar files,
7676
or add data that is not supported by this plugin
7777
@@ -91,7 +91,6 @@ def __init__(self, minsize: bool=True, ncols: int=0, nrows: int=0):
9191
self.horizontalHeader().setVisible(False)
9292
self.verticalHeader().setVisible(False)
9393
self.verticalHeader().setDefaultSectionSize(ROW_HEIGHT)
94-
self.setMinimumHeight(2 * (ROW_HEIGHT + 8))
9594
self.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents)
9695
if minsize:
9796
self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
@@ -101,6 +100,7 @@ def __init__(self, minsize: bool=True, ncols: int=0, nrows: int=0):
101100
self.setColumnCount(ncols)
102101
if nrows:
103102
self.setRowCount(nrows)
103+
self.resizeRowsToContents()
104104

105105

106106
class MyQTableItem(QTableWidgetItem):
@@ -168,7 +168,7 @@ def __init__(self, bidsfolder: Path, input_bidsmap: BidsMap, template_bidsmap: B
168168
"""The bidsmap from which new data type run-items are taken"""
169169
self.datasaved = datasaved
170170
"""True if data has been saved on disk"""
171-
self.dataformats = [dataformat.dataformat for dataformat in input_bidsmap.dataformats if input_bidsmap.dir(dataformat)]
171+
self.dataformats = [dataformat.dataformat for dataformat in input_bidsmap.dataformats if input_bidsmap.dir(dataformat) and dataformat.dataformat]
172172
self.bidsignore: list[str] = input_bidsmap.options['bidsignore']
173173
self.unknowndatatypes: list[str] = input_bidsmap.options['unknowntypes']
174174
self.ignoredatatypes: list[str] = input_bidsmap.options['ignoretypes']
@@ -1146,6 +1146,10 @@ def __init__(self, runitem: RunItem, bidsmap: BidsMap, template_bidsmap: BidsMap
11461146
meta_table.setToolTip(f"The key-value pair that will be appended to the (e.g. dcm2niix-produced) json sidecar file")
11471147

11481148
# Set up the events tables
1149+
self.events_settings = events_settings = self.setup_table(events_data.get('settings', []), 'events_settings')
1150+
events_settings.cellChanged.connect(self.events_settings2run)
1151+
events_settings.setToolTip(f"Settings for parsing the input table from the source file")
1152+
events_settings.setStyleSheet('QTableView::item {border-right: 1px solid #d6d9dc;}')
11491153
inspect_button = QPushButton('Source')
11501154
inspect_button.setToolTip('TODO')
11511155
inspect_button.clicked.connect(self.inspect_sourcefile)
@@ -1174,7 +1178,7 @@ def __init__(self, runitem: RunItem, bidsmap: BidsMap, template_bidsmap: BidsMap
11741178
events_columns.horizontalHeader().setVisible(True)
11751179
events_columns.setStyleSheet('QTableView::item {border-right: 1px solid #d6d9dc;}')
11761180
log_table_label = QLabel('Log data')
1177-
log_table = self.setup_table(events_data.get('log_table',[]), 'log_table', minsize=False)
1181+
self.log_table = log_table = self.setup_table(events_data.get('log_table',[]), 'log_table', minsize=False)
11781182
log_table.setShowGrid(True)
11791183
log_table.horizontalHeader().setVisible(True)
11801184
log_table.setToolTip(f"The raw stimulus presentation data that is parsed from the log file")
@@ -1197,12 +1201,16 @@ def __init__(self, runitem: RunItem, bidsmap: BidsMap, template_bidsmap: BidsMap
11971201
sourcebox.setLayout(layout1)
11981202

11991203
layout1_ = QVBoxLayout()
1200-
layout1_.addWidget(inspect_button, alignment=QtCore.Qt.AlignmentFlag.AlignRight)
1204+
layout1_header = QHBoxLayout()
1205+
layout1_header.addWidget(events_settings)
1206+
layout1_header.addStretch()
1207+
layout1_header.addWidget(inspect_button)
1208+
layout1_.addLayout(layout1_header)
12011209
layout1_.addWidget(log_table_label)
12021210
layout1_.addWidget(log_table)
1203-
self.events_inbox = events_inbox = QGroupBox(f"{self.dataformat} input data")
1204-
events_inbox.setSizePolicy(sizepolicy)
1205-
events_inbox.setLayout(layout1_)
1211+
self.events_inputbox = events_inputbox = QGroupBox(f"{self.dataformat} input data")
1212+
events_inputbox.setSizePolicy(sizepolicy)
1213+
events_inputbox.setLayout(layout1_)
12061214

12071215
self.arrow = arrow = QLabel()
12081216
arrow.setPixmap(QtGui.QPixmap(str(RIGHTARROW)).scaled(30, 30, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.TransformationMode.SmoothTransformation))
@@ -1251,10 +1259,10 @@ def __init__(self, runitem: RunItem, bidsmap: BidsMap, template_bidsmap: BidsMap
12511259
layout_tables.addWidget(arrow)
12521260
layout_tables.addWidget(bidsbox)
12531261
if events_data:
1254-
layout_tables.addWidget(events_inbox)
1262+
layout_tables.addWidget(events_inputbox)
12551263
layout_tables.addWidget(events_editbox)
12561264
layout_tables.addWidget(eventsbox)
1257-
events_inbox.hide()
1265+
events_inputbox.hide()
12581266
events_editbox.hide()
12591267

12601268
# Set-up buttons
@@ -1354,12 +1362,12 @@ def fill_table(self, table: MyQTable, data: list):
13541362
for j, item in enumerate(row):
13551363
itemvalue = item['value']
13561364

1357-
if tablename == 'bids' and isinstance(itemvalue, list):
1365+
if tablename in ('bids', 'events_settings') and isinstance(itemvalue, list):
13581366
dropdown = QComboBox()
13591367
dropdown.addItems(itemvalue[0:-1])
13601368
dropdown.setCurrentIndex(itemvalue[-1])
1361-
dropdown.currentIndexChanged.connect(partial(self.bids2run, i, j))
1362-
if j == 0:
1369+
dropdown.currentIndexChanged.connect(partial(self.bids2run if tablename=='bids' else self.events_settings2run, i, j))
1370+
if tablename=='bids' and j == 0:
13631371
dropdown.setToolTip(get_entityhelp(key))
13641372
table.setCellWidget(i, j, self.spacedwidget(dropdown))
13651373

@@ -1482,6 +1490,11 @@ def run2data(self) -> tuple:
14821490
[{'value': 'units/sec', 'editable': False}, {'value': runitem.events['time']['unit'], 'editable': True}],
14831491
[{'value': 'start', 'editable': False}, {'value': runitem.events['time']['start'], 'editable': True}]]
14841492

1493+
# Set up the data for the events settings
1494+
events_data['settings'] = []
1495+
for key, value in runitem.events.get('settings', {}).items():
1496+
events_data['settings'].append([{'value': key, 'editable': True}, {'value': value, 'editable': True}])
1497+
14851498
# Set up the data for the events conditions / row groups
14861499
events_data['rows'] = [[{'value': 'condition', 'editable': False}, {'value': 'output column', 'editable': False}]]
14871500
for condition in runitem.events.get('rows') or []:
@@ -1517,8 +1530,7 @@ def properties2run(self, rowindex: int, colindex: int):
15171530
if colindex == 1:
15181531
key = self.properties_table.item(rowindex, 0).text().strip()
15191532
value = self.properties_table.item(rowindex, 1).text().strip()
1520-
oldvalue = self.target_run.properties.get(key)
1521-
if oldvalue is None:
1533+
if (oldvalue := self.target_run.properties.get(key)) is None:
15221534
oldvalue = ''
15231535

15241536
# Only if cell was changed, update
@@ -1541,8 +1553,7 @@ def attributes2run(self, rowindex: int, colindex: int):
15411553
if colindex == 1:
15421554
key = self.attributes_table.item(rowindex, 0).text().strip()
15431555
value = self.attributes_table.item(rowindex, 1).text()
1544-
oldvalue = self.target_run.attributes.get(key)
1545-
if oldvalue is None:
1556+
if (oldvalue := self.target_run.attributes.get(key)) is None:
15461557
oldvalue = ''
15471558

15481559
# Only if cell was changed, update
@@ -1567,11 +1578,9 @@ def bids2run(self, rowindex: int, colindex: int):
15671578
if hasattr(self.bids_table.cellWidget(rowindex, 1), 'spacedwidget'):
15681579
dropdown = self.bids_table.cellWidget(rowindex, 1).spacedwidget
15691580
value = [dropdown.itemText(n) for n in range(len(dropdown))] + [dropdown.currentIndex()]
1570-
oldvalue = self.target_run.bids.get(key)
15711581
else:
15721582
value = self.bids_table.item(rowindex, 1).text().strip()
1573-
oldvalue = self.target_run.bids.get(key)
1574-
if oldvalue is None:
1583+
if (oldvalue := self.target_run.bids.get(key)) is None:
15751584
oldvalue = ''
15761585

15771586
# Only if cell was changed, update
@@ -1602,10 +1611,7 @@ def meta2run(self, rowindex: int, colindex: int):
16021611

16031612
key = self.meta_table.item(rowindex, 0).text().strip()
16041613
value = self.meta_table.item(rowindex, 1).text().strip()
1605-
oldvalue = self.target_run.meta.get(key)
1606-
if oldvalue is None:
1607-
oldvalue = ''
1608-
if value != oldvalue:
1614+
if value != (oldvalue := self.target_run.meta.get(key)):
16091615
# Replace the (dynamic) value
16101616
if '<<' not in value or '>>' not in value:
16111617
value = self.datasource.dynamicvalue(value, cleanup=False)
@@ -1663,6 +1669,34 @@ def events_time2run(self, rowindex: int, colindex: int):
16631669
self.fill_table(self.events_time, events_data['time'])
16641670
self.fill_table(self.events_table, events_data['table'])
16651671

1672+
def events_settings2run(self, rowindex: int, colindex: int):
1673+
"""Events settings table has been changed. Read the data from the event 'settings' table and, if OK, update the target run"""
1674+
1675+
key = self.events_settings.item(rowindex, 0).text().strip()
1676+
if hasattr(self.events_settings.cellWidget(rowindex, 1), 'spacedwidget'):
1677+
dropdown = self.events_settings.cellWidget(rowindex, 1).spacedwidget
1678+
value = [dropdown.itemText(n) for n in range(len(dropdown))] + [dropdown.currentIndex()]
1679+
else:
1680+
value = self.events_settings.item(rowindex, 1).text().strip()
1681+
if (oldvalue := self.target_run.events['settings'].get(key)) is None:
1682+
oldvalue = ''
1683+
1684+
# Only if cell was changed, update
1685+
if key and value != oldvalue:
1686+
# Validate user input against BIDS or replace the (dynamic) bids-value if it is a run attribute
1687+
if isinstance(value, str) and ('<<' not in value or '>>' not in value):
1688+
value = bids.sanitize(self.datasource.dynamicvalue(value))
1689+
self.events_settings.blockSignals(True)
1690+
self.events_settings.item(rowindex, 1).setText(value)
1691+
self.events_settings.blockSignals(False)
1692+
LOGGER.verbose(f"User sets events['settings']['{key}'] from '{oldvalue}' to '{value}' for {self.target_run}")
1693+
self.target_run.events['settings'][key] = value
1694+
1695+
# Refresh the log and events tables
1696+
_,_,_,_,events_data = self.run2data()
1697+
self.fill_table(self.log_table, events_data['log_table'])
1698+
self.fill_table(self.events_table, events_data['table'])
1699+
16661700
def events_rows2run(self, rowindex: int, colindex: int):
16671701
"""Events value has been changed. Read the data from the event 'rows' table and, if OK, update the target run"""
16681702

@@ -1693,7 +1727,7 @@ def events_rows2run(self, rowindex: int, colindex: int):
16931727
# Refresh the events tables, i.e. delete empty rows or add a new row if a key is defined on the last row
16941728
_,_,_,_,events_data = self.run2data()
16951729
self.fill_table(self.events_table, events_data['table'])
1696-
self.fill_table(self.events_rows, events_data['rows'])
1730+
self.fill_table(self.events_rows, events_data['rows'])
16971731

16981732
def events_columns2run(self, rowindex: int, colindex: int):
16991733
"""Events value has been changed. Read the data from the event 'columns' table and, if OK, update the target run"""
@@ -1732,7 +1766,7 @@ def edit_events(self):
17321766
self.sourcebox.hide()
17331767
self.arrow.hide()
17341768
self.bidsbox.hide()
1735-
self.events_inbox.show()
1769+
self.events_inputbox.show()
17361770
self.events_editbox.show()
17371771
self.edit_button.hide()
17381772
self.done_button.show()
@@ -1743,7 +1777,7 @@ def done_events(self):
17431777
self.sourcebox.show()
17441778
self.arrow.show()
17451779
self.bidsbox.show()
1746-
self.events_inbox.hide()
1780+
self.events_inputbox.hide()
17471781
self.events_editbox.hide()
17481782
self.edit_button.show()
17491783
self.done_button.hide()
@@ -1856,6 +1890,7 @@ def reset(self, refresh: bool=False):
18561890
self.fill_table(self.bids_table, bids_data)
18571891
self.fill_table(self.meta_table, meta_data)
18581892
if events_data:
1893+
self.fill_table(self.events_settings, events_data['settings'])
18591894
self.fill_table(self.events_time, events_data['time'])
18601895
self.fill_table(self.events_rows, events_data['rows'])
18611896
self.fill_table(self.events_columns, events_data['columns'])
@@ -1940,20 +1975,20 @@ def inspect_sourcefile(self, rowindex: int=None, colindex: int=None):
19401975
QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(str(Path(self.target_run.provenance).parent)))
19411976

19421977
@staticmethod
1943-
def spacedwidget(alignedwidget, align='left'):
1978+
def spacedwidget(childwidget, align='left'):
19441979
"""Place the widget in a QHBoxLayout and add a stretcher next to it. Return the widget as widget.spacedwidget"""
19451980

19461981
widget = QtWidgets.QWidget()
19471982
layout = QHBoxLayout()
19481983
if align != 'left':
19491984
layout.addStretch()
1950-
layout.addWidget(alignedwidget)
1985+
layout.addWidget(childwidget)
19511986
else:
1952-
layout.addWidget(alignedwidget)
1987+
layout.addWidget(childwidget)
19531988
layout.addStretch()
19541989
layout.setContentsMargins(0, 0, 0, 0)
19551990
widget.setLayout(layout)
1956-
widget.spacedwidget = alignedwidget
1991+
widget.spacedwidget = childwidget
19571992
return widget
19581993

19591994
def get_help(self):
@@ -2115,7 +2150,7 @@ def __init__(self, filename: Path):
21152150
if filename.name == 'DICOMDIR':
21162151
LOGGER.bcdebug(f"Getting DICOM fields from {filename} will raise dcmread error below if pydicom => v3.0")
21172152
text = str(dcmread(filename, force=True))
2118-
elif is_parfile(filename) or filename.suffix.lower() in ('.spar', '.txt', '.text', '.log'):
2153+
elif is_parfile(filename) or filename.suffix.lower() in ('.spar', '.txt', '.text', '.log', '.csv', '.tsv'):
21192154
text = filename.read_text()
21202155
elif filename.suffix.lower() == '.7':
21212156
try:

0 commit comments

Comments
 (0)