Repository: nilearn/nilearn

Issue:
Avoid duplicate subject IDs being passed to `first_level_from_bids`
It might be useful to add the following check for the `sub_labels` parameter in `glm.first_level.first_level_from_bids`. See original comment:

You probably want to avoid duplicated values in case users pass twice the same subject by mistake, like the example below:

```python
first_level_from_bids(bids_path, 'main',
                      sub_labels=["01", "01"],
                      space_label='MNI',
                      img_filters=[('desc', 'preproc')])  
```

_Originally posted by @Remi-Gau in https://github.com/nilearn/nilearn/pull/3351#discussion_r1118089725_
            


Discussion:

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
@@ -1,10 +1,16 @@
 """
 Data generation utilities
 """
+
+from __future__ import annotations
+
+import itertools
 import json
-import os
 import string
 
+from pathlib import Path
+from typing import Any
+
 import numpy as np
 import pandas as pd
 import scipy.linalg
@@ -15,6 +21,7 @@
 
 from nilearn import datasets, image, maskers, masking
 from nilearn._utils import as_ndarray, logger
+from nilearn.interfaces.bids._utils import _bids_entities, _check_bids_label
 
 
 def generate_mni_space_img(n_scans=1, res=30, random_state=0, mask_dilation=2):
@@ -687,7 +694,7 @@ def generate_group_sparse_gaussian_graphs(n_subjects=5,
 
 
 def basic_paradigm(condition_names_have_spaces=False):
-    """Generate basic paradigm
+    """Generate basic paradigm.
 
     Parameters
     ----------
@@ -744,77 +751,118 @@ def basic_confounds(length, random_state=0):
     return confounds
 
 
-def create_fake_bids_dataset(base_dir='',
-                             n_sub=10,
-                             n_ses=2,
-                             tasks=['localizer', 'main'],
-                             n_runs=[1, 3],
-                             with_derivatives=True,
-                             with_confounds=True,
-                             confounds_tag="desc-confounds_timeseries",
-                             no_session=False,
-                             random_state=0):
-    """Creates a fake :term:`bids<BIDS>` dataset directory with dummy files.
+def generate_random_img(
+    shape,
+    affine=np.eye(4),
+    random_state=np.random.RandomState(0),
+):
+    """Create a random 3D or 4D image with a given shape and affine.
+
+    Parameters
+    ----------
+    shape : length-3 or length-4 tuple
+        The shape of the image being generated.
+        The number of elements determines the dimensionality of the image.
+
+    affine : 4x4 numpy.ndarray
+        The affine of the image
+
+    random_state : numpy.random.RandomState instance, optional
+        random number generator.
+
+    Returns
+    -------
+    data_img : 3D or 4D niimg
+        The data image.
+
+    mask_img : 3D niimg
+        The mask image.
+    """
+    data = random_state.standard_normal(size=shape)
+    data_img = Nifti1Image(data, affine)
+    if len(shape) == 4:
+        mask_data = as_ndarray(data[..., 0] > 0.2, dtype=np.int8)
+    else:
+        mask_data = as_ndarray(data > 0.2, dtype=np.int8)
+
+    mask_img = Nifti1Image(mask_data, affine)
+
+    return data_img, mask_img
+
+
+def create_fake_bids_dataset(
+    base_dir = Path(),
+    n_sub = 10,
+    n_ses = 2,
+    tasks = ["localizer", "main"],
+    n_runs = [1, 3],
+    with_derivatives = True,
+    with_confounds = True,
+    confounds_tag = "desc-confounds_timeseries",
+    random_state=0,
+    entities = None,
+):
+    """Create a fake :term:`bids<BIDS>` dataset directory with dummy files.
 
     Returns fake dataset directory name.
 
     Parameters
     ----------
-    base_dir : :obj:`str` (Absolute path), optional
+    base_dir : :obj:`str` or :obj:`pathlib.Path` (Absolute path). \
+        Default=pathlib.Path()
         Absolute directory path in which to create the fake :term:`BIDS`
         dataset dir.
-        Default: Current directory.
 
-    n_sub : :obj:`int`, optional
+    n_sub : :obj:`int`, Defaults=10
         Number of subjects to be simulated in the dataset.
-        Default=10.
 
-    n_ses : :obj:`int`, optional
+    n_ses : :obj:`int`, Defaults=2
         Number of sessions to be simulated in the dataset.
-        Ignored if no_session=True.
-        Default=2.
 
-    tasks : :obj:`list` of :obj:`str`, optional
+        Specifying n_ses=0 will only produce runs and files without the
+        optional session field.
+
+    tasks : :obj:`list` of :obj:`str`, Defaults=["localizer", "main"]
         List of tasks to be simulated in the dataset.
-        Default=["localizer", "main"].
 
-    n_runs : :obj:`list` of :obj:`int`, optional
+    n_runs : :obj:`list` of :obj:`int`, Defaults=[1, 3]
         Number of runs to create, where each element indicates the
         number of runs for the corresponding task.
         The length of this list must match the number of items in ``tasks``.
         Each run creates 100 volumes.
-        Default=[1, 3].
+        Files will be generated without run entity
+        if a value is equal to 0 or less.
 
-    with_derivatives : :obj:`bool`, optional
+    with_derivatives : :obj:`bool`, Defaults=True
         In the case derivatives are included, they come with two spaces and
-        descriptions. Spaces are 'MNI' and 'T1w'. Descriptions are 'preproc'
-        and :term:`fMRIPrep`. Only space 'T1w' include both descriptions.
-        Default=True.
+        descriptions.
+        Spaces are 'MNI' and 'T1w'.
+        Descriptions are 'preproc' and :term:`fMRIPrep`.
+        Only space 'T1w' include both descriptions.
 
-    with_confounds : :obj:`bool`, optional
+    with_confounds : :obj:`bool`, Defaults=True
         Whether to generate associated confounds files or not.
-        Default=True.
 
-    confounds_tag : :obj:`str` (filename suffix), optional
-        If generating confounds, what path should they have? Defaults to
-        `desc-confounds_timeseries` as in :term:`fMRIPrep` >= 20.2
+    confounds_tag : :obj:`str`, Defaults="desc-confounds_timeseries"
+        Filename "suffix":
+        If generating confounds, what path should they have?
+        Defaults to `desc-confounds_timeseries` as in :term:`fMRIPrep` >= 20.2
         but can be other values (e.g. "desc-confounds_regressors" as
         in :term:`fMRIPrep` < 20.2).
-        Default="desc-confounds_timeseries".
 
-    no_session : :obj:`bool`, optional
-        Specifying no_sessions will only produce runs and files without the
-        optional session field. In this case n_ses will be ignored.
-        Default=False.
-
-    random_state : :obj:`int` or :obj:`numpy.random.RandomState` instance, \
-                   optional
+    random_state : :obj:`int` or :obj:`numpy.random.RandomState` instance. \
+                    Defaults=0
         Random number generator, or seed.
-        Default=0.
+
+    entities : :obj:`dict`, optional
+        Extra entity to add to the BIDS filename with a list of values.
+        For example, if you want to add an 'echo' entity
+        with values '1' for some files and '1' for others,
+        you would pass: ``entities={"echo": ['1', '2']}``.
 
     Returns
     -------
-    dataset directory name : :obj:`str`
+    dataset directory name : :obj:`pathlib.Path`
         'bids_dataset'.
 
     Notes
@@ -822,124 +870,506 @@ def create_fake_bids_dataset(base_dir='',
         Creates a directory with dummy files.
 
     """
-    bids_path = os.path.join(base_dir, 'bids_dataset')
-    os.makedirs(bids_path)
+    n_voxels = 4
+
     rand_gen = check_random_state(random_state)
-    # Create surface bids dataset
-    open(os.path.join(bids_path, 'README.txt'), 'w')
-    vox = 4
-    created_sessions = ['ses-%02d' % label for label in range(1, n_ses + 1)]
-    if no_session:
-        created_sessions = ['']
-    for subject in ['sub-%02d' % label for label in range(1, n_sub + 1)]:
-        for session in created_sessions:
-            subses_dir = os.path.join(bids_path, subject, session)
-            if session in ('ses-01', ''):
-                anat_path = os.path.join(subses_dir, 'anat')
-                os.makedirs(anat_path)
-                anat_file = os.path.join(anat_path, subject + '_T1w.nii.gz')
-                open(anat_file, 'w')
-            func_path = os.path.join(subses_dir, 'func')
-            os.makedirs(func_path)
-            for task, n_run in zip(tasks, n_runs):
-                run_labels = [
-                    'run-%02d' % label for label in range(1, n_run + 1)
-                ]
-                for run in run_labels:
-                    fields = [subject, session, 'task-' + task]
-                    if '' in fields:
-                        fields.remove('')
-                    file_id = '_'.join(fields)
-                    if n_run > 1:
-                        file_id += '_' + run
-                    bold_path = os.path.join(func_path,
-                                             file_id + '_bold.nii.gz')
-                    write_fake_bold_img(bold_path, [vox, vox, vox, 100],
-                                        random_state=rand_gen)
-                    events_path = os.path.join(func_path,
-                                               file_id + '_events.tsv')
-                    basic_paradigm().to_csv(events_path, sep='\t', index=None)
-                    param_path = os.path.join(func_path,
-                                              file_id + '_bold.json')
-                    with open(param_path, 'w') as param_file:
-                        json.dump({'RepetitionTime': 1.5}, param_file)
-
-    # Create derivatives files
+
+    bids_dataset_dir = "bids_dataset"
+    bids_path = Path(base_dir) / bids_dataset_dir
+
+    for task_ in tasks:
+        _check_bids_label(task_)
+
+    if (not isinstance(n_runs, list) 
+        or not all(isinstance(x, int) for x in n_runs)):
+        raise TypeError(
+            "n_runs must be a list of integers."
+        )
+
+    if len(tasks) != len(n_runs):
+        raise ValueError(
+            "The number of tasks and number of runs must be the same."
+            f"Got {len(tasks)} tasks and {len(n_runs)} runs."
+        )
+
+    if entities is None:
+        entities = {}
+    _check_entities_and_labels(entities)
+
+    _mock_bids_dataset(
+        bids_path=bids_path,
+        n_sub=n_sub,
+        n_ses=n_ses,
+        tasks=tasks,
+        n_runs=n_runs,
+        entities=entities,
+        n_voxels=n_voxels,
+        rand_gen=rand_gen,
+    )
+
     if with_derivatives:
-        bids_path = os.path.join(base_dir, 'bids_dataset', 'derivatives')
-        os.makedirs(bids_path)
-        for subject in ['sub-%02d' % label for label in range(1, 11)]:
-            for session in created_sessions:
-                subses_dir = os.path.join(bids_path, subject, session)
-                func_path = os.path.join(subses_dir, 'func')
-                os.makedirs(func_path)
-                for task, n_run in zip(tasks, n_runs):
-                    for run in [
-                            'run-%02d' % label
-                            for label in range(1, n_run + 1)
-                    ]:
-                        fields = [subject, session, 'task-' + task]
-                        if '' in fields:
-                            fields.remove('')
-                        file_id = '_'.join(fields)
-                        if n_run > 1:
-                            file_id += '_' + run
-                        preproc = (
-                            file_id + '_space-MNI_desc-preproc_bold.nii.gz')
-                        preproc_path = os.path.join(func_path, preproc)
-                        write_fake_bold_img(preproc_path, [vox, vox, vox, 100],
-                                            random_state=rand_gen)
-                        preproc = (
-                            file_id + '_space-T1w_desc-preproc_bold.nii.gz')
-                        preproc_path = os.path.join(func_path, preproc)
-                        write_fake_bold_img(preproc_path, [vox, vox, vox, 100],
-                                            random_state=rand_gen)
-                        preproc = (
-                            file_id + '_space-T1w_desc-fmriprep_bold.nii.gz')
-                        preproc_path = os.path.join(func_path, preproc)
-                        write_fake_bold_img(preproc_path, [vox, vox, vox, 100],
-                                            random_state=rand_gen)
-                        if with_confounds:
-                            confounds_path = os.path.join(
-                                func_path,
-                                file_id + '_' + confounds_tag + '.tsv',
+        if not with_confounds:
+            confounds_tag = None
+
+        _mock_bids_derivatives(
+            bids_path=bids_path,
+            n_sub=n_sub,
+            n_ses=n_ses,
+            tasks=tasks,
+            n_runs=n_runs,
+            confounds_tag=confounds_tag,
+            entities=entities,
+            n_voxels=n_voxels,
+            rand_gen=rand_gen,
+        )
+
+    return bids_path
+
+
+def _check_entities_and_labels(entities):
+    """Check entities and labels are BIDS compliant.
+    
+    Parameters
+    ----------
+    entities : :obj:`dict`, optional
+        Extra entity to add to the BIDS filename with a list of values.
+        For example, if you want to add an 'echo' entity
+        with values '1' for some files and '1' for others,
+        you would pass: ``entities={"echo": ['1', '2']}``.
+    """
+    if len(entities.keys()) > 1:
+        # Generating dataset with more than one extra entity
+        # becomes too complex.
+        # Won't be implemented until there is a need.
+        raise ValueError("Only a single extra entity is supported for now.")
+
+    for key in entities:
+        if key not in [*_bids_entities()["raw"], *_bids_entities()["derivatives"]]:
+            raise ValueError(
+                f"Invalid entity: {key}. Allowed entities are: "
+                f"{[*_bids_entities()['raw'], *_bids_entities()['derivatives']]}"
+            )
+        [_check_bids_label(label_) for label_ in entities[key]]
+
+
+def _mock_bids_dataset(
+    bids_path,
+    n_sub,
+    n_ses,
+    tasks,
+    n_runs,
+    entities,
+    n_voxels,
+    rand_gen,
+):
+    """Create a fake raw :term:`bids<BIDS>` dataset directory with dummy files.
+
+    Parameters
+    ----------
+    base_dir : :obj:`Path`
+        Path where to create the fake :term:`BIDS` dataset.
+
+    n_sub : :obj:`int`
+        Number of subjects to be simulated in the dataset.
+
+    n_ses : :obj:`int`
+        Number of sessions to be simulated in the dataset.
+        Ignored if n_ses=0.
+
+    tasks : :obj:`list` of :obj:`str`
+        List of tasks to be simulated in the dataset.
+
+    n_runs : :obj:`list` of :obj:`int`
+        Number of runs to create, where each element indicates the
+        number of runs for the corresponding task.
+        No run entity will be used if a value is equal to 1 or less.
+
+    entities : :obj:`dict`, optional
+        Extra entities to add to the BIDS filename with a list of values.
+
+    n_voxels : :obj:`int`
+        Number of voxels along a given axis in the functional image.
+
+    rand_gen : :obj:`numpy.random.RandomState` instance
+        Random number generator.
+
+    """
+    bids_path.mkdir(parents=True, exist_ok=True)
+
+    bids_path.joinpath("README.txt").write_text("")
+
+    for subject, session in itertools.product(
+        _listify(n_sub), _listify(n_ses)
+    ):
+        subses_dir = bids_path / f"sub-{subject}"
+        if session != "":
+            subses_dir = subses_dir / f"ses-{session}"
+
+        if session in ("01", ""):
+            _write_bids_raw_anat(subses_dir, subject, session)
+
+        func_path = subses_dir / "func"
+        func_path.mkdir(parents=True, exist_ok=True)
+
+        for task, n_run in zip(tasks, n_runs):
+            for run in _listify(n_run):
+
+                if entities:
+                    for key in entities:
+                        for label in entities[key]:
+                            fields = _init_fields(
+                                subject=subject,
+                                session=session,
+                                task=task,
+                                run=run,
+                            )
+                            if key in _bids_entities()["raw"]:
+                                fields["entities"][key] = label
+                            _write_bids_raw_func(
+                                func_path=func_path,
+                                fields=fields,
+                                n_voxels=n_voxels,
+                                rand_gen=rand_gen,
                             )
-                            basic_confounds(100, random_state=rand_gen).to_csv(
-                                confounds_path, sep='\t', index=None)
-    return 'bids_dataset'
 
+                else:
+                    fields = _init_fields(
+                        subject=subject, session=session, task=task, run=run
+                    )
+                    _write_bids_raw_func(
+                        func_path=func_path,
+                        fields=fields,
+                        n_voxels=n_voxels,
+                        rand_gen=rand_gen,
+                    )
+
+
+def _mock_bids_derivatives(
+    bids_path,
+    n_sub,
+    n_ses,
+    tasks,
+    n_runs,
+    confounds_tag,
+    entities,
+    n_voxels,
+    rand_gen,
+):
+    """Create a fake raw :term:`bids<BIDS>` dataset directory with dummy files.
 
-def generate_random_img(
-    shape,
-    affine=np.eye(4),
-    random_state=np.random.RandomState(0),
+    Parameters
+    ----------
+    base_dir : :obj:`Path`
+        Path where to create the fake :term:`BIDS` dataset.
+
+    n_sub : :obj:`int`
+        Number of subjects to be simulated in the dataset.
+
+    n_ses : :obj:`int`
+        Number of sessions to be simulated in the dataset.
+        Ignored if n_ses=0.
+
+    tasks : :obj:`list` of :obj:`str`
+        List of tasks to be simulated in the dataset.
+
+    n_runs : :obj:`list` of :obj:`int`
+        Number of runs to create, where each element indicates the
+        number of runs for the corresponding task.
+        No run entity will be used if a value is equal to 1 or less.
+
+    confounds_tag : :obj:`str`
+        Filename "suffix":
+        For example: `desc-confounds_timeseries`
+        or "desc-confounds_regressors".
+
+    entities : :obj:`dict`
+        Extra entity to add to the BIDS filename with a list of values.
+
+    n_voxels : :obj:`int`
+        Number of voxels along a given axis in the functional image.
+
+    rand_gen : :obj:`numpy.random.RandomState` instance
+        Random number generator.
+
+    """
+    bids_path = bids_path / "derivatives"
+    bids_path.mkdir(parents=True, exist_ok=True)
+
+    for subject, session in itertools.product(
+        _listify(n_sub), _listify(n_ses)
+    ):
+        subses_dir = bids_path / f"sub-{subject}"
+        if session != "":
+            subses_dir = subses_dir / f"ses-{session}"
+
+        func_path = subses_dir / "func"
+        func_path.mkdir(parents=True, exist_ok=True)
+
+        for task, n_run in zip(tasks, n_runs):
+            for run in _listify(n_run):
+
+                if entities:
+                    for key in entities:
+                        for label in entities[key]:
+                            fields = _init_fields(
+                                subject=subject,
+                                session=session,
+                                task=task,
+                                run=run,
+                            )
+                            fields["entities"][key] = label
+                            _write_bids_derivative_func(
+                                func_path=func_path,
+                                fields=fields,
+                                n_voxels=n_voxels,
+                                rand_gen=rand_gen,
+                                confounds_tag=confounds_tag,
+                            )
+
+                else:
+                    fields = _init_fields(
+                        subject=subject, session=session, task=task, run=run
+                    )
+                    _write_bids_derivative_func(
+                        func_path=func_path,
+                        fields=fields,
+                        n_voxels=n_voxels,
+                        rand_gen=rand_gen,
+                        confounds_tag=confounds_tag,
+                    )
+
+
+def _listify(n):
+    """Return a list of zero padded BIDS labels.
+
+    If n is 0 or less, return an empty list.
+
+    Parameters
+    ----------
+    n : :obj:`int`
+        Number of labels to create.
+
+    Returns
+    -------
+    List of labels : :obj:`list` of :obj:`str`
+
+    """
+    return [""] if n <= 0 else [f"{label:02}" for label in range(1, n + 1)]
+
+
+def _create_bids_filename(
+    fields, entities_to_include = None
 ):
-    """Create a random 3D or 4D image with a given shape and affine.
+    """Create BIDS filename from dictionary of entity-label pairs.
 
     Parameters
     ----------
-    shape : length-3 or length-4 tuple
-        The shape of the image being generated.
-        The number of elements determines the dimensionality of the image.
-    affine : 4x4 numpy.ndarray
-        The affine of the image
-    random_state : numpy.random.RandomState instance, optional
-        random number generator.
+    fields : :obj:`dict` of :obj:`str`
+        Dictionary of entity-label pairs, for example:
+
+        {
+         "suffix": "T1w",
+         "extension": "nii.gz",
+         "entities": {"acq":  "ap",
+                      "desc": "preproc"}
+        }.
 
     Returns
     -------
-    data_img : 3D or 4D niimg
-        The data image.
-    mask_img : 3D niimg
-        The mask image.
+    BIDS filename : :obj:`str`
+
     """
-    data = random_state.standard_normal(size=shape)
-    data_img = Nifti1Image(data, affine)
-    if len(shape) == 4:
-        mask_data = as_ndarray(data[..., 0] > 0.2, dtype=np.int8)
-    else:
-        mask_data = as_ndarray(data > 0.2, dtype=np.int8)
+    if entities_to_include is None:
+        entities_to_include = _bids_entities()["raw"]
 
-    mask_img = Nifti1Image(mask_data, affine)
+    filename = ""
 
-    return data_img, mask_img
+    for key in entities_to_include:
+        if key in fields["entities"]:
+            value = fields["entities"][key]
+            if value not in (None, ""):
+                filename += f"{key}-{value}_"
+    filename += f"{fields['suffix']}.{fields['extension']}"
+
+    return filename
+
+
+def _init_fields(subject,
+                 session,
+                 task,
+                 run):
+    """Initialize fields to help create a valid BIDS filename.
+
+    Parameters
+    ----------
+    subject : :obj:`str`
+        Subject label
+
+    session : :obj:`str`
+        Session label
+
+    task : :obj:`str`
+        Task label
+
+    run : :obj:`str`
+        Run label
+
+    Returns
+    -------
+    dict
+        Fields used to create a BIDS filename.
+
+    See Also
+    --------
+    _create_bids_filename
+
+    """
+    fields = {
+        "suffix": "bold",
+        "extension": "nii.gz",
+        "entities": {
+            "sub": subject,
+            "ses": session,
+            "task": task,
+            "run": run,
+        },
+    }
+    return fields
+
+
+def _write_bids_raw_anat(subses_dir, subject, session) -> None:
+    """Create a dummy anat T1w file.
+
+    Parameters
+    ----------
+    subses_dir : :obj:`Path`
+        Subject session directory
+
+    subject : :obj:`str`
+        Subject label
+
+    session : :obj:`str`
+        Session label
+    """
+    anat_path = subses_dir / "anat"
+    anat_path.mkdir(parents=True, exist_ok=True)
+    fields = {
+        "suffix": "T1w",
+        "extension": "nii.gz",
+        "entities": {"sub": subject, "ses": session},
+    }
+    (anat_path / _create_bids_filename(fields)).write_text("")
+
+
+def _write_bids_raw_func(
+    func_path,
+    fields,
+    n_voxels,
+    rand_gen,
+):
+    """Create BIDS functional raw nifti, json sidecar and events files.
+
+    Parameters
+    ----------
+    func_path : :obj:`Path`
+        Path to a subject functional directory.
+
+    file_id : :obj:`str`
+        Root of the BIDS filename:
+        typically basename without the BIDS suffix and extension.
+
+    n_voxels : :obj:`int`
+        Number of voxels along a given axis in the functional image.
+
+    rand_gen : :obj:`numpy.random.RandomState` instance
+        Random number generator.
+
+    """
+    n_time_points = 100
+    bold_path = func_path / _create_bids_filename(fields)
+    write_fake_bold_img(
+        bold_path,
+        [n_voxels, n_voxels, n_voxels, n_time_points],
+        random_state=rand_gen,
+    )
+
+    repetition_time = 1.5
+    fields["extension"] = "json"
+    param_path = func_path / _create_bids_filename(fields)
+    param_path.write_text(json.dumps({"RepetitionTime": repetition_time}))
+
+    fields["suffix"] = "events"
+    fields["extension"] = "tsv"
+    events_path = func_path / _create_bids_filename(fields)
+    basic_paradigm().to_csv(events_path, sep="\t", index=None)
+
+
+def _write_bids_derivative_func(
+    func_path,
+    fields,
+    n_voxels,
+    rand_gen,
+    confounds_tag,
+):
+    """Create BIDS functional derivative and confounds files.
+
+    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.
+
+    Parameters
+    ----------
+    func_path : :obj:`Path`
+        Path to a subject functional directory.
+
+    file_id : :obj:`str`
+        Root of the BIDS filename:
+        typically basename without the BIDS suffix and extension.
+
+    n_voxels : :obj:`int`
+        Number of voxels along a given axis in the functional image.
+
+    rand_gen : :obj:`numpy.random.RandomState` instance
+        Random number generator.
+
+    confounds_tag : :obj:`str`, optional.
+        Filename "suffix":
+        For example: `desc-confounds_timeseries`
+        or "desc-confounds_regressors".
+
+    """
+    n_time_points = 100
+
+    if confounds_tag is not None:
+        fields["suffix"] = confounds_tag
+        fields["extension"] = "tsv"
+        confounds_path = func_path / _create_bids_filename(
+            fields=fields, entities_to_include=_bids_entities()["raw"]
+        )
+        basic_confounds(length=n_time_points, random_state=rand_gen).to_csv(
+            confounds_path, sep="\t", index=None
+        )
+
+    fields["suffix"] = "bold"
+    fields["extension"] = "nii.gz"
+
+    shape = [n_voxels, n_voxels, n_voxels, n_time_points]
+
+    for space in ("MNI", "T1w"):
+        for desc in ("preproc", "fmriprep"):
+            # Only space 'T1w' include both descriptions.
+            if space == "MNI" and desc == "fmriprep":
+                continue
+
+            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
+            )
+            write_fake_bold_img(bold_path, shape=shape, random_state=rand_gen)
diff --git a/nilearn/glm/first_level/first_level.py b/nilearn/glm/first_level/first_level.py
--- a/nilearn/glm/first_level/first_level.py
+++ b/nilearn/glm/first_level/first_level.py
@@ -5,11 +5,17 @@
 Author: Bertrand Thirion, Martin Perez-Guevara, 2016
 
 """
+from __future__ import annotations
+
 import glob
 import json
 import os
+import pathlib
 import sys
 import time
+
+from pathlib import Path
+from typing import Optional
 from warnings import warn
 
 import numpy as np
@@ -21,6 +27,7 @@
 
 from nilearn.interfaces.bids import get_bids_files, parse_bids_filename
 from nilearn._utils import fill_doc
+from nilearn.interfaces.bids._utils import _bids_entities, _check_bids_label
 from nilearn._utils.glm import (_check_events_file_uses_tab_separators,
                                 _check_run_tables, _check_run_sample_masks)
 from nilearn._utils.niimg_conversions import check_niimg
@@ -793,64 +800,80 @@ def _get_voxelwise_model_attribute(self, attribute,
         return output
 
 
-def first_level_from_bids(dataset_path, task_label, space_label=None,
+def first_level_from_bids(dataset_path,
+                          task_label,
+                          space_label=None,
                           sub_labels=None,
-                          img_filters=None, t_r=None, slice_time_ref=0.,
-                          hrf_model='glover', drift_model='cosine',
-                          high_pass=.01, drift_order=1, fir_delays=[0],
-                          min_onset=-24, mask_img=None,
-                          target_affine=None, target_shape=None,
-                          smoothing_fwhm=None, memory=Memory(None),
-                          memory_level=1, standardize=False,
-                          signal_scaling=0, noise_model='ar1',
-                          verbose=0, n_jobs=1,
+                          img_filters=None,
+                          t_r=None,
+                          slice_time_ref=0.,
+                          hrf_model='glover',
+                          drift_model='cosine',
+                          high_pass=.01,
+                          drift_order=1,
+                          fir_delays=[0],
+                          min_onset=-24,
+                          mask_img=None,
+                          target_affine=None,
+                          target_shape=None,
+                          smoothing_fwhm=None,
+                          memory=Memory(None),
+                          memory_level=1,
+                          standardize=False,
+                          signal_scaling=0,
+                          noise_model='ar1',
+                          verbose=0,
+                          n_jobs=1,
                           minimize_memory=True,
                           derivatives_folder='derivatives'):
     """Create FirstLevelModel objects and fit arguments from a BIDS dataset.
 
-    It t_r is not specified this function will attempt to load it from a
-    bold.json file alongside slice_time_ref. Otherwise t_r and slice_time_ref
-    are taken as given.
+    If t_r is not specified this function will attempt to load it from a
+    bold.json file alongside slice_time_ref.
+    Otherwise t_r and slice_time_ref are taken as given.
 
     Parameters
     ----------
-    dataset_path : str
-        Directory of the highest level folder of the BIDS dataset. Should
-        contain subject folders and a derivatives folder.
+    dataset_path : :obj:`str` or :obj:`pathlib.Path`
+        Directory of the highest level folder of the BIDS dataset.
+        Should contain subject folders and a derivatives folder.
 
-    task_label : str
+    task_label : :obj:`str`
         Task_label as specified in the file names like _task-<task_label>_.
 
-    space_label : str, optional
+    space_label : :obj:`str`, optional
         Specifies the space label of the preprocessed bold.nii images.
         As they are specified in the file names like _space-<space_label>_.
 
-    sub_labels : list of str, optional
+    sub_labels : :obj:`list` of :obj:`str`, optional
         Specifies the subset of subject labels to model.
         If 'None', will model all subjects in the dataset.
-        .. versionadded:: 0.9.3.dev
+        .. versionadded:: 0.10.1.dev
 
-    img_filters : list of tuples (str, str), optional
+    img_filters : :obj:`list` of :obj:`tuples` (str, str), optional
         Filters are of the form (field, label). Only one filter per field
-        allowed. A file that does not match a filter will be discarded.
+        allowed.
+        A file that does not match a filter will be discarded.
         Possible filters are 'acq', 'ce', 'dir', 'rec', 'run', 'echo', 'res',
-        'den', and 'desc'. Filter examples would be ('desc', 'preproc'),
-        ('dir', 'pa') and ('run', '10').
+        'den', and 'desc'.
+        Filter examples would be ('desc', 'preproc'), ('dir', 'pa')
+        and ('run', '10').
 
-    derivatives_folder : str, optional
+    derivatives_folder : :obj:`str`, Defaults="derivatives".
         derivatives and app folder path containing preprocessed files.
-        Like "derivatives/FMRIPREP". Default="derivatives".
+        Like "derivatives/FMRIPREP".
 
     All other parameters correspond to a `FirstLevelModel` object, which
-    contains their documentation. The subject label of the model will be
-    determined directly from the BIDS dataset.
+    contains their documentation.
+    The subject label of the model will be determined directly
+    from the BIDS dataset.
 
     Returns
     -------
     models : list of `FirstLevelModel` objects
-        Each FirstLevelModel object corresponds to a subject. All runs from
-        different sessions are considered together for the same subject to run
-        a fixed effects analysis on them.
+        Each FirstLevelModel object corresponds to a subject.
+        All runs from different sessions are considered together
+        for the same subject to run a fixed effects analysis on them.
 
     models_run_imgs : list of list of Niimg-like objects,
         Items for the FirstLevelModel fit function of their respective model.
@@ -862,80 +885,62 @@ def first_level_from_bids(dataset_path, task_label, space_label=None,
         Items for the FirstLevelModel fit function of their respective model.
 
     """
-    # check arguments
-    sub_labels = sub_labels if sub_labels else []
+    sub_labels = sub_labels or []
     img_filters = img_filters or []
-    if not isinstance(dataset_path, str):
-        raise TypeError(
-            'dataset_path must be a string, instead %s was given' %
-            type(task_label))
-    if not os.path.exists(dataset_path):
-        raise ValueError('given path do not exist: %s' % dataset_path)
-    if not isinstance(task_label, str):
-        raise TypeError('task_label must be a string, instead %s was given' %
-                        type(task_label))
-    if space_label is not None and not isinstance(space_label, str):
-        raise TypeError('space_label must be a string, instead %s was given' %
-                        type(space_label))
-    if not isinstance(sub_labels, list):
-        raise TypeError('sub_labels must be a list, instead %s was given' %
-                        type(sub_labels))
-    if not isinstance(img_filters, list):
-        raise TypeError('img_filters must be a list, instead %s was given' %
-                        type(img_filters))
-    for img_filter in img_filters:
-        if (not isinstance(img_filter[0], str)
-                or not isinstance(img_filter[1], str)):
-            raise TypeError('filters in img filters must be (str, str), '
-                            'instead %s was given' % type(img_filter))
-        if img_filter[0] not in ['acq', 'ce', 'dir', 'rec', 'run',
-                                 'echo', 'desc', 'res', 'den',
-                                 ]:
-            raise ValueError(
-                "field %s is not a possible filter. Only "
-                "'acq', 'ce', 'dir', 'rec', 'run', 'echo', "
-                "'desc', 'res', 'den' are allowed." % img_filter[0])
+    
+    _check_args_first_level_from_bids(dataset_path=dataset_path,
+                                         task_label=task_label,
+                                         space_label=space_label,
+                                         sub_labels=sub_labels,
+                                         img_filters=img_filters,
+                                         derivatives_folder=derivatives_folder)
 
-    # check derivatives folder is present
-    derivatives_path = os.path.join(dataset_path, derivatives_folder)
-    if not os.path.exists(derivatives_path):
-        raise ValueError('derivatives folder does not exist in given dataset')
+    derivatives_path = Path(dataset_path) / derivatives_folder
 
     # Get acq specs for models. RepetitionTime and SliceTimingReference.
     # Throw warning if no bold.json is found
     if t_r is not None:
-        warn('RepetitionTime given in model_init as %d' % t_r)
-        warn('slice_time_ref is %d percent of the repetition '
-             'time' % slice_time_ref)
+        warn(f'RepetitionTime given in as {t_r}')
+        warn(f'slice_time_ref is {slice_time_ref} percent '
+             'of the repetition time')
     else:
-        filters = [('task', task_label)]
-        for img_filter in img_filters:
-            if img_filter[0] in ['acq', 'rec', 'run']:
-                filters.append(img_filter)
-
-        img_specs = get_bids_files(derivatives_path, modality_folder='func',
-                                   file_tag='bold', file_type='json',
+        filters = _make_bids_files_filter(
+            task_label=task_label,
+            supported_filters=[*_bids_entities()["raw"], *_bids_entities()["derivatives"]],
+            extra_filter=img_filters
+        )
+        img_specs = get_bids_files(derivatives_path,
+                                   modality_folder='func',
+                                   file_tag='bold',
+                                   file_type='json',
                                    filters=filters)
         # If we don't find the parameter information in the derivatives folder
         # we try to search in the raw data folder
         if not img_specs:
-            img_specs = get_bids_files(dataset_path, modality_folder='func',
-                                       file_tag='bold', file_type='json',
+            filters = _make_bids_files_filter(
+                task_label=task_label,
+                supported_filters=_bids_entities()["raw"],
+                extra_filter=img_filters
+            )
+            img_specs = get_bids_files(dataset_path,
+                                       modality_folder='func',
+                                       file_tag='bold',
+                                       file_type='json',
                                        filters=filters)
         if not img_specs:
-            warn('No bold.json found in derivatives folder or '
-                 'in dataset folder. t_r can not be inferred and will need to'
-                 ' be set manually in the list of models, otherwise their fit'
-                 ' will throw an exception')
+            warn('No bold.json found in the derivatives or dataset folder.'
+                 ' t_r can not be inferred '
+                 ' and will need to be set manually in the list of models'
+                 ' otherwise their fit will throw an exception.')
         else:
             specs = json.load(open(img_specs[0], 'r'))
             if 'RepetitionTime' in specs:
                 t_r = float(specs['RepetitionTime'])
             else:
-                warn('RepetitionTime not found in file %s. t_r can not be '
-                     'inferred and will need to be set manually in the '
-                     'list of models. Otherwise their fit will throw an '
-                     ' exception' % img_specs[0])
+                warn(f'RepetitionTime not found in file {img_specs[0]}.'
+                     ' t_r can not be inferred ',
+                     ' and will need to be set manually in the list of models',
+                     ' otherwise their fit will throw an exception.')
             if 'SliceTimingRef' in specs:
                 slice_time_ref = float(specs['SliceTimingRef'])
             else:
@@ -945,21 +950,7 @@ def first_level_from_bids(dataset_path, task_label, space_label=None,
                      'be set manually in the generated list of models' %
                      img_specs[0])
 
-    # Infer subjects in dataset
-    if not sub_labels:
-        sub_folders = glob.glob(os.path.join(derivatives_path, 'sub-*/'))
-        sub_labels = [
-            os.path.basename(s[:-1]).split('-')[1] for s in sub_folders
-        ]
-        sub_labels = sorted(list(set(sub_labels)))
-
-    sub_labels_exist = []
-    for this_label in sub_labels:
-        if os.path.exists(os.path.join(derivatives_path, f"sub-{this_label}")):
-            sub_labels_exist.append(this_label)
-        else:
-            warn(f'Subject label {this_label} is not present in the'
-                    ' dataset and cannot be processed.')
+    sub_labels = _list_valid_subjects(derivatives_path, sub_labels)
 
     # Build fit_kwargs dictionaries to pass to their respective models fit
     # Events and confounds files must match number of imgs (runs)
@@ -967,7 +958,9 @@ def first_level_from_bids(dataset_path, task_label, space_label=None,
     models_run_imgs = []
     models_events = []
     models_confounds = []
-    for sub_label in sub_labels_exist:
+
+    for sub_label_ in sub_labels:
+
         # Create model
         model = FirstLevelModel(
             t_r=t_r, slice_time_ref=slice_time_ref, hrf_model=hrf_model,
@@ -979,108 +972,656 @@ def first_level_from_bids(dataset_path, task_label, space_label=None,
             memory_level=memory_level, standardize=standardize,
             signal_scaling=signal_scaling, noise_model=noise_model,
             verbose=verbose, n_jobs=n_jobs,
-            minimize_memory=minimize_memory, subject_label=sub_label)
+            minimize_memory=minimize_memory, subject_label=sub_label_)
         models.append(model)
 
-        # Get preprocessed imgs
-        if space_label is None:
-            filters = [('task', task_label)] + img_filters
-        else:
-            filters = [('task', task_label),
-                       ('space', space_label)] + img_filters
-        imgs = get_bids_files(derivatives_path, modality_folder='func',
-                              file_tag='bold', file_type='nii*',
-                              sub_label=sub_label, filters=filters)
-        # If there is more than one file for the same (ses, run), likely we
-        # have an issue of underspecification of filters.
-        run_check_list = []
-        # If more than one run is present the run field is mandatory in BIDS
-        # as well as the ses field if more than one session is present.
-        if len(imgs) > 1:
-            for img in imgs:
-                img_dict = parse_bids_filename(img)
-                if (
-                    '_ses-' in img_dict['file_basename']
-                    and '_run-' in img_dict['file_basename']
-                ):
-                    if (img_dict['ses'], img_dict['run']) in run_check_list:
-                        raise ValueError(
-                            'More than one nifti image found '
-                            'for the same run %s and session %s. '
-                            'Please verify that the '
-                            'desc_label and space_label labels '
-                            'corresponding to the BIDS spec '
-                            'were correctly specified.' %
-                            (img_dict['run'], img_dict['ses']))
-                    else:
-                        run_check_list.append((img_dict['ses'],
-                                               img_dict['run']))
-
-                elif '_ses-' in img_dict['file_basename']:
-                    if img_dict['ses'] in run_check_list:
-                        raise ValueError(
-                            'More than one nifti image '
-                            'found for the same ses %s, while '
-                            'no additional run specification present'
-                            '. Please verify that the desc_label and '
-                            'space_label labels '
-                            'corresponding to the BIDS spec '
-                            'were correctly specified.' %
-                            img_dict['ses'])
-                    else:
-                        run_check_list.append(img_dict['ses'])
-
-                elif '_run-' in img_dict['file_basename']:
-                    if img_dict['run'] in run_check_list:
-                        raise ValueError(
-                            'More than one nifti image '
-                            'found for the same run %s. '
-                            'Please verify that the desc_label and '
-                            'space_label labels '
-                            'corresponding to the BIDS spec '
-                            'were correctly specified.' %
-                            img_dict['run'])
-                    else:
-                        run_check_list.append(img_dict['run'])
+        imgs = _get_processed_imgs(derivatives_path=derivatives_path,
+                                   sub_label=sub_label_,
+                                   task_label=task_label,
+                                   space_label=space_label,
+                                   img_filters=img_filters,
+                                   verbose=verbose)
         models_run_imgs.append(imgs)
 
-        # Get events and extra confounds
-        filters = [('task', task_label)]
-        for img_filter in img_filters:
-            if img_filter[0] in ['acq', 'rec', 'run']:
-                filters.append(img_filter)
-
-        # Get events files
-        events = get_bids_files(dataset_path, modality_folder='func',
-                                file_tag='events', file_type='tsv',
-                                sub_label=sub_label, filters=filters)
-        if events:
-            if len(events) != len(imgs):
-                raise ValueError('%d events.tsv files found for %d bold '
-                                 'files. Same number of event files as '
-                                 'the number of runs is expected' %
-                                 (len(events), len(imgs)))
-            events = [pd.read_csv(event, sep='\t', index_col=None)
-                      for event in events]
-            models_events.append(events)
-        else:
-            raise ValueError('No events.tsv files found')
-
-        # Get confounds. If not found it will be assumed there are none.
-        # If there are confounds, they are assumed to be present for all runs.
-        confounds = get_bids_files(derivatives_path, modality_folder='func',
-                                   file_tag='desc-confounds*',
-                                   file_type='tsv', sub_label=sub_label,
-                                   filters=filters)
-
+        events = _get_events_files(dataset_path=dataset_path,
+                                   sub_label=sub_label_,
+                                   task_label=task_label,
+                                   img_filters=img_filters,
+                                   imgs=imgs,
+                                   verbose=verbose)
+        events = [pd.read_csv(event, sep='\t', index_col=None)
+                  for event in events]
+        models_events.append(events)
+
+        confounds = _get_confounds(derivatives_path=derivatives_path,
+                                   sub_label=sub_label_,
+                                   task_label=task_label,
+                                   img_filters=img_filters,
+                                   imgs=imgs,
+                                   verbose=verbose)
         if confounds:
-            if len(confounds) != len(imgs):
-                raise ValueError('%d confounds.tsv files found for %d bold '
-                                 'files. Same number of confound files as '
-                                 'the number of runs is expected' %
-                                 (len(events), len(imgs)))
             confounds = [pd.read_csv(c, sep='\t', index_col=None)
                          for c in confounds]
-            models_confounds.append(confounds)
+        models_confounds.append(confounds)
 
     return models, models_run_imgs, models_events, models_confounds
+
+
+def _list_valid_subjects(derivatives_path,
+                         sub_labels):
+    """List valid subjects in the dataset.
+
+    - Include all subjects if no subject pre-selection is passed.
+    - Exclude subjects that do not exist in the derivatives folder.
+    - Remove duplicate subjects.
+
+    Parameters
+    ----------
+    derivatives_path : :obj:`str`
+        Path to the BIDS derivatives folder.
+
+    sub_labels : :obj:`list` of :obj:`str`, optional
+        List of subject labels to process. 
+        If None, all subjects in the dataset will be processed.
+
+    Returns
+    -------
+    sub_labels : :obj:`list` of :obj:`str`, optional
+        List of subject labels that will be processed.
+    """    
+    # Infer subjects in dataset if not provided
+    if not sub_labels:
+        sub_folders = glob.glob(os.path.join(derivatives_path, "sub-*/"))
+        sub_labels = [
+            os.path.basename(s[:-1]).split("-")[1] for s in sub_folders
+        ]
+        sub_labels = sorted(list(set(sub_labels)))
+
+    # keep only existing subjects
+    sub_labels_exist = []
+    for sub_label_ in sub_labels:
+        if os.path.exists(os.path.join(derivatives_path, f"sub-{sub_label_}")):
+            sub_labels_exist.append(sub_label_)
+        else:
+            warn(
+                f"Subject label {sub_label_} is not present in the"
+                " dataset and cannot be processed."
+            )
+
+    return set(sub_labels_exist)
+
+
+def _report_found_files(
+    files, text, sub_label, filters
+):
+    """Print list of files found for a given subject and filter.
+
+    Parameters
+    ----------
+    files : :obj:`list` of :obj:`str`
+        List of fullpath of files.
+
+    text :  :obj:`str`
+        Text description of the file type.
+
+    sub_label : :obj:`str`
+        Subject label as specified in the file names like sub-<sub_label>_.
+
+    filters : :obj:`list` of :obj:`tuple` (str, str)
+        Filters are of the form (field, label).
+        Only one filter per field allowed.
+
+    """
+    print(
+        f"Found the following {len(files)} {text} files\n",
+        f"for subject {sub_label}\n",
+        f"for filter: {filters}:\n",
+        f"{files}\n",
+    )
+
+
+def _get_processed_imgs(
+    derivatives_path,
+    sub_label,
+    task_label,
+    space_label,
+    img_filters,
+    verbose
+) :
+    """Get images for a given subject, task and filters.
+
+    Also checks that there is only one images per run / session.
+
+    Parameters
+    ----------
+    derivatives_path : :obj:`str`
+        Directory of the derivatives BIDS dataset.
+
+    sub_label : :obj:`str`
+        Subject label as specified in the file names like sub-<sub_label>_.
+
+    task_label : :obj:`str`
+        Task label as specified in the file names like _task-<task_label>_.
+
+    img_filters : :obj:`list` of :obj:`tuple` (str, str)
+        Filters are of the form (field, label).
+        Only one filter per field allowed.
+
+    verbose : :obj:`integer`
+        Indicate the level of verbosity.
+
+    Returns
+    -------
+    imgs : :obj:`list` of :obj:`str`
+        List of fullpath to the imgs files
+
+    """
+    filters = _make_bids_files_filter(
+        task_label=task_label,
+        space_label=space_label,
+        supported_filters=_bids_entities()["raw"]
+        + _bids_entities()["derivatives"],
+        extra_filter=img_filters,
+    )
+    imgs = get_bids_files(
+        main_path=derivatives_path,
+        modality_folder="func",
+        file_tag="bold",
+        file_type="nii*",
+        sub_label=sub_label,
+        filters=filters,
+    )
+    if verbose:
+        _report_found_files(files=imgs,
+                            text='preprocessed BOLD',
+                            sub_label=sub_label,
+                            filters=filters)
+    _check_bids_image_list(imgs, sub_label, filters)
+    return imgs
+
+
+def _get_events_files(
+    dataset_path,
+    sub_label,
+    task_label,
+    img_filters,
+    imgs,
+    verbose,
+):
+    """Get events.tsv files for a given subject, task and filters.
+
+    Also checks that the number of events.tsv files
+    matches the number of images.
+
+    Parameters
+    ----------
+    dataset_path : :obj:`str`
+        Directory of the derivatives BIDS dataset.
+
+    sub_label : :obj:`str`
+        Subject label as specified in the file names like sub-<sub_label>_.
+
+    task_label : :obj:`str`
+        Task label as specified in the file names like _task-<task_label>_.
+
+    img_filters : :obj:`list` of :obj:`tuple` (str, str)
+        Filters are of the form (field, label).
+        Only one filter per field allowed.
+
+    imgs : :obj:`list` of :obj:`str`
+        List of fullpath to the preprocessed images
+
+    verbose : :obj:`integer`
+        Indicate the level of verbosity.
+
+    Returns
+    -------
+    events : :obj:`list` of :obj:`str`
+        List of fullpath to the events files
+    """
+    events_filters = _make_bids_files_filter(
+        task_label=task_label,
+        supported_filters=_bids_entities()["raw"],
+        extra_filter=img_filters,
+    )
+    events = get_bids_files(
+        dataset_path,
+        modality_folder="func",
+        file_tag="events",
+        file_type="tsv",
+        sub_label=sub_label,
+        filters=events_filters,
+    )
+    if verbose:
+        _report_found_files(files=events,
+                            text='events',
+                            sub_label=sub_label,
+                            filters=events_filters)
+    _check_bids_events_list(
+        events=events,
+        imgs=imgs,
+        sub_label=sub_label,
+        task_label=task_label,
+        dataset_path=dataset_path,
+        events_filters=events_filters,
+    )
+    return events
+
+
+def _get_confounds(
+    derivatives_path,
+    sub_label,
+    task_label,
+    img_filters,
+    imgs,
+    verbose,
+):
+    """Get confounds.tsv files for a given subject, task and filters.
+
+    Also checks that the number of confounds.tsv files
+    matches the number of images.
+
+    Parameters
+    ----------
+    derivatives_path : :obj:`str`
+        Directory of the derivatives BIDS dataset.
+
+    sub_label : :obj:`str`
+        Subject label as specified in the file names like sub-<sub_label>_.
+
+    task_label : :obj:`str`
+        Task label as specified in the file names like _task-<task_label>_.
+
+    img_filters : :obj:`list` of :obj:`tuple` (str, str)
+        Filters are of the form (field, label).
+        Only one filter per field allowed.
+
+    imgs : :obj:`list` of :obj:`str`
+        List of fullpath to the preprocessed images
+
+    verbose : :obj:`integer`
+        Indicate the level of verbosity.
+
+    Returns
+    -------
+    confounds : :obj:`list` of :obj:`str` or None
+        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,
+        extra_filter=img_filters,
+    )
+    confounds = get_bids_files(
+        derivatives_path,
+        modality_folder="func",
+        file_tag="desc-confounds*",
+        file_type="tsv",
+        sub_label=sub_label,
+        filters=filters,
+    )
+    if verbose:
+        _report_found_files(files=confounds,
+                            text='confounds',
+                            sub_label=sub_label,
+                            filters=filters)
+    _check_confounds_list(confounds=confounds, imgs=imgs)
+    return confounds or None
+
+
+def _check_confounds_list(confounds, imgs):
+    """Check the number of confounds.tsv files.
+
+    If no file is found, it will be assumed there are none,
+    but if there are any confounds files, there must be one per run.
+
+    Parameters
+    ----------
+    confounds : :obj:`list` of :obj:`str`
+        List of fullpath to the confounds.tsv files
+
+    imgs : :obj:`list` of :obj:`str`
+        List of fullpath to the preprocessed images
+
+    """
+    if confounds and len(confounds) != len(imgs):
+        raise ValueError(
+            f"{len(confounds)} confounds.tsv files found "
+            f"for {len(imgs)} bold files. "
+            "Same number of confound files as "
+            "the number of runs is expected"
+        )
+
+
+def _check_args_first_level_from_bids(
+    dataset_path,
+    task_label,
+    space_label,
+    sub_labels,
+    img_filters,
+    derivatives_folder,
+):
+    """Check type and value of arguments of first_level_from_bids.
+
+    Check that:
+        - dataset_path is a string and exists
+        - derivatives_path exists
+        - task_label and space_label are valid bids labels
+        - img_filters is a list of tuples of strings
+          and all filters are valid bids entities
+          with valid bids labels
+
+    Parameters
+    ----------
+    dataset_path : :obj:`str`
+        Fullpath of the BIDS dataset root folder.
+
+    task_label : :obj:`str`
+        Task_label as specified in the file names like _task-<task_label>_.
+
+    space_label : :obj:`str`
+        Specifies the space label of the preprocessed bold.nii images.
+        As they are specified in the file names like _space-<space_label>_.
+
+    sub_labels : :obj:`list` of :obj:`str`, optional
+        Specifies the subset of subject labels to model.
+        If 'None', will model all subjects in the dataset.
+
+    img_filters : :obj:`list` of :obj:`tuples` (str, str)
+        Filters are of the form (field, label).
+        Only one filter per field allowed.
+
+    derivatives_path : :obj:`str`
+        Fullpath of the BIDS dataset derivative folder.
+
+    """
+    if not isinstance(dataset_path, (str, Path)):
+        raise TypeError(
+            "'dataset_path' must be a string or pathlike. "
+            f"Got {type(dataset_path)} instead."
+        )
+    dataset_path = Path(dataset_path)
+    if not dataset_path.exists():
+        raise ValueError(f"'dataset_path' does not exist:\n{dataset_path}")
+
+    if not isinstance(derivatives_folder, str):
+        raise TypeError(
+            "'derivatives_folder' must be a string. "
+            f"Got {type(derivatives_folder)} instead."
+        )
+    derivatives_folder = dataset_path / derivatives_folder
+    if not derivatives_folder.exists():
+        raise ValueError(
+            "derivatives folder not found in given dataset:\n"
+            f"{derivatives_folder}"
+        )
+
+    _check_bids_label(task_label)
+
+    if space_label is not None:
+        _check_bids_label(space_label)
+
+    if not isinstance(sub_labels, list):
+        raise TypeError(
+            f"sub_labels must be a list, instead {type(sub_labels)} was given"
+        )
+    for sub_label_ in sub_labels:
+        _check_bids_label(sub_label_)
+
+    if not isinstance(img_filters, list):
+        raise TypeError(
+            f"'img_filters' must be a list. "
+            f"Got {type(img_filters)} instead."
+        )
+    supported_filters = [
+        *_bids_entities()["raw"], *_bids_entities()["derivatives"]
+    ]
+    for filter_ in img_filters:
+        if len(filter_) != 2 or not all(isinstance(x, str) for x in filter_):
+            raise TypeError(
+                "Filters in img_filters must be (str, str). "
+                f"Got {filter_} instead."
+            )
+        if filter_[0] not in supported_filters:
+            raise ValueError(
+                f"Entity {filter_[0]} for {filter_} is not a possible filter. "
+                f"Only {supported_filters} are allowed."
+            )
+        _check_bids_label(filter_[1])
+
+
+def _make_bids_files_filter(
+    task_label,
+    space_label=None,
+    supported_filters= None,
+    extra_filter= None,
+) :
+    """Return a filter to specific files from a BIDS dataset.
+
+    Parameters
+    ----------
+    task_label : :obj:`str`
+        Task label as specified in the file names like _task-<task_label>_.
+
+    space_label : :obj:`str` or None, optional
+        Specifies the space label of the preprocessed bold.nii images.
+        As they are specified in the file names like _space-<space_label>_.
+
+    supported_filters : :obj:`list` of :obj:`str` or None, optional
+        List of authorized BIDS entities
+
+    extra_filter : :obj:`list` of :obj:`tuple` (str, str) or None, optional
+        _description_
+
+    Returns
+    -------
+    Filter to be used by :func:`get_bids_files`: \
+        :obj:`list` of :obj:`tuple` (str, str)
+        filters
+
+    """
+    filters = [("task", task_label)]
+
+    if space_label is not None:
+        filters.append(("space", space_label))
+
+    if extra_filter and supported_filters:
+        for filter_ in extra_filter:
+            if filter_[0] not in supported_filters:
+                warn(
+                    f"The filter {filter_} will be skipped. "
+                    f"'{filter_[0]}' is not among the supported filters. "
+                    f"Allowed filters include: {supported_filters}"
+                )
+                continue
+
+            filters.append(filter_)
+
+    return filters
+
+
+def _check_bids_image_list(
+    imgs, sub_label, filters
+):
+    """Check input BIDS images.
+
+    Check that:
+        - some images were found
+        - if more than one image was found, check that there is not more than
+          one image for a given session / run combination.
+
+    Parameters
+    ----------
+    imgs : :obj:`list` of :obj:`str` or None
+        List of image fullpath filenames.
+
+    sub_label : :obj:`str`
+        Subject label as specified in the file names like _sub-<sub_label>_.
+
+    filters : :obj:`list` of :obj:`tuple` (str, str)
+        Filters of the form (field, label) used to select the files.
+        See :func:`get_bids_files`.
+
+    """
+    if not imgs:
+        raise ValueError(
+            "No BOLD files found "
+            f"for subject {sub_label} "
+            f"for filter: {filters}"
+        )
+
+    if len(imgs) <= 1:
+        return
+
+    msg_start = (
+        "Too many images found\n "
+        f"for subject: '{sub_label}'\n"
+        f"for filters: {filters}\n"
+    )
+    msg_end = (
+        "Please specify it further by setting, "
+        "for example, some required task_label, "
+        "space_label or img_filters"
+    )
+
+    run_check_list: list = []
+
+    for img_ in imgs:
+        parsed_filename = parse_bids_filename(img_)
+        session = parsed_filename.get("ses", None)
+        run = parsed_filename.get("run", None)
+
+        if session and run:
+            if (session, run) in set(run_check_list):
+                raise ValueError(
+                    f"{msg_start}"
+                    f"for the same run {run} and session {session}. "
+                    f"{msg_end}"
+                )
+            run_check_list.append((session, run))
+
+        elif session:
+            if session in set(run_check_list):
+                raise ValueError(
+                    f"{msg_start}"
+                    f"for the same session {session}, "
+                    "while no additional run specification present. "
+                    f"{msg_end}"
+                )
+            run_check_list.append(session)
+
+        elif run:
+            if run in set(run_check_list):
+                raise ValueError(
+                    f"{msg_start}" f"for the same run {run}. " f"{msg_end}"
+                )
+            run_check_list.append(run)
+
+
+def _check_bids_events_list(
+    events,
+    imgs,
+    sub_label,
+    task_label,
+    dataset_path,
+    events_filters,
+):
+    """Check input BIDS events.
+
+    Check that:
+        - some events.tsv files were found
+        - as many events.tsv were found as images
+        - there is only one events.tsv per image and that they have the same
+          raw entities.
+
+    Parameters
+    ----------
+    events : :obj:`list` of :obj:`str` or None
+        List of events.tsv fullpath filenames.
+
+    imgs : :obj:`list` of :obj:`str`
+        List of image fullpath filenames.
+
+    sub_label : :obj:`str`
+        Subject label as specified in the file names like sub-<sub_label>_.
+
+    task_label : :obj:`str`
+        Task label as specified in the file names like _task-<task_label>_.
+
+    dataset_path : :obj:`str`
+        Fullpath to the BIDS dataset.
+
+    events_filters : :obj:`list` of :obj:`tuple` (str, str)
+        Filters of the form (field, label) used to select the files.
+        See :func:`get_bids_files`.
+
+    """
+    if not events:
+        raise ValueError(
+            "No events.tsv files found "
+            f"for subject {sub_label} "
+            f"for filter: {events_filters}."
+        )
+    if len(events) != len(imgs):
+        raise ValueError(
+            f"{len(events)} events.tsv files found"
+            f" for {len(imgs)} bold files. "
+            "Same number of event files "
+            "as the number of runs is expected."
+        )
+
+    supported_filters = [
+        "sub",
+        "ses",
+        "task",
+        *_bids_entities()["raw"],
+    ]
+    for this_img in imgs:
+        parsed_filename = parse_bids_filename(this_img)
+        extra_filter = [
+            (key, parsed_filename[key])
+            for key in parsed_filename
+            if key in supported_filters
+        ]
+        filters = _make_bids_files_filter(
+            task_label=task_label,
+            space_label=None,
+            supported_filters=supported_filters,
+            extra_filter=extra_filter,
+        )
+        this_event = get_bids_files(
+            dataset_path,
+            modality_folder="func",
+            file_tag="events",
+            file_type="tsv",
+            sub_label=sub_label,
+            filters=filters,
+        )
+        msg_suffix = (
+            f" bold file:\n{this_img}\nfilter:\n{filters})\n"
+            "Found all the following events files "
+            f"for filter:\n{events}\n"
+        )
+        if len(this_event) == 0:
+            raise ValueError(
+                f"No events.tsv files " f"corresponding to {msg_suffix}"
+            )
+        if len(this_event) > 1:
+            raise ValueError(
+                f"More than 1 events.tsv files "
+                f"corresponding to {msg_suffix}"
+            )
+        if this_event[0] not in events:
+            raise ValueError(
+                f"\n{this_event} not in {events}.\n"
+                "No corresponding events.tsv files found "
+                f"for {msg_suffix}"
+            )
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
@@ -1,4 +1,7 @@
 """Utility functions for the nilearn.interfaces.bids module."""
+
+from __future__ import annotations
+
 import json
 import warnings
 
@@ -149,3 +152,61 @@ def _generate_dataset_description(out_file, model_level):
 
     with open(out_file, 'w') as f_obj:
         json.dump(dataset_description, f_obj, indent=4, sort_keys=True)
+
+
+def _bids_entities() -> dict[str, list[str]]:
+    """Return a dictionary of BIDS entities.
+
+    Entities are listed in the order they should appear in a filename.
+
+    https://bids-specification.readthedocs.io/en/stable/appendices/entities.html # noqa
+
+    Note that:
+
+    - this only contains the entities for functional data
+
+    https://github.com/bids-standard/bids-specification/blob/master/src/schema/rules/files/raw/func.yaml#L13 # noqa
+    https://github.com/bids-standard/bids-specification/blob/master/src/schema/rules/files/deriv/imaging.yaml#L29 # noqa
+
+    Returns
+    -------
+    Dictionary of raw and derivatives entities : dict[str, list[str]]
+
+    """
+    return {
+        "raw": [
+            "sub",
+            "ses",
+            "task",
+            "acq",
+            "ce",
+            "rec",
+            "dir",
+            "run",
+            "echo",
+            "part",
+        ],
+        "derivatives": ["space", "res", "den", "desc"],
+    }
+
+
+def _check_bids_label(label) -> None:
+    """Validate a BIDS label.
+
+    https://bids-specification.readthedocs.io/en/stable/glossary.html#label-formats # noqa
+
+    Parameters
+    ----------
+    label : Any
+        Label to validate
+
+    """
+    if not isinstance(label, str):
+        raise TypeError(
+            f"All bids labels must be string. "
+            f"Got '{type(label)}' for {label} instead."
+        )
+    if not all(char.isalnum() for char in label):
+        raise ValueError(
+            f"All bids labels must be alphanumeric. Got '{label}' instead."
+        )


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
@@ -1,6 +1,13 @@
+"""Tests for the data generation utilities."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
 import numpy as np
 import pytest
 from nilearn._utils.data_gen import (
+    create_fake_bids_dataset,
     generate_fake_fmri,
     generate_labeled_regions,
     generate_maps,
@@ -9,6 +16,389 @@
 from nilearn.image import get_data
 
 
+def _bids_path_template(
+    task,
+    suffix,
+    n_runs = None,
+    space = None,
+    desc = None,
+    extra_entity = None,
+):
+    """Create a BIDS filepath from a template.
+
+    File path is relative to the BIDS root folder.
+
+    File path contains a session level folder.
+
+    """
+    task = f"task-{task}_*"
+    run = "run-*_*" if n_runs is not None else "*"
+    space = f"space-{space}_*" if space is not None else "*"
+    desc = f"desc-{desc}_*" if desc is not None else "*"
+
+    # only using with resolution and acquisition entities (for now)
+    acq = "*"
+    res = "*"
+    if extra_entity is not None:
+        if "acq" in extra_entity:
+            acq = f"acq-{extra_entity['acq']}_*"
+        elif "res" in extra_entity:
+            res = f"res-{extra_entity['res']}_*"
+
+    path = "sub-*/ses-*/func/sub-*_ses-*_*"
+    path += f"{task}{acq}{run}{space}{res}{desc}{suffix}"
+    # TODO use regex
+    path = path.replace("***", "*")
+    path = path.replace("**", "*")
+    return path
+
+
+@pytest.mark.parametrize("n_sub", [1, 2])
+@pytest.mark.parametrize("n_ses", [1, 2])
+@pytest.mark.parametrize(
+    "tasks,n_runs",
+    [(["main"], [1]), (["main"], [2]), (["main", "localizer"], [2, 1])],
+)
+def test_fake_bids_raw_with_session_and_runs(
+    tmpdir, n_sub, n_ses, tasks, n_runs
+):
+    """Check number of each file 'type' created in raw."""
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmpdir, n_sub=n_sub, n_ses=n_ses, tasks=tasks, n_runs=n_runs
+    )
+
+    # raw
+    file_pattern = "sub-*/ses-*/anat/sub-*ses-*T1w.nii.gz"
+    raw_anat_files = list(bids_path.glob(file_pattern))
+    assert len(raw_anat_files) == n_sub
+
+    for i, task in enumerate(tasks):
+        for suffix in ["bold.nii.gz", "bold.json", "events.tsv"]:
+            file_pattern = _bids_path_template(
+                task=task, suffix=suffix, n_runs=n_runs[i]
+            )
+            files = list(bids_path.glob(file_pattern))
+            assert len(files) == n_sub * n_ses * n_runs[i]
+
+    all_files = list(bids_path.glob("sub-*/ses-*/*/*"))
+    # per subject: 1 anat + (1 event + 1 json + 1 bold) per run per session
+    n_raw_files_expected = n_sub * (1 + 3 * sum(n_runs) * n_ses)
+    assert len(all_files) == n_raw_files_expected
+
+
+def _check_nb_files_derivatives_for_task(
+    bids_path,
+    n_sub,
+    n_ses,
+    task,
+    n_run,
+    extra_entity = None,
+):
+    """Check number of each file 'type' in derivatives for a given task."""
+    for suffix in ["timeseries.tsv"]:
+        # 1 confound per raw file
+        # so we do not use the extra entity for derivatives entities like res
+        if extra_entity is None or "res" in extra_entity:
+            file_pattern = _bids_path_template(
+                task=task,
+                suffix=suffix,
+                n_runs=n_run,
+                extra_entity=None,
+            )
+        elif "acq" in extra_entity:
+            file_pattern = _bids_path_template(
+                task=task,
+                suffix=suffix,
+                n_runs=n_run,
+                extra_entity=extra_entity,
+            )
+
+        files = list(bids_path.glob(f"derivatives/{file_pattern}"))
+        assert len(files) == n_sub * n_ses * n_run
+
+    for space in ["MNI", "T1w"]:
+        file_pattern = _bids_path_template(
+            task=task,
+            suffix="bold.nii.gz",
+            n_runs=n_run,
+            space=space,
+            desc="preproc",
+            extra_entity=extra_entity,
+        )
+        files = list(bids_path.glob(f"derivatives/{file_pattern}"))
+        assert len(files) == n_sub * n_ses * n_run
+
+    # only T1w have desc-fmriprep_bold
+    file_pattern = _bids_path_template(
+        task=task,
+        suffix="bold.nii.gz",
+        n_runs=n_run,
+        space="T1w",
+        desc="fmriprep",
+        extra_entity=extra_entity,
+    )
+    files = list(bids_path.glob(f"derivatives/{file_pattern}"))
+    assert len(files) == n_sub * n_ses * n_run
+
+    file_pattern = _bids_path_template(
+        task=task,
+        suffix="bold.nii.gz",
+        n_runs=n_run,
+        space="MNI",
+        desc="fmriprep",
+        extra_entity=extra_entity,
+    )
+    files = list(bids_path.glob(f"derivatives/{file_pattern}"))
+    assert not files
+
+
+@pytest.mark.parametrize("n_sub", [1, 2])
+@pytest.mark.parametrize("n_ses", [1, 2])
+@pytest.mark.parametrize(
+    "tasks,n_runs",
+    [(["main"], [1]), (["main"], [2]), (["main", "localizer"], [2, 1])],
+)
+def test_fake_bids_derivatives_with_session_and_runs(
+    tmpdir, n_sub, n_ses, tasks, n_runs
+):
+    """Check number of each file 'type' created in derivatives."""
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmpdir, n_sub=n_sub, n_ses=n_ses, tasks=tasks, n_runs=n_runs
+    )
+
+    # derivatives
+    for task, n_run in zip(tasks, n_runs):
+        _check_nb_files_derivatives_for_task(
+            bids_path=bids_path,
+            n_sub=n_sub,
+            n_ses=n_ses,
+            task=task,
+            n_run=n_run,
+        )
+
+    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)
+    assert len(all_files) == n_derivatives_files_expected
+
+
+def test_bids_dataset_no_run_entity(tmpdir):
+    """n_runs = 0 produces files without the run entity."""
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmpdir,
+        n_sub=1,
+        n_ses=1,
+        tasks=["main"],
+        n_runs=[0],
+        with_derivatives=True,
+    )
+
+    files = list(bids_path.glob("**/*run-*"))
+    assert not files
+
+    # nifti: 1 anat + 1 raw bold + 3 derivatives bold
+    files = list(bids_path.glob("**/*.nii.gz"))
+    assert len(files) == 5
+
+    # events or json or confounds: 1
+    for suffix in ["events.tsv", "timeseries.tsv", "bold.json"]:
+        files = list(bids_path.glob(f"**/*{suffix}"))
+        assert len(files) == 1
+
+
+def test_bids_dataset_no_session(tmpdir):
+    """n_ses = 0 prevent creation of a session folder."""
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmpdir,
+        n_sub=1,
+        n_ses=0,
+        tasks=["main"],
+        n_runs=[1],
+        with_derivatives=True,
+    )
+
+    files = list(bids_path.glob("**/*ses-*"))
+    assert not files
+
+    # nifti: 1 anat + 1 raw bold + 3 derivatives bold
+    files = list(bids_path.glob("**/*.nii.gz"))
+    assert len(files) == 5
+
+    # events or json or confounds: 1
+    for suffix in ["events.tsv", "timeseries.tsv", "bold.json"]:
+        files = list(bids_path.glob(f"**/*{suffix}"))
+        assert len(files) == 1
+
+
+def test_create_fake_bids_dataset_no_derivatives(tmpdir):
+    """Check no file is created in derivatives."""
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmpdir,
+        n_sub=1,
+        n_ses=1,
+        tasks=["main"],
+        n_runs=[2],
+        with_derivatives=False,
+    )
+    files = list(bids_path.glob("derivatives/**"))
+    assert not files
+
+
+@pytest.mark.parametrize(
+    "confounds_tag,with_confounds", [(None, True), ("_timeseries", False)]
+)
+def test_create_fake_bids_dataset_no_confounds(
+    tmpdir, confounds_tag, with_confounds
+):
+    """Check that files are created in the derivatives but no confounds."""
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmpdir,
+        n_sub=1,
+        n_ses=1,
+        tasks=["main"],
+        n_runs=[2],
+        with_confounds=with_confounds,
+        confounds_tag=confounds_tag,
+    )
+    assert list(bids_path.glob("derivatives/*"))
+    files = list(bids_path.glob("derivatives/*/*/func/*timeseries.tsv"))
+    assert not files
+
+
+def test_fake_bids_errors(tmpdir):
+    with pytest.raises(ValueError, match="labels.*alphanumeric"):
+        create_fake_bids_dataset(
+            base_dir=tmpdir, n_sub=1, n_ses=1, tasks=["foo_bar"], n_runs=[1]
+        )
+
+    with pytest.raises(ValueError, match="labels.*alphanumeric"):
+        create_fake_bids_dataset(
+            base_dir=tmpdir,
+            n_sub=1,
+            n_ses=1,
+            tasks=["main"],
+            n_runs=[1],
+            entities={"acq": "foo_bar"},
+        )
+
+    with pytest.raises(ValueError, match="number.*tasks.*runs.*same"):
+        create_fake_bids_dataset(
+            base_dir=tmpdir,
+            n_sub=1,
+            n_ses=1,
+            tasks=["main"],
+            n_runs=[1, 2],
+        )
+
+
+def test_fake_bids_extra_raw_entity(tmpdir):
+    """Check files with extra entity are created appropriately."""
+    n_sub = 2
+    n_ses = 2
+    tasks = ["main"]
+    n_runs = [2]
+    entities = {"acq": ["foo", "bar"]}
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmpdir,
+        n_sub=n_sub,
+        n_ses=n_ses,
+        tasks=tasks,
+        n_runs=n_runs,
+        entities=entities,
+    )
+
+    # raw
+    for i, task in enumerate(tasks):
+        for suffix in ["bold.nii.gz", "bold.json", "events.tsv"]:
+            for label in entities["acq"]:
+                file_pattern = _bids_path_template(
+                    task=task,
+                    suffix=suffix,
+                    n_runs=n_runs[i],
+                    extra_entity={"acq": label},
+                )
+                files = list(bids_path.glob(file_pattern))
+                assert len(files) == n_sub * n_ses * n_runs[i]
+
+    all_files = list(bids_path.glob("sub-*/ses-*/*/*"))
+    # per subject:
+    # 1 anat + (1 event + 1 json + 1 bold) per entity per run per session
+    n_raw_files_expected = n_sub * (
+        1 + 3 * sum(n_runs) * n_ses * len(entities["acq"])
+    )
+    assert len(all_files) == n_raw_files_expected
+
+    # derivatives
+    for label in entities["acq"]:
+        for task, n_run in zip(tasks, n_runs):
+            _check_nb_files_derivatives_for_task(
+                bids_path=bids_path,
+                n_sub=n_sub,
+                n_ses=n_ses,
+                task=task,
+                n_run=n_run,
+                extra_entity={"acq": label},
+            )
+
+    all_files = list(bids_path.glob("derivatives/sub-*/ses-*/*/*"))
+    # per subject: (1 confound + 3 bold) per run per session per entity
+    n_derivatives_files_expected = (
+        n_sub * (4 * sum(n_runs) * n_ses) * len(entities["acq"])
+    )
+    assert len(all_files) == n_derivatives_files_expected
+
+
+def test_fake_bids_extra_derivative_entity(tmpdir):
+    """Check files with extra entity are created appropriately."""
+    n_sub = 2
+    n_ses = 2
+    tasks = ["main"]
+    n_runs = [2]
+    entities = {"res": ["foo", "bar"]}
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmpdir,
+        n_sub=n_sub,
+        n_ses=n_ses,
+        tasks=tasks,
+        n_runs=n_runs,
+        entities=entities,
+    )
+
+    # raw
+    all_files = list(bids_path.glob("sub-*/ses-*/*/*res*"))
+    assert not all_files
+
+    # derivatives
+    for label in entities["res"]:
+        for task, n_run in zip(tasks, n_runs):
+            _check_nb_files_derivatives_for_task(
+                bids_path=bids_path,
+                n_sub=n_sub,
+                n_ses=n_ses,
+                task=task,
+                n_run=n_run,
+                extra_entity={"res": label},
+            )
+
+    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
+    n_derivatives_files_expected = n_sub * (
+        1 * sum(n_runs) * n_ses
+        + 3 * sum(n_runs) * n_ses * len(entities["res"])
+    )
+    assert len(all_files) == n_derivatives_files_expected
+
+
+def test_fake_bids_extra_entity_not_bids_entity(tmpdir):
+    """Check files with extra entity are created appropriately."""
+    with pytest.raises(ValueError, match="Invalid entity"):
+        create_fake_bids_dataset(
+            base_dir=tmpdir,
+            entities={"egg": ["spam"]},)
+
+
 @pytest.mark.parametrize("window", ["boxcar", "hamming"])
 def test_generate_regions_ts_no_overlap(window):
     n_voxels = 50
diff --git a/nilearn/glm/tests/test_first_level.py b/nilearn/glm/tests/test_first_level.py
--- a/nilearn/glm/tests/test_first_level.py
+++ b/nilearn/glm/tests/test_first_level.py
@@ -1,9 +1,6 @@
-"""
-Test the first level model.
-"""
 import os
-import shutil
 import unittest.mock
+from pathlib import Path
 
 import numpy as np
 import pandas as pd
@@ -14,22 +11,20 @@
                            assert_array_equal, assert_array_less)
 from sklearn.cluster import KMeans
 
-from nilearn._utils.data_gen import (create_fake_bids_dataset,
+from nilearn._utils.data_gen import (basic_paradigm, create_fake_bids_dataset,
                                      generate_fake_fmri_data_and_design,
-                                     write_fake_fmri_data_and_design,
-                                     basic_paradigm)
+                                     write_fake_fmri_data_and_design)
 from nilearn.glm.contrasts import compute_fixed_effects
 from nilearn.glm.first_level import (FirstLevelModel, first_level_from_bids,
                                      mean_scaling, run_glm)
 from nilearn.glm.first_level.design_matrix import (
     check_design_matrix, make_first_level_design_matrix)
 from nilearn.glm.first_level.first_level import _yule_walker
+from nilearn.glm.regression import ARModel, OLSModel
 from nilearn.image import get_data
 from nilearn.interfaces.bids import get_bids_files
-from nilearn.glm.regression import ARModel, OLSModel
 from nilearn.maskers import NiftiMasker
 
-
 BASEDIR = os.path.dirname(os.path.abspath(__file__))
 FUNCFILE = os.path.join(BASEDIR, 'functional.nii.gz')
 
@@ -80,7 +75,7 @@ def test_high_level_glm_one_session():
 
 
 def test_explicit_fixed_effects():
-    """ tests the fixed effects performed manually/explicitly"""
+    """Test the fixed effects performed manually/explicitly."""
     with InTemporaryDirectory():
         shapes, rk = ((7, 8, 7, 15), (7, 8, 7, 16)), 3
         mask, fmri_data, design_matrices =\
@@ -351,7 +346,6 @@ def test_run_glm():
 
 def test_glm_AR_estimates():
     """Test that Yule-Walker AR fits are correct."""
-
     n, p, q = 1, 500, 2
     X_orig = np.random.RandomState(2).randn(p, q)
     Y_orig = np.random.RandomState(2).randn(p, n)
@@ -410,7 +404,7 @@ def test_glm_random_state(random_state):
 
 
 def test_scaling():
-    """Test the scaling function"""
+    """Test the scaling function."""
     rng = np.random.RandomState(42)
     shape = (400, 10)
     u = rng.standard_normal(size=shape)
@@ -629,119 +623,6 @@ def test_first_level_contrast_computation():
         del func_img, FUNCFILE, model
 
 
-def test_first_level_from_bids():
-    with InTemporaryDirectory():
-        bids_path = create_fake_bids_dataset(n_sub=10, n_ses=2,
-                                             tasks=['localizer', 'main'],
-                                             n_runs=[1, 3])
-        # test arguments are provided correctly
-        with pytest.raises(TypeError):
-            first_level_from_bids(2, 'main', 'MNI')
-        with pytest.raises(ValueError):
-            first_level_from_bids('lolo', 'main', 'MNI')
-        with pytest.raises(TypeError):
-            first_level_from_bids(bids_path, 2, 'MNI')
-        with pytest.raises(TypeError):
-            first_level_from_bids(bids_path, 'main', 'MNI',
-                                  model_init=[])
-        with pytest.raises(TypeError,
-                           match="space_label must be a string"):
-            first_level_from_bids(bids_path, 'main',
-                                  space_label=42)
-
-        with pytest.raises(TypeError,
-                           match="sub_labels must be a list"):
-            first_level_from_bids(bids_path, 'main',
-                                  sub_labels="foo")
-
-        with pytest.raises(TypeError,
-                           match="img_filters must be a list"):
-            first_level_from_bids(bids_path, 'main',
-                                  img_filters="foo")
-
-        with pytest.raises(TypeError,
-                           match="filters in img"):
-            first_level_from_bids(bids_path, 'main',
-                                  img_filters=[(1, 2)])
-
-        with pytest.raises(ValueError,
-                           match="field foo is not a possible filter."):
-            first_level_from_bids(bids_path, 'main',
-                                  img_filters=[("foo", "bar")])
-
-        # test output is as expected
-        models, m_imgs, m_events, m_confounds = first_level_from_bids(
-            bids_path, 'main', 'MNI', img_filters=[('desc', 'preproc')])
-        assert len(models) == len(m_imgs)
-        assert len(models) == len(m_events)
-        assert len(models) == len(m_confounds)
-        # test repeated run tag error when run tag is in filenames
-        # can arise when desc or space is present and not specified
-        with pytest.raises(ValueError):
-            first_level_from_bids(
-                bids_path, 'main', 'T1w')  # desc not specified
-        # test more than one ses file error when run tag is not in filenames
-        # can arise when desc or space is present and not specified
-        with pytest.raises(ValueError):
-            first_level_from_bids(
-                bids_path, 'localizer', 'T1w')  # desc not specified
-        # test issues with confound files. There should be only one confound
-        # file per img. An one per image or None. Case when one is missing
-        confound_files = get_bids_files(os.path.join(bids_path, 'derivatives'),
-                                        file_tag='desc-confounds_timeseries')
-        os.remove(confound_files[-1])
-        with pytest.raises(ValueError):
-            first_level_from_bids(
-                bids_path, 'main', 'MNI')
-        # test issues with event files
-        events_files = get_bids_files(bids_path, file_tag='events')
-        os.remove(events_files[0])
-        # one file missing
-        with pytest.raises(ValueError):
-            first_level_from_bids(bids_path, 'main', 'MNI')
-        for f in events_files[1:]:
-            os.remove(f)
-        # all files missing
-        with pytest.raises(ValueError):
-            first_level_from_bids(bids_path, 'main', 'MNI')
-
-        # In case different desc and spaces exist and are not selected we
-        # fail and ask for more specific information
-        shutil.rmtree(os.path.join(bids_path, 'derivatives'))
-        # issue if no derivatives folder is present
-        with pytest.raises(ValueError):
-            first_level_from_bids(
-                bids_path, 'main', 'MNI')
-
-        # check runs are not repeated when ses field is not used
-        shutil.rmtree(bids_path)
-        bids_path = create_fake_bids_dataset(n_sub=10, n_ses=1,
-                                             tasks=['localizer', 'main'],
-                                             n_runs=[1, 3], no_session=True)
-        # test repeated run tag error when run tag is in filenames and not ses
-        # can arise when desc or space is present and not specified
-        with pytest.raises(ValueError):
-            first_level_from_bids(
-                bids_path, 'main', 'T1w')  # desc not specified
-
-
-def test_first_level_from_bids_with_subject_labels():
-    with InTemporaryDirectory():
-        bids_path = create_fake_bids_dataset(n_sub=10, n_ses=2,
-                                             tasks=['localizer', 'main'],
-                                             n_runs=[1, 3])
-        warning_message = ('Subject label foo is not present in'
-                           ' the dataset and cannot be processed')
-        # check that the incorrect label `foo` raises a warning
-        with pytest.warns(UserWarning, match=warning_message):
-            models, m_imgs, m_events, m_confounds = first_level_from_bids(
-                                  bids_path, 'main',
-                                  sub_labels=["foo", "01"],
-                                  space_label='MNI',
-                                  img_filters=[('desc', 'preproc')])
-            # check that the correct label `01` gets a model
-            assert models[0].subject_label == '01'
-
 def test_first_level_with_scaling():
     shapes, rk = [(3, 1, 1, 2)], 1
     fmri_data = list()
@@ -769,9 +650,10 @@ def test_first_level_with_scaling():
 
 
 def test_first_level_with_no_signal_scaling():
-    """
-    test to ensure that the FirstLevelModel works correctly with a
-    signal_scaling==False. In particular, that derived theta are correct for a
+    """Test to ensure that the FirstLevelModel works correctly
+    with a signal_scaling==False.
+
+    In particular, that derived theta are correct for a
     constant design matrix with a single valued fmri image
     """
     shapes, rk = [(3, 1, 1, 2)], 1
@@ -899,9 +781,10 @@ def test_first_level_predictions_r_square():
     True
 ])
 def test_first_level_hrf_model(hrf_model, spaces):
-    """
-    Ensure that FirstLevelModel runs without raising errors
-    for different values of hrf_model. In particular, one checks that it runs
+    """Ensure that FirstLevelModel runs without raising errors
+    for different values of hrf_model.
+
+    In particular, one checks that it runs
     without raising errors when given a custom response function.
     When :meth:`~nilearn.glm.first_level.FirstLevelModel.compute_contrast`
     is used errors should be raised when event (ie condition) names are not
@@ -945,3 +828,446 @@ def test_glm_sample_mask():
               sample_masks=sample_mask)
     assert model.design_matrices_[0].shape[0] == 22
     assert model.predicted[0].shape[-1] == 22
+
+
+"""Test the first level model on BIDS datasets."""
+
+
+def _inputs_for_new_bids_dataset():
+    n_sub = 2
+    n_ses = 2
+    tasks = ["main"]
+    n_runs = [2]
+    return n_sub, n_ses, tasks, n_runs
+
+
+@pytest.fixture(scope="session")
+def bids_dataset(tmp_path_factory):
+    """Create a fake BIDS dataset for testing purposes.
+    
+    Only use if the dataset does not need to me modified.
+    """
+    base_dir = tmp_path_factory.mktemp("bids")
+    n_sub, n_ses, tasks, n_runs = _inputs_for_new_bids_dataset()
+    return create_fake_bids_dataset(
+        base_dir=base_dir,
+        n_sub=n_sub,
+        n_ses=n_ses,
+        tasks=tasks,
+        n_runs=n_runs
+    )
+
+
+def _new_bids_dataset(base_dir=Path()):
+    """Create a new BIDS dataset for testing purposes.
+    
+    Use if the dataset needs to be modified after creation.
+    """
+    n_sub, n_ses, tasks, n_runs = _inputs_for_new_bids_dataset()
+    return create_fake_bids_dataset(
+        base_dir=base_dir,
+        n_sub=n_sub,
+        n_ses=n_ses,
+        tasks=tasks,
+        n_runs=n_runs
+    )
+
+
+@pytest.mark.parametrize("n_runs", ([1, 0], [1, 1], [1, 2]))
+@pytest.mark.parametrize("n_ses", [0, 1, 2])
+@pytest.mark.parametrize("task_index", [0, 1])
+@pytest.mark.parametrize("space_label", ["MNI", "T1w"])
+def test_first_level_from_bids(tmp_path, n_runs, n_ses, task_index, space_label):
+    """Test several BIDS structure."""
+    n_sub = 2
+    tasks = ['localizer', 'main']
+
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmp_path,
+        n_sub=n_sub,
+        n_ses=n_ses,
+        tasks=tasks,
+        n_runs=n_runs
+    )
+
+    models, m_imgs, m_events, m_confounds = first_level_from_bids(
+        dataset_path=bids_path,
+        task_label=tasks[task_index],
+        space_label=space_label,
+        img_filters=[("desc", "preproc")]
+    )
+
+    assert len(models) == n_sub
+    assert len(models) == len(m_imgs)
+    assert len(models) == len(m_events)
+    assert len(models) == len(m_confounds)
+
+    n_imgs_expected = n_ses * n_runs[task_index]
+
+    # no run entity in filename or session level
+    # when they take a value of 0 when generating a dataset
+    no_run_entity = n_runs[task_index] <= 1
+    no_session_level =  n_ses <= 1
+
+    if no_session_level:
+        n_imgs_expected = 1 if no_run_entity else n_runs[task_index]
+    elif no_run_entity:
+        n_imgs_expected = n_ses
+
+    assert len(m_imgs[0]) == n_imgs_expected
+
+
+def test_first_level_from_bids_select_one_run_per_session(bids_dataset):
+    n_sub, n_ses, *_ = _inputs_for_new_bids_dataset()
+
+    models, m_imgs, m_events, m_confounds = first_level_from_bids(
+                            dataset_path=bids_dataset, 
+                            task_label='main',
+                            space_label='MNI',
+                            img_filters=[('run', '01'), 
+                                            ('desc', 'preproc')])
+
+    assert len(models) == n_sub
+    assert len(models) == len(m_imgs)
+    assert len(models) == len(m_events)
+    assert len(models) == len(m_confounds)
+
+    n_imgs_expected = n_ses
+    assert len(m_imgs[0]) == n_imgs_expected
+
+
+def test_first_level_from_bids_select_all_runs_of_one_session(bids_dataset):
+    n_sub, _, _, n_runs= _inputs_for_new_bids_dataset()
+
+    models, m_imgs, m_events, m_confounds = first_level_from_bids(
+                            dataset_path=bids_dataset, 
+                            task_label='main',
+                            space_label='MNI',
+                            img_filters=[('ses', '01'), 
+                                            ('desc', 'preproc')])  
+    
+    assert len(models) == n_sub
+    assert len(models) == len(m_imgs)
+    assert len(models) == len(m_events)
+    assert len(models) == len(m_confounds)
+
+    n_imgs_expected = n_runs[0]
+    assert len(m_imgs[0]) == n_imgs_expected
+
+@pytest.mark.parametrize("verbose", [0, 1])
+def test_first_level_from_bids_smoke_test_for_verbose_argument(bids_dataset, verbose):
+    first_level_from_bids(
+        dataset_path=bids_dataset,
+        task_label="main",
+        space_label="MNI",
+        img_filters=[("desc", "preproc")],
+        verbose=verbose,
+    )
+
+
+@pytest.mark.parametrize(
+    "entity", ["acq", "ce", "dir", "rec", "echo", "res", "den"]
+)
+def test_first_level_from_bids_several_labels_per_entity(tmp_path, entity):
+    """Correct files selected when an entity has several possible labels.
+    
+    Regression test for https://github.com/nilearn/nilearn/issues/3524
+    """
+    n_sub = 1
+    n_ses = 1
+    tasks = ["main"]
+    n_runs = [1]
+
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmp_path,
+        n_sub=n_sub,
+        n_ses=n_ses,
+        tasks=tasks,
+        n_runs=n_runs,
+        entities={entity: ["A", "B"]},
+    )
+
+    models, m_imgs, m_events, m_confounds = first_level_from_bids(
+        dataset_path=bids_path,
+        task_label="main",
+        space_label="MNI",
+        img_filters=[("desc", "preproc"), (entity, "A")],
+    )
+    assert len(models) == n_sub
+    assert len(models) == len(m_imgs)
+    assert len(models) == len(m_events)
+    assert len(models) == len(m_confounds)
+
+    n_imgs_expected = n_ses * n_runs[0]
+    assert len(m_imgs[0]) == n_imgs_expected
+
+
+def test_first_level_from_bids_with_subject_labels(bids_dataset):
+    """Test that the subject labels arguments works \
+    with proper warning for missing subjects.
+    
+    Check that the incorrect label `foo` raises a warning,
+    but that we still get a model for existing subject.        
+    """
+    warning_message = ('Subject label foo is not present in'
+                        ' the dataset and cannot be processed')
+    with pytest.warns(UserWarning, match=warning_message):
+        models, *_ = first_level_from_bids(
+                                dataset_path=bids_dataset,
+                                task_label='main',
+                                sub_labels=["foo", "01"],
+                                space_label='MNI',
+                                img_filters=[('desc', 'preproc')])
+        assert models[0].subject_label == '01'
+
+
+def test_first_level_from_bids_no_duplicate_sub_labels(bids_dataset):
+    """Make sure that if a subject label is repeated, \
+    only one model is created.
+    
+    See https://github.com/nilearn/nilearn/issues/3585
+    """
+    models, *_ = first_level_from_bids(
+                            dataset_path=bids_dataset,
+                            task_label='main',
+                            sub_labels=["01", "01"],
+                            space_label='MNI',
+                            img_filters=[('desc', 'preproc')])  
+    
+    assert len(models) == 1
+
+
+def test_first_level_from_bids_validation_input_dataset_path():
+    with pytest.raises(TypeError, match='must be a string or pathlike'):
+        first_level_from_bids(dataset_path=2,
+                              task_label="main",
+                              space_label="MNI")
+    with pytest.raises(ValueError, match="'dataset_path' does not exist"):
+        first_level_from_bids(dataset_path="lolo",
+                              task_label="main",
+                              space_label="MNI")
+    with pytest.raises(TypeError, match="derivatives_.* must be a string"):
+        first_level_from_bids(dataset_path=Path(),
+                              task_label="main",
+                              space_label="MNI",
+                              derivatives_folder=1)        
+
+
+@pytest.mark.parametrize("task_label, error_type", 
+                         [(42, TypeError), 
+                          ("$$$", ValueError)],
+                         )
+def test_first_level_from_bids_validation_task_label(bids_dataset,
+                                                     task_label,
+                                                     error_type):
+    with pytest.raises(error_type, 
+                        match="All bids labels must be "):
+        first_level_from_bids(dataset_path=bids_dataset,
+                                task_label=task_label,
+                                space_label="MNI")
+
+
+@pytest.mark.parametrize("sub_labels, error_type, error_msg", 
+                         [("42", TypeError, 'must be a list'), 
+                          (["1", 1], TypeError, 'must be string'),
+                          ([1], TypeError, 'must be string')],
+                         )
+def test_first_level_from_bids_validation_sub_labels(bids_dataset,
+                                                      sub_labels,
+                                                      error_type,
+                                                      error_msg):
+    with pytest.raises(error_type, match =  error_msg):
+        first_level_from_bids(
+            dataset_path=bids_dataset,
+            task_label="main",
+            sub_labels=sub_labels
+        )        
+
+@pytest.mark.parametrize("space_label, error_type", 
+                         [(42, TypeError), 
+                          ("$$$", ValueError)],
+                         )
+def test_first_level_from_bids_validation_space_label(bids_dataset,
+                                                      space_label,
+                                                      error_type):
+    with pytest.raises(error_type, 
+                        match="All bids labels must be "):
+        first_level_from_bids(
+            dataset_path=bids_dataset,
+            task_label="main",
+            space_label=space_label
+        )           
+
+
+@pytest.mark.parametrize(
+    "img_filters, error_type,match", [
+        ("foo", TypeError, "'img_filters' must be a list"),
+        ([(1, 2)], TypeError, "Filters in img"),
+        ([("desc", "*/-")], ValueError, "bids labels must be alphanumeric."),
+        ([("foo", "bar")], ValueError, "is not a possible filter."),
+    ]
+)
+def test_first_level_from_bids_validation_img_filter(bids_dataset,
+                                                     img_filters,
+                                                     error_type,
+                                                     match):
+    with pytest.raises(error_type, match=match):
+        first_level_from_bids(
+            dataset_path=bids_dataset,
+            task_label="main",
+            img_filters=img_filters
+        )
+
+
+def test_first_level_from_bids_too_many_bold_files(bids_dataset):
+    """Too many bold files if img_filters is underspecified,
+    should raise an error.
+
+    Here there is a desc-preproc and desc-fmriprep image for the space-T1w.
+    """
+    with pytest.raises(ValueError,
+                        match="Too many images found"):
+        first_level_from_bids(
+            dataset_path=bids_dataset, task_label="main", space_label="T1w"
+        )
+
+
+def test_first_level_from_bids_with_missing_events(tmp_path_factory):
+    """All events.tsv files are missing, should raise an error."""
+    bids_dataset = _new_bids_dataset(tmp_path_factory.mktemp("no_events"))
+    events_files = get_bids_files(main_path=bids_dataset, file_tag="events")
+    for f in events_files:
+        os.remove(f)
+
+    with pytest.raises(ValueError, match="No events.tsv files found"):
+        first_level_from_bids(
+            dataset_path=bids_dataset, task_label="main", space_label="MNI"
+        )        
+
+
+def test_first_level_from_bids_no_bold_file(tmp_path_factory):
+    bids_dataset = _new_bids_dataset(tmp_path_factory.mktemp("no_bold"))
+    imgs = get_bids_files(main_path=bids_dataset / "derivatives", 
+                            file_tag="bold",
+                            file_type="*gz")
+    for img_ in imgs:
+        os.remove(img_)
+
+    with pytest.raises(ValueError, match="No BOLD files found "):
+        first_level_from_bids(
+            dataset_path=bids_dataset, task_label="main", space_label="MNI"
+        )            
+
+
+def test_first_level_from_bids_with_one_events_missing(tmp_path_factory):
+    """Only one events.tsv file is missing, should raise an error."""
+    bids_dataset = _new_bids_dataset(tmp_path_factory.mktemp("one_event_missing"))
+    events_files = get_bids_files(main_path=bids_dataset, file_tag="events")
+    os.remove(events_files[0])
+
+    with pytest.raises(
+        ValueError, match="Same number of event files "
+    ):
+        first_level_from_bids(
+            dataset_path=bids_dataset, task_label="main", space_label="MNI"
+        )
+
+
+def test_first_level_from_bids_one_confound_missing(tmp_path_factory):
+    """There must be only one confound file per image or none
+
+    If only one is missing, it should raise an error.
+    """
+    bids_dataset = _new_bids_dataset(tmp_path_factory.mktemp("one_confound_missing"))  
+    confound_files = get_bids_files(
+        main_path=bids_dataset / "derivatives",
+        file_tag="desc-confounds_timeseries",
+    )
+    os.remove(confound_files[-1])
+
+    with pytest.raises(ValueError, match="Same number of confound"):
+        first_level_from_bids(
+            dataset_path=bids_dataset, task_label="main", space_label="MNI"
+        )
+
+
+def test_first_level_from_bids_all_confounds_missing(tmp_path_factory):
+    """If all confound files are missing, confounds should be an array of None."""
+    bids_dataset = _new_bids_dataset(tmp_path_factory.mktemp("no_confounds"))    
+    confound_files = get_bids_files(
+        main_path=bids_dataset / "derivatives",
+        file_tag="desc-confounds_timeseries",
+    )
+    for f in confound_files:
+        os.remove(f)
+
+    models, m_imgs, m_events, m_confounds = first_level_from_bids(
+        dataset_path=bids_dataset,
+        task_label="main",
+        space_label="MNI",
+        img_filters=[("desc", "preproc")],
+        verbose=0,
+    )
+
+    assert len(models) == len(m_imgs)
+    assert len(models) == len(m_events)
+    assert len(models) == len(m_confounds)
+    for condounds_ in m_confounds:
+        assert condounds_ is None
+
+
+def test_first_level_from_bids_no_derivatives(tmp_path):
+    """Raise error if the derivative folder does not exist."""
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmp_path,
+        n_sub=1,
+        n_ses=1,
+        tasks=["main"],
+        n_runs=[1],
+        with_derivatives=False,
+    )
+    with pytest.raises(ValueError, match="derivatives folder not found"):
+        first_level_from_bids(
+            dataset_path=bids_path, task_label="main", space_label="MNI"
+        )
+
+
+def test_first_level_from_bids_no_session(tmp_path):
+    """Check runs are not repeated when ses field is not used."""
+    bids_path = create_fake_bids_dataset(
+        base_dir=tmp_path,
+        n_sub=3,
+        n_ses=0,
+        tasks=["main"],
+        n_runs=[2]
+    )
+    # repeated run entity error 
+    # when run entity is in filenames and not ses
+    # can arise when desc or space is present and not specified
+    with pytest.raises(ValueError,
+                        match="Too many images found"):
+        first_level_from_bids(
+            dataset_path=bids_path, task_label="main", space_label="T1w"
+        )
+
+
+def test_first_level_from_bids_mismatch_run_index(tmp_path_factory):
+    """Test error when run index is zero padded in raw but not in derivatives.
+    
+    Regression test for https://github.com/nilearn/nilearn/issues/3029
+    
+    """
+    bids_dataset = _new_bids_dataset(tmp_path_factory.mktemp("renamed_runs")) 
+    files_to_rename = (bids_dataset / "derivatives").glob("**/func/*_task-main_*desc-*")
+    for file_ in files_to_rename:
+        new_file = file_.parent / file_.name.replace("run-0", "run-")
+        file_.rename(new_file)
+
+    with pytest.raises(ValueError, match=".*events.tsv files.*"):
+        first_level_from_bids(
+            dataset_path=bids_dataset,
+            task_label="main",
+            space_label="MNI",
+            img_filters=[("desc", "preproc")]
+        )
+
diff --git a/nilearn/glm/tests/test_second_level.py b/nilearn/glm/tests/test_second_level.py
--- a/nilearn/glm/tests/test_second_level.py
+++ b/nilearn/glm/tests/test_second_level.py
@@ -227,21 +227,21 @@ def test_infer_effect_maps():
     # with InTemporaryDirectory():
     shapes, rk = ((7, 8, 9, 1), (7, 8, 7, 16)), 3
     mask, fmri_data, design_matrices = write_fake_fmri_data_and_design(shapes,
-                                                                       rk)
+                                                                    rk)
     func_img = load(fmri_data[0])
     second_level_input = pd.DataFrame({'map_name': ["a", "b"],
-                                       'effects_map_path': [fmri_data[0],
+                                    'effects_map_path': [fmri_data[0],
                                                             "bar"]})
     assert _infer_effect_maps(second_level_input, "a") == [fmri_data[0]]
     with pytest.raises(ValueError,
-                       match="File not found: 'bar'"):
+                    match="File not found: 'bar'"):
         _infer_effect_maps(second_level_input, "b")
     assert _infer_effect_maps([fmri_data[0]], None) == [fmri_data[0]]
     contrast = np.eye(rk)[1]
     second_level_input = [FirstLevelModel(mask_img=mask)] * 2
     for i, model in enumerate(second_level_input):
         model.fit(fmri_data[i],
-                  design_matrices=design_matrices[i])
+                design_matrices=design_matrices[i])
     assert len(_infer_effect_maps(second_level_input, contrast)) == 2
     # Delete objects attached to files to avoid WindowsError when deleting
     # temporary directory (in Windows)