From 46a4ae1efee7d4c874360d4ae6d6816930fcb1a1 Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Wed, 19 Aug 2026 17:01:10 +0200 Subject: [PATCH 1/3] fix(experiment): :bug: Respect a Joblib launcher pinned to one job `launcher_is_parallel` classified any non-builtin launcher as concurrent from its `_target_` alone. joblib's `Parallel(n_jobs=1)` selects the sequential backend and runs every job in the calling process, so a run with `hydra/launcher=joblib hydra.launcher.n_jobs=1` was serial on both levels: Hydra ran the jobs one at a time and `batch_run` skipped its parallel branch believing Hydra had it covered. `parallels` was ignored exactly as in #169, only in a narrower configuration. The check now reads `n_jobs` as well. joblib is the only launcher that spells its concurrency knob that way; every other value, the -1 default and an absent key all leave the launcher classified as parallel. An `n_jobs` that cannot be resolved keeps that optimistic default rather than raising out of a predicate and aborting the run. Reported by CodeRabbit on #173. --- abses/core/experiment.py | 21 ++++++++++++++++++--- tests/core/test_experiment.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/abses/core/experiment.py b/abses/core/experiment.py index 44e7a55..7c58ef8 100644 --- a/abses/core/experiment.py +++ b/abses/core/experiment.py @@ -40,6 +40,7 @@ from hydra.core.hydra_config import HydraConf, HydraConfig from joblib import Parallel, delayed from omegaconf import DictConfig, OmegaConf +from omegaconf.errors import OmegaConfBaseException from tqdm.auto import tqdm from abses.core.job_manager import ExperimentManager @@ -102,8 +103,9 @@ def relative_path_from_to(from_path: Path, to_path: Path) -> Path: # Hydra ships exactly one built-in launcher, `BasicLauncher`, and it is serial. # Anything outside this prefix is a third-party launcher plugin, which we treat -# as parallel: the launchers that exist in practice (joblib, submitit, ray) all -# are, and assuming parallel only costs us a layer of nesting we skip. +# as parallel unless its own config says otherwise: the launchers that exist in +# practice (joblib, submitit, ray) all are, and assuming parallel only costs us +# a layer of nesting we skip. _SERIAL_LAUNCHER_PREFIX = "hydra._internal.core_plugins." @@ -125,7 +127,20 @@ def launcher_is_parallel(launcher: Optional[DictConfig]) -> bool: if not launcher: return False target = str(launcher.get("_target_", "")) - return bool(target) and not target.startswith(_SERIAL_LAUNCHER_PREFIX) + if not target or target.startswith(_SERIAL_LAUNCHER_PREFIX): + return False + # A plugin can still be told to run serially. `n_jobs` is joblib's knob and + # joblib is the only launcher that spells it that way; `n_jobs: 1` selects + # joblib's sequential backend, which runs every job in the calling process + # exactly like BasicLauncher does. Other values -- including the -1 default + # and an absent key -- leave the launcher concurrent. + try: + n_jobs = OmegaConf.select(launcher, "n_jobs", default=None) + except OmegaConfBaseException: + # An unreadable value tells us nothing; keep the optimistic default + # rather than aborting the run from inside a predicate. + return True + return n_jobs != 1 def run_single( diff --git a/tests/core/test_experiment.py b/tests/core/test_experiment.py index 17a6e23..be7dda6 100644 --- a/tests/core/test_experiment.py +++ b/tests/core/test_experiment.py @@ -130,6 +130,36 @@ def test_plugin_launchers_are_parallel(self, target): launcher = OmegaConf.create({"_target_": target}) assert launcher_is_parallel(launcher) is True + def test_joblib_with_one_job_is_not_parallel(self): + """`n_jobs: 1` makes joblib run every job in the calling process. + + joblib's `Parallel(n_jobs=1)` uses the sequential backend, so this + launcher is as serial as BasicLauncher despite being a plugin. + """ + launcher = OmegaConf.create( + { + "_target_": "hydra_plugins.hydra_joblib_launcher" + ".joblib_launcher.JoblibLauncher", + "n_jobs": 1, + } + ) + assert launcher_is_parallel(launcher) is False + + def test_unreadable_n_jobs_falls_back_to_parallel(self): + """An `n_jobs` we cannot read must not crash the run. + + This is a predicate on the way to `batch_run`; raising here would abort + the whole experiment. Assuming parallel only costs a layer of nesting. + """ + launcher = OmegaConf.create( + { + "_target_": "hydra_plugins.hydra_joblib_launcher" + ".joblib_launcher.JoblibLauncher", + "n_jobs": "${undefined_key}", + } + ) + assert launcher_is_parallel(launcher) is True + @contextmanager def _inside_hydra_job(launcher_target: str, output_dir: Path): From 250b0e22d24a1cdc5061979b90d6dcba650ed7ff Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Wed, 19 Aug 2026 17:01:11 +0200 Subject: [PATCH 2/3] chore(ci): :lock: Narrow the back-merge job to read access on contents The job checks out, fetches, and opens a pull request against a branch that already exists on the remote; it never pushes. `contents: read` is enough for the first two and `pull-requests: write` covers the third. Reported by CodeRabbit on #173. --- .github/workflows/release-please.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b68cd83..a6b84ce 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -62,7 +62,7 @@ jobs: if: ${{ needs.release-please.outputs.release_created }} runs-on: ubuntu-latest permissions: - contents: write + contents: read pull-requests: write steps: - uses: actions/checkout@v4 From 02629602ad701b75377b99cfb02dc8db93fd19a4 Mon Sep 17 00:00:00 2001 From: SongshGeo Date: Wed, 19 Aug 2026 17:01:11 +0200 Subject: [PATCH 3/3] chore: :lock: Sync the lockfile to the released version `uv.lock` still recorded abses 0.10.0, left over from the version drift between dev and master. Any `uv run` regenerated it and dirtied the working tree. --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index fd5c1e0..3197164 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "abses" -version = "0.10.0" +version = "0.11.7" source = { editable = "." } dependencies = [ { name = "fiona" },