Skip to content

feat: install pip deps constrained to conda's resolved versions - #61

Merged
johnyaku merged 1 commit into
mainfrom
feat/conda-pip-constraints
Jun 17, 2026
Merged

johnyaku merged 1 commit into
mainfrom
feat/conda-pip-constraints

Conversation

@johnyaku

Copy link
Copy Markdown
Contributor

Problem

When an environment.yaml mixes conda dependencies with a pip: section, micromamba create -f env.yaml hands the pip subsection to a plain pip install with no constraints. pip doesn't know conda's pins exist, so a pip package (e.g. torch requiring numpy>=2) will silently upgrade the conda-installed numpy past its pin — leaving an environment that violates the conda spec, sometimes with ABI breakage.

This is inherent to composing two independent resolvers over one prefix: conda records its solve in conda-meta/, pip in *.dist-info, and neither treats the other's record as a constraint. conda's own docs flag pip-after-conda as a known hazard. Docker didn't cause it (same thing happens with a plain conda env create), but our build is the one place that sees both halves — so that's where the coordination belongs.

Fix

Split the pip: section out of the conda solve and install it in a second phase constrained to the versions conda just resolved:

RUN micromamba create -y -n env -f /tmp/env.yaml &&     micromamba run -n env python -m pip list --format=freeze > /tmp/conda.constraints.txt &&     micromamba run -n env python -m pip install --no-cache-dir         --constraint /tmp/conda.constraints.txt --requirement /tmp/requirements.txt &&     micromamba run -n env python -m pip check &&     ...
  • pip cannot move a conda-managed package off its resolved version.
  • A genuine conflict (pip needs a version beyond a conda pin) now fails the build with a pip resolution error, instead of silently clobbering the pin — per the "fail loudly" decision. The fix is to relax the conda pin or pin the pip package compatibly.
  • A trailing pip check confirms the final dependency tree is consistent.

Details

  • templates.py: _split_conda_pip() separates conda vs pip deps, injects pip into the conda deps when the user didn't list it (so phase 2 can run), and preserves pip option lines (--extra-index-url, git+ URLs). New template context: conda_env_yaml, pip_requirements, has_pip. env_yaml (full) is retained for custom templates.
  • Fragments conda_on_base.j2, builder_stage.j2, single_stage.j2: two-phase install (pip step inserted before conda-pack in the packed paths).
  • No behavior change for conda-only envs — when there's no pip: section, the rendered Dockerfile is identical to before (verified by test).

Testing

  • pytest: 116 passed, 2 skipped (Docker smoke tests). ruff check + format clean.
  • New tests: _split_conda_pip (separation, pip injection, no-duplicate, no-pip passthrough) and rendered output (constrained two-phase ordering; no-pip omits the pip steps).
  • Rendered the flash_scope --base Dockerfile by hand to confirm output.

Scope / follow-up

  • conda + pip only. R (conda + renv) has the same class of problem via a different mechanism (renv shadows conda's R library via .libPaths(); renv is exact-pinned so the conflict is lockfile-vs-conda, not a runaway resolver). The constraints trick doesn't transfer — the renv analog is conflict detection plus "keep R single-managed". Intentionally deferred; docs note this.

🤖 Generated with Claude Code

When an environment.yaml mixes conda dependencies with a pip: section,
`micromamba create -f env.yaml` hands the pip subsection to a plain
`pip install` with no constraints. pip has no knowledge of conda's pins,
so a pip package (e.g. torch) that requires `numpy>=2` will silently
upgrade the conda-installed numpy past its pin, leaving an environment
that violates the conda spec.

This is inherent to composing two independent resolvers over one prefix;
neither conda nor pip treats the other's record as a constraint. Fix it
in the layer that sees both: split the pip section out of the conda solve
and install it in a second phase constrained to the versions conda just
resolved (`pip install -c <frozen conda env>`). A conflict now fails the
build (pip resolution error) instead of clobbering a pin, and a trailing
`pip check` confirms the final tree is consistent.

- templates.py: _split_conda_pip() separates conda vs pip, injects `pip`
  into the conda deps when needed, and exposes conda_env_yaml /
  pip_requirements / has_pip to the templates.
- conda_on_base / builder_stage / single_stage fragments: two-phase
  install. Envs with no pip section render exactly as before.
- docs + tests.

R (conda + renv) has the same class of problem via a different mechanism
and is intentionally left for a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@johnyaku
johnyaku merged commit 2805409 into main Jun 17, 2026
9 checks passed
@johnyaku
johnyaku deleted the feat/conda-pip-constraints branch June 17, 2026 23:29
johnyaku added a commit that referenced this pull request Jun 17, 2026
* fix: don't rm root-owned COPY temp files as non-root build user

The builder/single-stage paths run as $MAMBA_USER, but COPY creates
/tmp/requirements.txt owned by root. In sticky /tmp a non-owner can't
delete it, so `rm -f /tmp/requirements.txt` failed with "Operation not
permitted" and broke the build (CI #61).

Drop the cleanup in these two paths: the builder stage is discarded
(multi-stage), and single-stage already leaves /tmp/env.yaml behind, so
this is consistent and the leftover is a harmless few-KB text file. The
conda_on_base path runs as root and keeps its cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version to 0.2.7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant