Repository: physiopy/phys2bids

Issue:
Automatically generate BIDS compulsory agnostic files and update them if they already exist
comes from #185. When a heuristic file is provided and data is bidsdifyed agnostic files (README, participants.tsv and dataset_description.json) should be generated
## Detailed Description
When a heuristic file is provided and data is bidsdifyed agnostic files should be generated:

- [ ] dataset_description.json
       Fields
       - Name (Required)
       - BIDSVersion (Required)
       - DatasetType (Recommended)
       - License (Recommended)

- [ ] Participants.tsv
       Fields
       - participant_id (Required)
       - age (Recommended)
       - sex (Recommended)
       - handedness (Recommended)

- [ ] README

## Context / Motivation
comply BIDS 1.4 standard

## Possible Implementation
Create one function per file where we check if the file exists in the dataset dir, in case it doesn't generate it and cover at least the required fields, preferably also the  recommended  and add them to the pipeline.


Discussion:

Patch:
diff --git a/phys2bids/bids.py b/phys2bids/bids.py
index a2782fa..b71d958 100644
--- a/phys2bids/bids.py
+++ b/phys2bids/bids.py
@@ -1,8 +1,10 @@
 import os
 import logging
-
+from csv import reader
 from pathlib import Path
 
+import yaml
+
 from phys2bids import utils
 
 LGR = logging.getLogger(__name__)
@@ -170,3 +172,73 @@ def use_heuristic(heur_file, sub, ses, filename, outdir, record_label=''):
     heurpath = os.path.join(fldr, f'{name}physio')
 
     return heurpath
+
+
+def participants_file(outdir, yml, sub):
+    """
+    Create participants.tsv file if it does not exist.
+    If it exists and the subject is missing, then add it.
+    Otherwise, do nothing.
+
+    Parameters
+    ----------
+    outdir: path
+        Full path to the output directory.
+    yml: path
+        Full path to the yaml file.
+    sub: str
+        Subject ID.
+
+    """
+    LGR.info('Updating participants.tsv ...')
+    file_path = os.path.join(outdir, 'participants.tsv')
+    if not os.path.exists(file_path):
+        LGR.warning('phys2bids could not find participants.tsv')
+        # Read yaml info if file exists
+        if '.yml' in yml and os.path.exists(yml):
+            LGR.info('Using yaml data to populate participants.tsv')
+            with open(yml) as f:
+                yaml_data = yaml.load(f, Loader=yaml.FullLoader)
+            p_id = f'sub-{sub}'
+            p_age = yaml_data['participant']['age']
+            p_sex = yaml_data['participant']['sex']
+            p_handedness = yaml_data['participant']['handedness']
+        else:
+            LGR.info('No yaml file was provided. Using phys2bids data to '
+                     'populate participants.tsv')
+            # Fill in with data from phys2bids
+            p_id = f'sub-{sub}'
+            p_age = 'n/a'
+            p_sex = 'n/a'
+            p_handedness = 'n/a'
+
+        # Write to participants.tsv file
+        header = ['participant_id', 'age', 'sex', 'handedness']
+        utils.append_list_as_row(file_path, header)
+
+        participants_data = [p_id, p_age, p_sex, p_handedness]
+        utils.append_list_as_row(file_path, participants_data)
+
+    else:  # If participants.tsv exists only update when subject is not there
+        LGR.info('phys2bids found participants.tsv. Updating if needed...')
+        # Find participant_id column in header
+        pf = open(file_path, 'r')
+        header = pf.readline().split("\t")
+        header_length = len(header)
+        pf.close()
+        p_id_idx = header.index('participant_id')
+
+        # Check if subject is already in the file
+        sub_exists = False
+        with open(file_path) as pf:
+            tsvreader = reader(pf, delimiter="\t")
+            for line in tsvreader:
+                if sub in line[p_id_idx]:
+                    sub_exists = True
+                    break
+        # Only append to file if subject is not in the file
+        if not sub_exists:
+            LGR.info(f'Appending subjet sub-{sub} to participants.tsv ...')
+            participants_data = ['n/a'] * header_length
+            participants_data[p_id_idx] = f'sub-{sub}'
+            utils.append_list_as_row(file_path, participants_data)
diff --git a/phys2bids/cli/run.py b/phys2bids/cli/run.py
index 8b9118c..1aa0f2d 100644
--- a/phys2bids/cli/run.py
+++ b/phys2bids/cli/run.py
@@ -125,6 +125,12 @@ def _get_parser():
                           type=str,
                           help='full path to store channels plot ',
                           default='')
+    optional.add_argument('-yml', '--participant-yml',
+                          dest='yml',
+                          type=str,
+                          help='full path to file with info needed to generate '
+                               'participant.tsv file ',
+                          default='')
     optional.add_argument('-debug', '--debug',
                           dest='debug',
                           action='store_true',
diff --git a/phys2bids/heuristics/participant.yml b/phys2bids/heuristics/participant.yml
new file mode 100644
index 0000000..a509d8c
--- /dev/null
+++ b/phys2bids/heuristics/participant.yml
@@ -0,0 +1,5 @@
+participant:
+    participant_id: # Required
+    age: n/a
+    sex: n/a
+    handedness: n/a
\ No newline at end of file
diff --git a/phys2bids/phys2bids.py b/phys2bids/phys2bids.py
index 06d74fd..5745d05 100644
--- a/phys2bids/phys2bids.py
+++ b/phys2bids/phys2bids.py
@@ -34,7 +34,7 @@ from copy import deepcopy
 from numpy import savetxt
 
 from phys2bids import utils, viz, _version
-from phys2bids.bids import bidsify_units, use_heuristic
+from phys2bids.bids import bidsify_units, use_heuristic, participants_file
 from phys2bids.cli.run import _get_parser
 from phys2bids.physio_obj import BlueprintOutput
 
@@ -112,7 +112,8 @@ def print_json(outfile, samp_freq, time_offset, ch_name):
 
 def phys2bids(filename, info=False, indir='.', outdir='.', heur_file=None,
               sub=None, ses=None, chtrig=0, chsel=None, num_timepoints_expected=0,
-              tr=1, thr=None, ch_name=[], chplot='', debug=False, quiet=False):
+              tr=1, thr=None, ch_name=[], chplot='', debug=False, quiet=False,
+              yml=''):
     """
     Main workflow of phys2bids.
 
@@ -264,6 +265,10 @@ def phys2bids(filename, info=False, indir='.', outdir='.', heur_file=None,
 
     if heur_file and sub:
         LGR.info(f'Preparing BIDS output using {heur_file}')
+        # Generate participants.tsv file if it doesn't exist already.
+        # Update the file if the subject is not in the file.
+        # Do not update if the subject is already in the file.
+        participants_file(outdir, yml, sub)
     elif heur_file and not sub:
         LGR.warning('While "-heur" was specified, option "-sub" was not.\n'
                     'Skipping BIDS formatting.')
diff --git a/phys2bids/utils.py b/phys2bids/utils.py
index bb59547..4705196 100644
--- a/phys2bids/utils.py
+++ b/phys2bids/utils.py
@@ -4,6 +4,7 @@ import json
 import logging
 import os
 import sys
+from csv import writer
 from pathlib import Path
 
 LGR = logging.getLogger(__name__)
@@ -280,3 +281,12 @@ def load_heuristic(heuristic):
         except Exception as exc:
             raise ImportError(f'Failed to import heuristic {heuristic}: {exc}')
     return mod
+
+
+def append_list_as_row(file_name, list_of_elem):
+    # Open file in append mode
+    with open(file_name, 'a+', newline='') as write_obj:
+        # Create a writer object from csv module
+        csv_writer = writer(write_obj, delimiter='\t')
+        # Add contents of list as last row in the csv file
+        csv_writer.writerow(list_of_elem)
diff --git a/requirements.txt b/requirements.txt
index 165bf7c..3118cec 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
 numpy>=1.9.3
 matplotlib>=3.1.1
+PyYAML
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index d184c71..7da2fe9 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -24,6 +24,7 @@ python_requires = >=3.6.1
 install_requires =
     numpy >=1.9.3
     matplotlib >=3.1.1
+    PyYAML
 tests_require =
     pytest >=3.6
 test_suite = pytest
@@ -47,8 +48,8 @@ test =
 interfaces = 
     %(acq)s
 all =
-    %(interfaces)s
     %(doc)s
+    %(interfaces)s
     %(style)s
     %(test)s
 


Test patch:
diff --git a/phys2bids/tests/test_integration.py b/phys2bids/tests/test_integration.py
index cb50eda..9d28511 100644
--- a/phys2bids/tests/test_integration.py
+++ b/phys2bids/tests/test_integration.py
@@ -4,6 +4,7 @@ import math
 import os
 import re
 import subprocess
+from csv import reader
 from pkg_resources import resource_filename
 
 from phys2bids._version import get_versions
@@ -336,6 +337,19 @@ def test_integration_heuristic(samefreq_short_txt_file):
     assert math.isclose(json_data['StartTime'], -189.6,)
     assert json_data['Columns'] == ['time', 'RESP - RSP100C', 'MR TRIGGER - Custom, HLT100C - A 5']
 
+    # Check that participant.tsv gets updated
+    phys2bids(filename=test_full_path, chtrig=test_chtrig, outdir=test_outdir,
+            num_timepoints_expected=test_ntp, tr=test_tr, thr=test_thr, sub='002',
+            ses='01', heur_file=test_heur)
+
+    counter = 0
+    subject_list = ['participant_id', '006', '002']
+    with open(os.path.join(test_path, 'participants.tsv')) as pf:
+        tsvreader = reader(pf, delimiter="\t")
+        for line in tsvreader:
+            assert subject_list[counter] in line[0]
+            counter += 1
+
     # Remove generated files
     for filename in glob.glob(os.path.join(test_path, 'phys2bids*')):
         os.remove(filename)