Repository: nilearn/nilearn (Python)

Issue:
[BUG] doc build failure on main for several examples
All of them point to nilearn/mass_univariate/permuted_least_squares.py

I believe that this must be due to https://github.com/nilearn/nilearn/pull/4875

But the fact that our test suite did not catch this means we have a blind spot somewhere.


##  02_decoding/plot_oasis_vbm.py

https://github.com/nilearn/nilearn/actions/runs/12201002128/job/34077197805#step:13:27267

```
    Traceback (most recent call last):
      File "/home/runner/work/nilearn/nilearn/examples/02_decoding/plot_oasis_vbm.py", line 196, in <module>
        neg_log_pvals, t_scores_original_data, _ = permuted_ols(
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/nilearn/mass_univariate/permuted_least_squares.py", line 781, in permuted_ols
        ret = joblib.Parallel(n_jobs=n_jobs, verbose=verbose)(
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/joblib/parallel.py", line 1098, in __call__
        self.retrieve()
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/joblib/parallel.py", line 975, in retrieve
        self._output.extend(job.get(timeout=self.timeout))
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/joblib/_parallel_backends.py", line 567, in wrap_future_result
        return future.result(timeout=timeout)
      File "/opt/hostedtoolcache/Python/3.9.20/x64/lib/python3.9/concurrent/futures/_base.py", line 446, in result
        return self.__get_result()
      File "/opt/hostedtoolcache/Python/3.9.20/x64/lib/python3.9/concurrent/futures/_base.py", line 391, in __get_result
        raise self._exception
    _pickle.PicklingError: Could not pickle the task to send it to the workers.
```

## 05_glm_second_level/plot_second_level_association_test.py

https://github.com/nilearn/nilearn/actions/runs/12201002128/job/34077197805#step:13:27286

```
    Traceback (most recent call last):
      File "/home/runner/work/nilearn/nilearn/examples/05_glm_second_level/plot_second_level_association_test.py", line 137, in <module>
        neg_log_pvals_permuted_ols_unmasked = non_parametric_inference(
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/nilearn/glm/second_level/second_level.py", line 1119, in non_parametric_inference
        outputs = permuted_ols(
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/nilearn/mass_univariate/permuted_least_squares.py", line 781, in permuted_ols
        ret = joblib.Parallel(n_jobs=n_jobs, verbose=verbose)(
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/joblib/parallel.py", line 1098, in __call__
        self.retrieve()
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/joblib/parallel.py", line 975, in retrieve
        self._output.extend(job.get(timeout=self.timeout))
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/joblib/_parallel_backends.py", line 567, in wrap_future_result
        return future.result(timeout=timeout)
      File "/opt/hostedtoolcache/Python/3.9.20/x64/lib/python3.9/concurrent/futures/_base.py", line 439, in result
        return self.__get_result()
      File "/opt/hostedtoolcache/Python/3.9.20/x64/lib/python3.9/concurrent/futures/_base.py", line 391, in __get_result
        raise self._exception
    _pickle.PicklingError: Could not pickle the task to send it to the workers.
```

## 07_advanced/plot_haxby_mass_univariate.py

https://github.com/nilearn/nilearn/actions/runs/12201002128/job/34077197805#step:13:27307

```
     Traceback (most recent call last):
      File "/home/runner/work/nilearn/nilearn/examples/07_advanced/plot_haxby_mass_univariate.py", line 104, in <module>
        neg_log_pvals, t_scores_original_data, _ = permuted_ols(
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/nilearn/mass_univariate/permuted_least_squares.py", line 781, in permuted_ols
        ret = joblib.Parallel(n_jobs=n_jobs, verbose=verbose)(
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/joblib/parallel.py", line 1098, in __call__
        self.retrieve()
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/joblib/parallel.py", line 975, in retrieve
        self._output.extend(job.get(timeout=self.timeout))
      File "/home/runner/work/nilearn/nilearn/.tox/doc/lib/python3.9/site-packages/joblib/_parallel_backends.py", line 567, in wrap_future_result
        return future.result(timeout=timeout)
      File "/opt/hostedtoolcache/Python/3.9.20/x64/lib/python3.9/concurrent/futures/_base.py", line 439, in result
        return self.__get_result()
      File "/opt/hostedtoolcache/Python/3.9.20/x64/lib/python3.9/concurrent/futures/_base.py", line 391, in __get_result
        raise self._exception
    _pickle.PicklingError: Could not pickle the task to send it to the workers.
```


Discussion:
Indeed !



Patch:
diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
index 9e157275513..1f901cf4a68 100644
--- a/.github/workflows/build-docs.yml
+++ b/.github/workflows/build-docs.yml
@@ -81,8 +81,6 @@ jobs:
             uses: actions/checkout@v4
             with:
                 ref: ${{ github.event.pull_request.head.sha }}
-        -   name: Merge with upstream
-            run: ./build_tools/github/merge_upstream.sh
         -   name: Setup python
             uses: actions/setup-python@v5
             with:
diff --git a/doc/conf.py b/doc/conf.py
index 1e4463b614c..bb13d531e99 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -106,10 +106,15 @@
 # built documents.
 
 # Latest release version
-latest_release = re.match(
-    r"v?([0-9]+.[0-9]+.[0-9]+).*",
-    os.popen("git describe --tags").read().strip(),
-).groups()[0]
+try:
+    latest_release = re.match(
+        r"v?([0-9]+.[0-9]+.[0-9]+).*",
+        os.popen("git describe --tags").read().strip(),
+    ).groups()[0]
+except AttributeError:
+    # This may fail in case the git tags were not fetched.
+    # So let's have a back up.
+    latest_release = "0.11.0"
 
 # The full current version, including alpha/beta/rc tags.
 current_version = __version__
diff --git a/examples/02_decoding/plot_oasis_vbm.py b/examples/02_decoding/plot_oasis_vbm.py
index ff747d1638b..4d44b28e032 100644
--- a/examples/02_decoding/plot_oasis_vbm.py
+++ b/examples/02_decoding/plot_oasis_vbm.py
@@ -56,7 +56,7 @@
 # %%
 import numpy as np
 
-from nilearn import datasets
+from nilearn.datasets import fetch_oasis_vbm
 from nilearn.image import get_data
 from nilearn.maskers import NiftiMasker
 
@@ -65,9 +65,7 @@
 # %%
 # Load Oasis dataset
 # ------------------
-oasis_dataset = datasets.fetch_oasis_vbm(
-    n_subjects=n_subjects, legacy_format=False
-)
+oasis_dataset = fetch_oasis_vbm(n_subjects=n_subjects, legacy_format=False)
 gray_matter_map_filenames = oasis_dataset.gray_matter_maps
 age = oasis_dataset.ext_vars["age"].to_numpy()
 
diff --git a/examples/07_advanced/plot_haxby_mass_univariate.py b/examples/07_advanced/plot_haxby_mass_univariate.py
index c629d467514..5608a613ed5 100644
--- a/examples/07_advanced/plot_haxby_mass_univariate.py
+++ b/examples/07_advanced/plot_haxby_mass_univariate.py
@@ -122,7 +122,8 @@
 
 # f_regression implicitly adds intercept
 _, pvals_bonferroni = f_regression(
-    grouped_fmri_masked, grouped_conditions_encoded
+    grouped_fmri_masked,
+    grouped_conditions_encoded.ravel(),
 )
 pvals_bonferroni *= fmri_masked.shape[1]
 pvals_bonferroni[np.isnan(pvals_bonferroni)] = 1
diff --git a/nilearn/mass_univariate/permuted_least_squares.py b/nilearn/mass_univariate/permuted_least_squares.py
index 55819b049bd..ccadcf8b566 100644
--- a/nilearn/mass_univariate/permuted_least_squares.py
+++ b/nilearn/mass_univariate/permuted_least_squares.py
@@ -762,20 +762,23 @@ def permuted_ols(
     if n_perm > n_jobs:
         n_perm_chunks = np.asarray([n_perm / n_jobs] * n_jobs, dtype=int)
         n_perm_chunks[-1] += n_perm % n_jobs
+    elif n_perm > 0:
+        warnings.warn(
+            f"The specified number of permutations is {n_perm} "
+            "and the number of jobs to be performed in parallel "
+            f"has set to {n_jobs}. "
+            f"This is incompatible so only {n_perm} jobs will be running. "
+            "You may want to perform more permutations "
+            "in order to take the most of the available computing resources.",
+            UserWarning,
+            stacklevel=2,
+        )
+        n_perm_chunks = np.ones(n_perm, dtype=int)
 
     threshold_t = _compute_t_stat_threshold(
         threshold, two_sided_test, tested_vars, confounding_vars
     )
 
-    warnings.warn(
-        f"The specified number of permutations is {n_perm} and the number "
-        f"of jobs to be performed in parallel has set to {n_jobs}. "
-        f"This is incompatible so only {n_perm} jobs will be running. "
-        "You may want to perform more permutations in order to take the "
-        "most of the available computing resources."
-    )
-    n_perm_chunks = np.ones(n_perm, dtype=int)
-
     # actual permutations, seeded from a random integer between 0 and maximum
     # value represented by np.int32 (to have a large entropy).
     ret = joblib.Parallel(n_jobs=n_jobs, verbose=verbose)(
diff --git a/pyproject.toml b/pyproject.toml
index 8a6b7b3fa88..a8a6b62e52d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -233,4 +233,4 @@ max-branches = 27
 # https://docs.astral.sh/ruff/settings/#lint_pylint_max-returns
 max-returns = 7
 # https://docs.astral.sh/ruff/settings/#lint_pylint_max-statements
-max-statements = 107
+max-statements = 110
diff --git a/tox.ini b/tox.ini
index 5c992f55a95..11ee78197a0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -188,7 +188,9 @@ passenv =
     {[global_var]passenv}
 allowlist_externals =
     make
+    git
 commands =
+    git fetch --tags
     make -C doc clean
     ; Update the authors file and the names file
     ; in case a contributor has been added to citation.cff


Test patch:
diff --git a/examples/05_glm_second_level/plot_second_level_association_test.py b/examples/05_glm_second_level/plot_second_level_association_test.py
index 6b2085fa35f..fa273de422a 100644
--- a/examples/05_glm_second_level/plot_second_level_association_test.py
+++ b/examples/05_glm_second_level/plot_second_level_association_test.py
@@ -19,10 +19,10 @@
 
 # %%
 # At first, we need to load the Localizer contrasts.
-from nilearn import datasets
+from nilearn.datasets import fetch_localizer_contrasts
 
 n_samples = 94
-localizer_dataset = datasets.fetch_localizer_contrasts(
+localizer_dataset = fetch_localizer_contrasts(
     ["left button press (auditory cue)"],
     n_subjects=n_samples,
     legacy_format=False,
@@ -84,9 +84,9 @@
 
 # %%
 # Let us plot the second level :term:`contrast` at the computed thresholds.
-from nilearn import plotting
+from nilearn.plotting import plot_stat_map, show
 
-plotting.plot_stat_map(
+plot_stat_map(
     z_map,
     threshold=threshold,
     colorbar=True,
@@ -94,7 +94,7 @@
     "and reading fluency (fdr=0.05)",
 )
 
-plotting.show()
+show()
 
 # %%
 # Computing the (corrected) p-values with parametric test to compare with
@@ -121,14 +121,14 @@
     "Group-level association between motor activity and reading: \n"
     "neg-log of parametric corrected p-values (FWER < 10%)"
 )
-plotting.plot_stat_map(
+plot_stat_map(
     neg_log_pval,
     colorbar=True,
     cut_coords=cut_coords,
     threshold=threshold,
     title=title,
 )
-plotting.show()
+show()
 
 # %%
 # Computing the (corrected) negative log p-values with permutation test
@@ -152,14 +152,14 @@
     "Group-level association between motor activity and reading: \n"
     "neg-log of non-parametric corrected p-values (FWER < 10%)"
 )
-plotting.plot_stat_map(
+plot_stat_map(
     neg_log_pvals_permuted_ols_unmasked,
     colorbar=True,
     cut_coords=cut_coords,
     threshold=threshold,
     title=title,
 )
-plotting.show()
+show()
 
 # The neg-log p-values obtained with non parametric testing are capped at 3
 # since the number of permutations is 1e3.
diff --git a/nilearn/mass_univariate/tests/test_permuted_least_squares.py b/nilearn/mass_univariate/tests/test_permuted_least_squares.py
index 5b1bd528d6b..ea62e5fdfdf 100644
--- a/nilearn/mass_univariate/tests/test_permuted_least_squares.py
+++ b/nilearn/mass_univariate/tests/test_permuted_least_squares.py
@@ -768,6 +768,31 @@ def test_sanitize_inputs_permuted_ols(design):
     )
 
 
+def test_permuted_ols_warnings_n_perm_n_job(cluster_level_design, masker):
+    """Check that proper warning are thrown depending on n_job VS n_perm."""
+    target_var, tested_var = cluster_level_design
+
+    # n_perm > n_job --> no warning
+    with pytest.warns() as record:
+        permuted_ols(
+            tested_var,
+            target_var,
+            n_perm=4,
+            n_jobs=1,
+            masker=masker,
+        )
+    assert not any(
+        "perform more permutations" in str(x.message) for x in record
+    )
+
+    # n_perm <= n_job  and n_job > 0 -->  warning
+    with pytest.warns(
+        UserWarning,
+        match="perform more permutations",
+    ):
+        permuted_ols(tested_var, target_var, n_perm=1, masker=masker, n_jobs=2)
+
+
 def test_cluster_level_parameters_warnings(cluster_level_design, masker):
     """Test combinations of parameters related to cluster-level inference."""
     target_var, tested_var = cluster_level_design