Skip to content

Commit af1ac9c

Browse files
nh13epruesse
andauthored
feat: pre-solved environments for mulled tests (#1082)
## Summary - Pre-solves the mulled test environment on the host using `conda create --dry-run --json`, then passes an `@EXPLICIT` spec file to the container - The container installs from the explicit spec (no solver needed), runs `create-env` post-processing, then executes tests - Graceful fallback: if pre-solve fails for any reason, falls back to the original `mulled-build build-and-test` path - Automatically disabled when `mulled_upload_target` is set (upload needs the Docker image produced by `mulled-build`) - Adds `--no-presolved-mulled-test` CLI flag for opt-out Related: #817 ## Savings 60-180s per mulled test by eliminating a redundant solver run inside the container. ## Risks - **Medium**: `conda create --dry-run --json` output format varies across conda versions. Robust fallback to original path mitigates this. - **Medium**: Bypassing `mulled-build` means maintaining a parallel test execution path. The `create-env --conda=: /usr/local` POSTINSTALL step is replicated from the involucro wrapper. - Backward compatible: graceful fallback ensures original behavior is preserved on any failure. ## Test plan - [x] Run existing test suite: `pytest test/` - [x] Integration test: `bioconda-utils build recipes/ config.yml --docker --mulled-test` on pyfaidx - [x] Verify tests pass identically with and without `--no-presolved-mulled-test` - [x] Test fallback: presolved path failed (host/container platform mismatch on macOS) and correctly fell back to mulled-build - [x] Verify `mulled_upload_target` correctly disables pre-solved path (confirmed: goes straight to mulled-build) --------- Co-authored-by: Elmar Pruesse <epruesse@users.noreply.github.com>
1 parent 3d4c9e1 commit af1ac9c

File tree

3 files changed

+212
-3
lines changed

3 files changed

+212
-3
lines changed

bioconda_utils/build.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ def build(
6262
dag: Optional[nx.DiGraph] = None,
6363
skiplist_leafs: bool = False,
6464
live_logs: bool = True,
65+
presolved_mulled_test: bool = True,
66+
mulled_upload_target=None,
6567
) -> BuildResult:
6668
"""
6769
Build a single recipe for a single env
@@ -198,6 +200,8 @@ def build(
198200
if mulled_test:
199201
logger.info("TEST START via mulled-build %s", recipe)
200202
mulled_images = []
203+
# Use pre-solved test env unless we need the mulled-build image for upload
204+
use_presolved = presolved_mulled_test and not mulled_upload_target
201205
for pkg_path in pkg_paths:
202206
try:
203207
report_resources(f"Starting mulled build for {pkg_path}")
@@ -206,6 +210,7 @@ def build(
206210
base_image=base_image,
207211
conda_image=mulled_conda_image,
208212
live_logs=live_logs,
213+
presolved=use_presolved,
209214
)
210215
except sp.CalledProcessError:
211216
logger.error("TEST FAILED: %s", recipe)
@@ -366,6 +371,7 @@ def build_recipes(
366371
live_logs: bool = True,
367372
exclude: List[str] = None,
368373
subdag_depth: int = None,
374+
presolved_mulled_test: bool = True,
369375
fast_resolve: bool = True,
370376
):
371377
"""
@@ -532,6 +538,8 @@ def build_recipes(
532538
record_build_failure=record_build_failures,
533539
skiplist_leafs=skiplist_leafs,
534540
live_logs=live_logs,
541+
presolved_mulled_test=presolved_mulled_test,
542+
mulled_upload_target=mulled_upload_target,
535543
)
536544

537545
if not res.success:

bioconda_utils/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,12 @@ def do_lint(
619619
action="store_true",
620620
help="Disable live logging during the build process",
621621
)
622+
@arg(
623+
"--no-presolved-mulled-test",
624+
action="store_true",
625+
help="Disable pre-solved mulled tests: always use mulled-build to solve and install "
626+
"the test environment from scratch.",
627+
)
622628
@arg(
623629
"--no-fast-resolve",
624630
action="store_true",
@@ -658,6 +664,7 @@ def build(
658664
record_build_failures=False,
659665
skiplist_leafs=False,
660666
disable_live_logs=False,
667+
no_presolved_mulled_test=False,
661668
no_fast_resolve=False,
662669
exclude=None,
663670
subdag_depth=None,
@@ -733,6 +740,7 @@ def build(
733740
live_logs=(not disable_live_logs),
734741
exclude=exclude,
735742
subdag_depth=subdag_depth,
743+
presolved_mulled_test=not no_presolved_mulled_test,
736744
fast_resolve=not no_fast_resolve,
737745
)
738746
exit(0 if success else 1)

bioconda_utils/pkg_test.py

Lines changed: 196 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Mulled Tests
33
"""
44

5+
import json
6+
import subprocess as sp
57
import tempfile
68
import os
79
import shlex
@@ -87,6 +89,163 @@ def get_image_name(path):
8789
return spec
8890

8991

92+
def _generate_explicit_spec(spec, channels, conda_bld_dir, tmpdir):
93+
"""Generate an @EXPLICIT spec file by dry-running conda create.
94+
95+
Parameters
96+
----------
97+
spec : str
98+
Package spec in name=version--build format
99+
channels : list
100+
List of resolved channel URLs (no "local")
101+
conda_bld_dir : str
102+
Path to local conda-bld directory
103+
tmpdir : str
104+
Directory to write the spec file into
105+
106+
Returns
107+
-------
108+
str or None
109+
Path to explicit spec file, or None on failure
110+
"""
111+
# Convert spec from mulled format (name=version--build) to conda format (name=version=build)
112+
pkg_spec = spec.replace("--", "=")
113+
114+
channel_args = []
115+
for ch in channels:
116+
channel_args += ["-c", ch]
117+
118+
cmd = (
119+
[
120+
"conda",
121+
"create",
122+
"--dry-run",
123+
"--json",
124+
"--override-channels",
125+
]
126+
+ channel_args
127+
+ [pkg_spec]
128+
)
129+
130+
logger.debug("Generating explicit spec: %s", cmd)
131+
try:
132+
result = sp.run(
133+
cmd,
134+
capture_output=True,
135+
text=True,
136+
timeout=300,
137+
)
138+
if result.returncode != 0:
139+
logger.debug("conda create --dry-run failed: %s", result.stderr)
140+
return None
141+
142+
data = json.loads(result.stdout)
143+
actions = data.get("actions", {})
144+
link_actions = actions.get("LINK", [])
145+
if not link_actions:
146+
logger.debug("No LINK actions in dry-run output")
147+
return None
148+
149+
# Build @EXPLICIT spec file
150+
spec_path = os.path.join(tmpdir, "explicit_spec.txt")
151+
with open(spec_path, "w") as f:
152+
f.write("@EXPLICIT\n")
153+
for pkg in link_actions:
154+
url = pkg.get("url")
155+
if not url:
156+
# Build URL from channel + subdir + fn
157+
channel = pkg.get("channel", "")
158+
subdir = pkg.get("platform", pkg.get("subdir", ""))
159+
fn = pkg.get("dist_name", "")
160+
if fn and not fn.endswith((".tar.bz2", ".conda")):
161+
fn += ".conda"
162+
url = f"{channel}/{subdir}/{fn}"
163+
md5 = pkg.get("md5", "")
164+
line = url
165+
if md5:
166+
line += f"#{md5}"
167+
f.write(line + "\n")
168+
169+
logger.debug("Generated explicit spec with %d packages", len(link_actions))
170+
return spec_path
171+
172+
except (sp.TimeoutExpired, json.JSONDecodeError, KeyError) as exc:
173+
logger.debug("Failed to generate explicit spec: %s", exc)
174+
return None
175+
176+
177+
def _test_with_explicit_spec(
178+
spec_path, tests, base_image, conda_image, conda_bld_dir, live_logs
179+
):
180+
"""Run mulled test using a pre-solved explicit spec file.
181+
182+
Parameters
183+
----------
184+
spec_path : str
185+
Path to @EXPLICIT spec file
186+
tests : str
187+
Test commands to run
188+
base_image : str
189+
Base image for the test container
190+
conda_image : str
191+
Conda image used to install packages
192+
conda_bld_dir : str
193+
Path to local conda-bld directory (needed for file:// URLs)
194+
live_logs : bool
195+
Enable live log output
196+
"""
197+
# Build a test script that:
198+
# 1. Creates env from explicit spec
199+
# 2. Runs create-env post-processing (activation/entrypoint scripts)
200+
# 3. Runs the test commands
201+
test_script = f"""#!/bin/bash
202+
set -eo pipefail
203+
204+
# Install from pre-solved explicit spec (no solver needed)
205+
conda create --name test --file /opt/explicit_spec.txt --yes --quiet
206+
207+
# Run create-env to set up activation/entrypoint scripts
208+
# This replicates the POSTINSTALL step from involucro
209+
create-env --conda=: /usr/local
210+
211+
# Run tests
212+
{tests}
213+
"""
214+
215+
with tempfile.TemporaryDirectory() as tmpdir:
216+
script_path = os.path.join(tmpdir, "test_script.bash")
217+
with open(script_path, "w") as f:
218+
f.write(test_script)
219+
220+
cmd = [
221+
"docker",
222+
"run",
223+
"-t",
224+
"--net",
225+
"host",
226+
"--rm",
227+
"-v",
228+
f"{script_path}:/opt/test_script.bash:ro",
229+
"-v",
230+
f"{spec_path}:/opt/explicit_spec.txt:ro",
231+
"-v",
232+
f"{conda_bld_dir}:{conda_bld_dir}:ro",
233+
]
234+
235+
env_args = []
236+
if base_image is not None:
237+
env_args += ["-e", f"DEST_BASE_IMAGE={base_image}"]
238+
239+
cmd += env_args
240+
cmd += [conda_image]
241+
cmd += ["/bin/bash", "/opt/test_script.bash"]
242+
243+
logger.debug("Pre-solved mulled test command: %s", cmd)
244+
with utils.Progress():
245+
p = utils.run(cmd, mask=False, live=live_logs)
246+
return p
247+
248+
90249
def test_package(
91250
path,
92251
name_override=None,
@@ -95,6 +254,7 @@ def test_package(
95254
base_image=None,
96255
conda_image=MULLED_CONDA_IMAGE,
97256
live_logs=True,
257+
presolved=True,
98258
):
99259
"""
100260
Tests a built package in a minimal docker container.
@@ -125,6 +285,11 @@ def test_package(
125285
126286
live_logs : True | bool
127287
If True, enable live logging during the build process
288+
289+
presolved : bool
290+
If True, attempt to pre-solve the test environment on the host and
291+
pass an @EXPLICIT spec file to the container, avoiding a redundant
292+
solver run. Falls back to the original mulled-build path on failure.
128293
"""
129294

130295
assert path.endswith((".tar.bz2", ".conda")), "Unrecognized path {0}".format(path)
@@ -139,16 +304,44 @@ def test_package(
139304
if "local" not in channels:
140305
raise ValueError('"local" must be in channel list')
141306

142-
channels = [
307+
resolved_channels = [
143308
"file://{0}".format(conda_bld_dir) if channel == "local" else channel
144309
for channel in channels
145310
]
146311

147-
channel_args = ["--channels", ",".join(channels)]
148-
149312
tests = get_tests(path)
150313
logger.debug("Tests to run: %s", tests)
151314

315+
# Try the pre-solved path first for faster testing
316+
if presolved:
317+
try:
318+
with tempfile.TemporaryDirectory() as tmpdir:
319+
spec_path = _generate_explicit_spec(
320+
spec,
321+
resolved_channels,
322+
conda_bld_dir,
323+
tmpdir,
324+
)
325+
if spec_path is not None:
326+
logger.info("Using pre-solved explicit spec for mulled test")
327+
return _test_with_explicit_spec(
328+
spec_path,
329+
tests,
330+
base_image,
331+
conda_image,
332+
conda_bld_dir,
333+
live_logs,
334+
)
335+
else:
336+
logger.info("Pre-solve failed, falling back to mulled-build")
337+
except Exception as exc:
338+
logger.info(
339+
"Pre-solved test failed (%s), falling back to mulled-build", exc
340+
)
341+
342+
# Original mulled-build path
343+
channel_args = ["--channels", ",".join(resolved_channels)]
344+
152345
cmd = [
153346
"mulled-build",
154347
"build-and-test",

0 commit comments

Comments
 (0)