Repository: nipy/nipype

Issue:
CSVReader does not handle quoted fields or tsv files
### Summary
I want to add functionality to the CSVReader so it can handle tsv files. While checking that out, I noticed that we're just splitting on `,`, which is not ideal.

### Actual behavior
Parsing CSV files with fields that contain quoted text with ',' characters does not work correctly. TSV files are not supported.


### Expected behavior
Quoted fields should be correctly parsed if they contain the delimiter.

### How to replicate the behavior
Run the script below.

### Script/Workflow details

```
from nipype.interfaces import utility

reader = utility.CSVReader()

lines = ["foo,\"hello,world\",300.1\n"]

with open("testcsv.csv", "w") as fid:
    fid.writelines(lines)
    fid.flush()

reader.inputs.in_file = "testcsv.csv"
out = reader.run()

assert out.outputs.column_1 == ["hello, world"]
```

### Platform details:

<!-- Please run the following code from your shell and place the output between the triple ticks, below.
python -c "import nipype; from pprint import pprint; pprint(nipype.get_info())"
-->

```
{'commit_hash': 'c46a957bd',
 'commit_source': 'repository',
 'networkx_version': '3.2.1',
 'nibabel_version': '5.2.1',
 'nipype_version': '1.8.7.dev0',
 'numpy_version': '1.26.4',
 'pkg_path': '/home/USER/Desktop/nipype/nipype',
 'scipy_version': '1.12.0',
 'sys_executable': '/home/USER/Desktop/nipype/.venv/bin/python',
 'sys_platform': 'linux',
 'sys_version': '3.9.18 (main, Feb  1 2024, 11:48:51) \n[GCC 11.4.0]',
 'traits_version': '6.3.2'}

```

### Execution environment
- My python environment outside container


Discussion:

Patch:
diff --git a/nipype/interfaces/utility/csv.py b/nipype/interfaces/utility/csv.py
index 3bfc46203..da09e425f 100644
--- a/nipype/interfaces/utility/csv.py
+++ b/nipype/interfaces/utility/csv.py
@@ -2,6 +2,7 @@
 # vi: set ft=python sts=4 ts=4 sw=4 et:
 """CSV Handling utilities
 """
+import csv
 from ..base import traits, TraitedSpec, DynamicTraitedSpec, File, BaseInterface
 from ..io import add_traits
 
@@ -13,6 +14,7 @@ class CSVReaderInputSpec(DynamicTraitedSpec, TraitedSpec):
     header = traits.Bool(
         False, usedefault=True, desc="True if the first line is a column header"
     )
+    delimiter = traits.String(",", usedefault=True, desc="Delimiter to use.")
 
 
 class CSVReader(BaseInterface):
@@ -52,14 +54,11 @@ class CSVReader(BaseInterface):
             outputs[key].append(value)
         return outputs
 
-    def _parse_line(self, line):
-        line = line.replace("\n", "")
-        entry = [x.strip() for x in line.split(",")]
-        return entry
-
     def _get_outfields(self):
         with open(self.inputs.in_file) as fid:
-            entry = self._parse_line(fid.readline())
+            reader = csv.reader(fid, delimiter=self.inputs.delimiter)
+
+            entry = next(reader)
             if self.inputs.header:
                 self._outfields = tuple(entry)
             else:
@@ -82,10 +81,10 @@ class CSVReader(BaseInterface):
         for key in self._outfields:
             outputs[key] = []  # initialize outfields
         with open(self.inputs.in_file) as fid:
-            for line in fid.readlines():
+            reader = csv.reader(fid, delimiter=self.inputs.delimiter)
+            for entry in reader:
                 if self.inputs.header and isHeader:  # skip header line
                     isHeader = False
                     continue
-                entry = self._parse_line(line)
                 outputs = self._append_entry(outputs, entry)
         return outputs


Test patch:
diff --git a/nipype/interfaces/utility/tests/test_auto_CSVReader.py b/nipype/interfaces/utility/tests/test_auto_CSVReader.py
index be24c59eb..a96a4d11b 100644
--- a/nipype/interfaces/utility/tests/test_auto_CSVReader.py
+++ b/nipype/interfaces/utility/tests/test_auto_CSVReader.py
@@ -4,6 +4,9 @@ from ..csv import CSVReader
 
 def test_CSVReader_inputs():
     input_map = dict(
+        delimiter=dict(
+            usedefault=True,
+        ),
         header=dict(
             usedefault=True,
         ),
diff --git a/nipype/interfaces/utility/tests/test_csv.py b/nipype/interfaces/utility/tests/test_csv.py
index 923af9d83..8a8f06e50 100644
--- a/nipype/interfaces/utility/tests/test_csv.py
+++ b/nipype/interfaces/utility/tests/test_csv.py
@@ -26,3 +26,44 @@ def test_csvReader(tmpdir):
                 assert out.outputs.column_0 == ["foo", "bar", "baz"]
                 assert out.outputs.column_1 == ["hello", "world", "goodbye"]
                 assert out.outputs.column_2 == ["300.1", "5", "0.3"]
+
+
+def test_csvReader_quoted(tmpdir):
+    header = "files,labels,erosion\n"
+    lines = ['foo,"hello, world",300.1\n']
+
+    name = tmpdir.join("testfile.csv").strpath
+    with open(name, "w") as fid:
+        reader = utility.CSVReader()
+        fid.writelines(lines)
+        fid.flush()
+        reader.inputs.in_file = name
+        out = reader.run()
+
+        assert out.outputs.column_0 == ["foo"]
+        assert out.outputs.column_1 == ["hello, world"]
+        assert out.outputs.column_2 == ["300.1"]
+
+
+def test_csvReader_tabs(tmpdir):
+    header = "files\tlabels\terosion\n"
+    lines = ["foo\thello\t300.1\n", "bar\tworld\t5\n", "baz\tgoodbye\t0.3\n"]
+    for x in range(2):
+        name = tmpdir.join("testfile.csv").strpath
+        with open(name, "w") as fid:
+            reader = utility.CSVReader(delimiter="\t")
+            if x % 2 == 0:
+                fid.write(header)
+                reader.inputs.header = True
+            fid.writelines(lines)
+            fid.flush()
+            reader.inputs.in_file = name
+            out = reader.run()
+            if x % 2 == 0:
+                assert out.outputs.files == ["foo", "bar", "baz"]
+                assert out.outputs.labels == ["hello", "world", "goodbye"]
+                assert out.outputs.erosion == ["300.1", "5", "0.3"]
+            else:
+                assert out.outputs.column_0 == ["foo", "bar", "baz"]
+                assert out.outputs.column_1 == ["hello", "world", "goodbye"]
+                assert out.outputs.column_2 == ["300.1", "5", "0.3"]