Repository: nilearn/nilearn

Issue:
[BUG] `func.gii` files not recognized in `interfaces.fmriprep.load_confounds`
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Operating system

- [X] Linux
- [ ] Mac
- [ ] Windows

### Operating system version

SUSE Linux Enterprise Server 12 SP5

### Python version

- [ ] 3.11
- [ ] 3.10
- [ ] 3.9
- [X] 3.8
- [ ] 3.7

### nilearn version

0.10.1.dev

### Expected behavior

```     python
fmriprep.load_confounds(
                gii_path,
                strategy=['motion', 'wm_csf', 'global_signal', 'high_pass'],
                motion='basic',
                demean=False)[0]
```
should return confounds. 


### Current behavior & error messages

This is what I got: Returns error message "'need fMRIprep output with extension func.gii'".

Problem: My file is an fmriprep output and proper gifti file according to [bids](https://bids-specification.readthedocs.io/en/stable/05-derivatives/03-imaging.html#discrete-surface-segmentations). This states that identifier 'hemi' comes before identifier 'space'.
This is the other way around here ('space' before 'hemi'): https://github.com/nilearn/nilearn/blob/787d662116a0808d074b0dd40c9b4c2498712819/nilearn/interfaces/fmriprep/load_confounds_utils.py#L19-L24


Suggestion: change to 
```python
img_file_patterns = {
    "aroma": "_desc-smoothAROMAnonaggr_bold",
    "nii.gz": "(_space-.*)?_desc-preproc_bold.nii.gz",
    "dtseries.nii": "(_space-.*)?_bold.dtseries.nii",
    "func.gii": "_hemi-[LR](_space-.*)?_bold.func.gii",
}
```

I think this confusion stems from an error in the fmriprep documentation https://github.com/nipreps/fmriprep/issues/3054

### Steps and code to reproduce bug

```python
from nilearn.interfaces import fmriprep
image_file='/user/data/derivatives/fmriprep/sub-s1/ses-s1/func/sub-s1_ses-s1_task-t1_acq-acq1_run-5_hemi-R_space-fsnative_bold.func.gii'
fmriprep.load_confounds(
                image_file,
                strategy=['motion', 'wm_csf', 'global_signal', 'high_pass'],
                motion='basic',
                demean=False)


```



Discussion:
Indeed hemi comes before space.

https://github.com/OpenNeuroDerivatives/ds000001-fmriprep/blob/2c7938fa2871889803a89cd408e1f7db5a1a695b/sub-01/func/sub-01_task-balloonanalogrisktask_run-1_hemi-R_space-fsaverage5_bold.func.gii

Will open a PR to fix

Resolving patch:
diff --git a/nilearn/_utils/data_gen.py b/nilearn/_utils/data_gen.py
--- a/nilearn/_utils/data_gen.py
+++ b/nilearn/_utils/data_gen.py
@@ -506,6 +506,27 @@ def write_fake_fmri_data_and_design(shapes,
     return mask_file, fmri_files, design_files
 
 
+def write_fake_bold_gifti(file_path):
+    """Generate a gifti image and write it to disk.
+
+    Note this only generates an empty file for now.
+
+    Parameters
+    ----------
+    file_path : :obj:`str`
+        Output file path.
+
+    Returns
+    -------
+    file_path : :obj:`str`
+        Output file path.
+
+    """
+    Path(file_path).parent.mkdir(parents=True, exist_ok=True)
+    Path(file_path).touch()
+    return file_path
+
+
 def write_fake_bold_img(file_path,
                         shape,
                         affine=np.eye(4),
@@ -734,13 +755,17 @@ def basic_confounds(length, random_state=0):
     -------
     confounds : :obj:`pandas.DataFrame`.
         Basic confounds.
-        This DataFrame will have six columns: "RotX", "RotY", "RotZ", "X", "Y",
-        and "Z".
+        This DataFrame will have 9 columns:
+        'csf', 'white_matter', 'global_signal'
+        'rot_x', 'rot_y', 'rot_z',
+        'trans_x', 'trans_y', 'trans_z'.
 
     """
     rand_gen = check_random_state(random_state)
-    columns = ['RotX', 'RotY', 'RotZ', 'X', 'Y', 'Z']
-    data = rand_gen.rand(length, 6)
+    columns = ['csf', 'white_matter', 'global_signal',
+               'rot_x', 'rot_y', 'rot_z',
+               'trans_x', 'trans_y', 'trans_z']
+    data = rand_gen.rand(length, len(columns))
     confounds = pd.DataFrame(data, columns=columns)
     return confounds
 
@@ -1097,7 +1122,8 @@ def _mock_bids_derivatives(
     n_voxels,
     rand_gen,
 ):
-    """Create a fake raw :term:`bids<BIDS>` dataset directory with dummy files.
+    """Create a fake derivatives :term:`bids<BIDS>` dataset directory \
+       with dummy files.
 
     Parameters
     ----------
@@ -1355,11 +1381,13 @@ def _write_bids_derivative_func(
 ):
     """Create BIDS functional derivative and confounds files.
 
-    Files created come with two spaces and descriptions.
+    Nifti files created come with two spaces and descriptions.
     Spaces are: 'MNI' and 'T1w'.
     Descriptions are: 'preproc' and :term:`fMRIPrep`.
     Only space 'T1w' include both descriptions.
 
+    Gifti files are in "fsaverage5" space for both hemispheres.
+
     Parameters
     ----------
     func_path : :obj:`Path`
@@ -1416,3 +1444,14 @@ def _write_bids_derivative_func(
                 fields=fields, entities_to_include=entities_to_include
             )
             write_fake_bold_img(bold_path, shape=shape, random_state=rand_gen)
+
+    fields["entities"]["space"] = "fsaverage5"
+    fields["extension"] = "func.gii"
+    fields["entities"].pop("desc")
+    for hemi in ["L", "R"]:
+        fields["entities"]["hemi"] = hemi
+        gifti_path = func_path / _create_bids_filename(
+            fields=fields,
+            entities_to_include=entities_to_include
+        )
+        write_fake_bold_gifti(gifti_path)
diff --git a/nilearn/interfaces/bids/_utils.py b/nilearn/interfaces/bids/_utils.py
--- a/nilearn/interfaces/bids/_utils.py
+++ b/nilearn/interfaces/bids/_utils.py
@@ -186,7 +186,7 @@ def _bids_entities():
             "echo",
             "part",
         ],
-        "derivatives": ["space", "res", "den", "desc"],
+        "derivatives": ["hemi", "space", "res", "den", "desc"],
     }
 
 
diff --git a/nilearn/interfaces/fmriprep/load_confounds_utils.py b/nilearn/interfaces/fmriprep/load_confounds_utils.py
--- a/nilearn/interfaces/fmriprep/load_confounds_utils.py
+++ b/nilearn/interfaces/fmriprep/load_confounds_utils.py
@@ -19,7 +19,7 @@
     "aroma": "_desc-smoothAROMAnonaggr_bold",
     "nii.gz": "(_space-.*)?_desc-preproc_bold.nii.gz",
     "dtseries.nii": "(_space-.*)?_bold.dtseries.nii",
-    "func.gii": "(_space-.*)?_hemi-[LR]_bold.func.gii",
+    "func.gii": "_hemi-[LR](_space-.*)?_bold.func.gii",
 }
 
 img_file_error = {


Test patch:
diff --git a/nilearn/_utils/tests/test_data_gen.py b/nilearn/_utils/tests/test_data_gen.py
--- a/nilearn/_utils/tests/test_data_gen.py
+++ b/nilearn/_utils/tests/test_data_gen.py
@@ -209,8 +209,8 @@ def test_fake_bids_derivatives_with_session_and_runs(
         )
 
     all_files = list(bids_path.glob("derivatives/sub-*/ses-*/*/*"))
-    # per subject: (1 confound + 3 bold) per run per session
-    n_derivatives_files_expected = n_sub * (4 * sum(n_runs) * n_ses)
+    # per subject: (1 confound + 3 bold + 2 gifti) per run per session
+    n_derivatives_files_expected = n_sub * (6 * sum(n_runs) * n_ses)
     assert len(all_files) == n_derivatives_files_expected
 
 
@@ -373,9 +373,10 @@ def test_fake_bids_extra_raw_entity(tmpdir):
             )
 
     all_files = list(bids_path.glob("derivatives/sub-*/ses-*/*/*"))
-    # per subject: (1 confound + 3 bold) per run per session per entity
+    # per subject: (1 confound + 3 bold + 2 gifti)
+    #              per run per session per entity
     n_derivatives_files_expected = (
-        n_sub * (4 * sum(n_runs) * n_ses) * len(entities["acq"])
+        n_sub * (6 * sum(n_runs) * n_ses) * len(entities["acq"])
     )
     assert len(all_files) == n_derivatives_files_expected
 
@@ -415,10 +416,10 @@ def test_fake_bids_extra_derivative_entity(tmpdir):
     all_files = list(bids_path.glob("derivatives/sub-*/ses-*/*/*"))
     # per subject:
     # 1 confound per run per session
-    # + (3 bold) per run per session per entity
+    # + (3 bold + 2 gifti) per run per session per entity
     n_derivatives_files_expected = n_sub * (
         1 * sum(n_runs) * n_ses
-        + 3 * sum(n_runs) * n_ses * len(entities["res"])
+        + 5 * sum(n_runs) * n_ses * len(entities["res"])
     )
     assert len(all_files) == n_derivatives_files_expected
 
diff --git a/nilearn/interfaces/fmriprep/tests/test_load_confounds.py b/nilearn/interfaces/fmriprep/tests/test_load_confounds.py
--- a/nilearn/interfaces/fmriprep/tests/test_load_confounds.py
+++ b/nilearn/interfaces/fmriprep/tests/test_load_confounds.py
@@ -8,7 +8,9 @@
 from scipy.stats import pearsonr
 from sklearn.preprocessing import scale
 
+from nilearn._utils.data_gen import create_fake_bids_dataset
 from nilearn._utils.fmriprep_confounds import _to_camel_case
+from nilearn.interfaces.bids import get_bids_files
 from nilearn.interfaces.fmriprep import load_confounds
 from nilearn.interfaces.fmriprep.load_confounds import (
     _check_strategy,
@@ -577,3 +579,31 @@ def test_inputs(tmp_path, image_type):
     else:
         conf, _ = load_confounds(files)
     assert len(conf) == 2
+
+
+def test_load_confounds_for_gifti(tmpdir):
+    """Ensure that confounds are found for gifti files.
+
+    Regression test for
+    https://github.com/nilearn/nilearn/issues/3817
+    Wrong order of space and hemi entity in filename pattern
+    lead to confounds not being found.
+    """
+    bids_path = create_fake_bids_dataset(base_dir=tmpdir, n_sub=1, n_ses=1)
+    selection = get_bids_files(
+        bids_path / "derivatives",
+        sub_label="01",
+        file_tag="bold",
+        file_type="func.gii",
+        filters=[("ses", "01"),
+                 ("task", "main"),
+                 ("run", "01"),
+                 ("hemi", "L")],
+        sub_folder=True,
+    )
+    assert len(selection) == 1
+    load_confounds(
+        selection[0],
+        strategy=['motion', 'wm_csf'],
+        motion='basic',
+        demean=False)