Repository: nilearn/nilearn

Issue:
[BUG] first_level_from_bids confused by "res" filter when looking for confounds.tsv
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Operating system

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

### Operating system version

CentOS Linux release 7.6.1810

### Python version

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

### nilearn version

0.10.0

### Expected behavior

When running the command below one can expect the model_confounds to be found in a bids-valid directory.

(models,
    models_run_imgs,
    models_events,
    models_confounds,) = first_level_from_bids(data_dir, task_label, derivatives_folder=derivatives_folder,space_label=space_label,img_filters=[("desc", "preproc"),("res","2")], smoothing_fwhm=6, slice_time_ref=0.481, n_jobs=-1, signal_scaling=(0,1))

### Current behavior & error messages

This is what I got:
models_confounds were al None.

What I did to fix it was simply add an extra line in /nilearn/glm/first_level/first_level.py for removing "res" from the filters:

    supported_filters.remove("desc")
    **supported_filters.remove("res")**
    
    filters = _make_bids_files_filter(
        task_label=task_label,
        supported_filters=supported_filters,
        extra_filter=img_filters,
        verbose=verbose,
    )
    confounds = get_bids_files(
        derivatives_path,
        modality_folder="func",
        file_tag="desc-confounds*",
        file_type="tsv",
        sub_label=sub_label,
        filters=filters,
    )

### Steps and code to reproduce bug

```python
# Paste your code here


```


Comments:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ee5f62336..cc165dd6e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -38,7 +38,6 @@ files: |
 
 exclude: |
     (?x)^(
-        doc/changes/(names|latest).rst
         | doc/glm/first_level_model\\.rst
         | introduction\\.rst
         | maintenance\\.rst
diff --git a/nilearn/_utils/data_gen.py b/nilearn/_utils/data_gen.py
index e88b09dd3..003d0f5eb 100644
--- a/nilearn/_utils/data_gen.py
+++ b/nilearn/_utils/data_gen.py
@@ -1399,6 +1399,11 @@ def _write_bids_derivative_func(
 
     shape = [n_voxels, n_voxels, n_voxels, n_time_points]
 
+    entities_to_include = [
+        *_bids_entities()["raw"],
+        *_bids_entities()["derivatives"]
+    ]
+
     for space in ("MNI", "T1w"):
         for desc in ("preproc", "fmriprep"):
             # Only space 'T1w' include both descriptions.
@@ -1408,10 +1413,6 @@ def _write_bids_derivative_func(
             fields["entities"]["space"] = space
             fields["entities"]["desc"] = desc
 
-            entities_to_include = [
-                *_bids_entities()["raw"], *_bids_entities()["derivatives"]
-            ]
-
             bold_path = func_path / _create_bids_filename(
                 fields=fields, entities_to_include=entities_to_include
             )
diff --git a/nilearn/glm/first_level/first_level.py b/nilearn/glm/first_level/first_level.py
index c40dd40f1..bda0bd566 100644
--- a/nilearn/glm/first_level/first_level.py
+++ b/nilearn/glm/first_level/first_level.py
@@ -1334,16 +1334,9 @@ def _get_confounds(
         List of fullpath to the confounds.tsv files
 
     """
-    supported_filters = (
-        _bids_entities()["raw"]
-        + _bids_entities()["derivatives"]
-    )
-    # confounds use a desc-confounds,
-    # so we must remove desc if it was passed as a filter
-    supported_filters.remove("desc")
     filters = _make_bids_files_filter(
         task_label=task_label,
-        supported_filters=supported_filters,
+        supported_filters=_bids_entities()["raw"],
         extra_filter=img_filters,
         verbose=verbose,
     )
diff --git a/pyproject.toml b/pyproject.toml
index 28f52010f..81d6d398f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -181,5 +181,5 @@ doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS"
 junit_family = "xunit2"
 
 [tool.codespell]
-skip = "./.git,plotly-gl3d-latest.min.js,jquery.min.js,localizer_behavioural.tsv,.mypy_cache,env,venv"
+skip = "./.git,plotly-gl3d-latest.min.js,jquery.min.js,localizer_behavioural.tsv,.mypy_cache,env,venv,./doc/auto_examples"
 ignore-words = ".github/codespell_ignore_words.txt"


Test patch:
diff --git a/doc/changes/latest.rst b/doc/changes/latest.rst
index 66430988a..5c9e7f486 100644
--- a/doc/changes/latest.rst
+++ b/doc/changes/latest.rst
@@ -11,6 +11,7 @@ NEW
 Fixes
 -----
 
+- Fix bug in :func:`~glm.first_level.first_level_from_bids` that returned no confound files if the corresponding bold files contained derivatives BIDS entities (:gh:`3742` by `Rémi Gau`_).
 - :bdg-dark:`Code` Fix bug where the `cv_params_` attribute of fitter Decoder objects sometimes had missing entries if `grid_param` is a sequence of dicts with different keys (:gh:`3733` by `Michelle Wang`_).
 
 Enhancements
diff --git a/nilearn/glm/tests/test_first_level.py b/nilearn/glm/tests/test_first_level.py
index 26eaaece7..8b7f9dd95 100644
--- a/nilearn/glm/tests/test_first_level.py
+++ b/nilearn/glm/tests/test_first_level.py
@@ -1112,10 +1112,11 @@ def test_first_level_from_bids(tmp_path,
         slice_time_ref=None,
     )
 
-    assert len(models) == n_sub
-    assert len(models) == len(m_imgs)
-    assert len(models) == len(m_events)
-    assert len(models) == len(m_confounds)
+    _check_output_first_level_from_bids(n_sub,
+                                        models,
+                                        m_imgs,
+                                        m_events,
+                                        m_confounds)
 
     n_imgs_expected = n_ses * n_runs[task_index]
 
@@ -1144,10 +1145,11 @@ def test_first_level_from_bids_select_one_run_per_session(bids_dataset):
         slice_time_ref=None,
     )
 
-    assert len(models) == n_sub
-    assert len(models) == len(m_imgs)
-    assert len(models) == len(m_events)
-    assert len(models) == len(m_confounds)
+    _check_output_first_level_from_bids(n_sub,
+                                        models,
+                                        m_imgs,
+                                        m_events,
+                                        m_confounds)
 
     n_imgs_expected = n_ses
     assert len(m_imgs[0]) == n_imgs_expected
@@ -1165,10 +1167,11 @@ def test_first_level_from_bids_select_all_runs_of_one_session(bids_dataset):
         slice_time_ref=None,
     )
 
-    assert len(models) == n_sub
-    assert len(models) == len(m_imgs)
-    assert len(models) == len(m_events)
-    assert len(models) == len(m_confounds)
+    _check_output_first_level_from_bids(n_sub,
+                                        models,
+                                        m_imgs,
+                                        m_events,
+                                        m_confounds)
 
     n_imgs_expected = n_runs[0]
     assert len(m_imgs[0]) == n_imgs_expected
@@ -1217,13 +1220,36 @@ def test_first_level_from_bids_several_labels_per_entity(tmp_path, entity):
         img_filters=[("desc", "preproc"), (entity, "A")],
         slice_time_ref=None,
     )
+
+    _check_output_first_level_from_bids(n_sub,
+                                        models,
+                                        m_imgs,
+                                        m_events,
+                                        m_confounds)
+    n_imgs_expected = n_ses * n_runs[0]
+    assert len(m_imgs[0]) == n_imgs_expected
+
+
+def _check_output_first_level_from_bids(n_sub,
+                                        models,
+                                        m_imgs,
+                                        m_events,
+                                        m_confounds):
     assert len(models) == n_sub
+    assert all(isinstance(model, FirstLevelModel) for model in models)
     assert len(models) == len(m_imgs)
+    for imgs in m_imgs:
+        assert isinstance(imgs, list)
+        assert all(Path(img_).exists() for img_ in imgs)
     assert len(models) == len(m_events)
+    for events in m_events:
+        assert isinstance(events, list)
+        assert all(isinstance(event_, pd.DataFrame) for event_ in events)
     assert len(models) == len(m_confounds)
-
-    n_imgs_expected = n_ses * n_runs[0]
-    assert len(m_imgs[0]) == n_imgs_expected
+    for confounds in m_confounds:
+        assert isinstance(confounds, list)
+        assert all(isinstance(confound_, pd.DataFrame)
+                   for confound_ in confounds)
 
 
 def test_first_level_from_bids_with_subject_labels(bids_dataset):
diff --git a/nilearn/plotting/tests/test_matrix_plotting.py b/nilearn/plotting/tests/test_matrix_plotting.py
index 54c2158f3..d289fe534 100644
--- a/nilearn/plotting/tests/test_matrix_plotting.py
+++ b/nilearn/plotting/tests/test_matrix_plotting.py
@@ -96,7 +96,7 @@ def test_sanitize_reorder_error(reorder):
         else:
             param_to_print.append(str(item))
     with pytest.raises(ValueError,
-                       match=("Parameter reorder needs to be one of:\n"
+                       match=("Parameter reorder needs to be one of:\\n"
                               f"{', '.join(param_to_print)}.")):
         _sanitize_reorder(reorder)