Skip to content

Commit a7e93ac

Browse files
committed
fix: fail on unreachable planned conda packages
After ``apply_categories`` walks forward from each requested input spec, every planned package should have at least one category. A package without any category is an orphan: the lockfile dependency graph cannot explain why this package is in the plan. Orphans are not tolerated. They would silently vanish from the v1 lockfile output (which emits one entry per category, so a package with no category produces zero entries) and the resulting environment would install fewer packages than the solver actually planned. This is the silent-vanishing variant of the conda#896 chain that cache-side and lockfile-side heal do not catch -- it surfaces only after categorization. Add ``conda_lock.solver.graph_integrity.assert_no_orphaned_conda_packages`` which owns the entire orphan policy: - Definition of an orphaned planned package. - No reverse-propagation of categories. ``apply_categories``' forward walk is the only categorization path. Reverse-prop hides the broken-graph signal in single-category projects (every orphan with any categorized dependency would silently inherit ``main``, and the hard-fail would never fire on real conda#896-style breakage), and ``"P depends on X (main)"`` does not prove ``"main needs P"``; a permissive variant would launder dev-only solver artifacts into main installs. - Why ``pip`` is not normally an orphan even though nothing the user explicitly requests transitively depends on it: both conda's ``add_pip_as_python_dependency`` and libmamba's repo-load injection mutate ``python``'s declared dependencies to include ``pip`` at metadata-load time. So a healthy forward walk from ``python`` reaches ``pip`` in normal operation, and an orphaned ``pip`` indicates broken solver metadata rather than the expected shape. - ``CONDA_LOCK_ALLOW_ORPHANED_LOCKFILE=1`` escape hatch: intentionally ugly, demotes the hard-fail to a loud WARNING and assigns orphans to ``main`` so they survive v1 serialization. This is for implementation-bug emergencies (a buggy custom solver, missing ``add_pip_as_python_dependency`` injection on an exotic channel) not a configuration knob. The WARNING spells out the over-install consequence: every ``conda-lock install`` invocation -- even ones without ``--dev-dependencies`` or ``-e <category>`` -- will install the orphans, potentially over-installing dev-only solver artifacts into production environments. Add ``OrphanLockedDependencyError`` in ``conda_lock.errors``; ``solve_conda`` calls ``assert_no_orphaned_conda_packages(planned, platform)`` after ``apply_categories``. The module docstring on ``conda_lock.conda_solver`` introduces the ``solver/*`` split as a whole so a new reader sees the layering on first opening the file. Six component tests in ``tests/component/test_graph_integrity.py`` pin the policy contract: - ``test_solve_conda_accepts_pip_via_python_add_pip_dependency``: the *normal* shape. ``python.depends == ["pip"]`` makes ``pip`` forward-reachable; ``pip`` inherits ``python``'s requested category without rescue. - ``test_solve_conda_hard_fails_when_python_metadata_omits_pip``: the abnormal-metadata shape. ``python.depends == []`` makes ``pip`` unreachable; the orphan check hard-fails. The test docstring names this as broken solver metadata, not normal solver auto-install behavior. - ``test_solve_conda_envvar_demotes_orphan_to_warning``: pins ``CONDA_LOCK_ALLOW_ORPHANED_LOCKFILE=1`` semantics. WARNING fires (explicitly mentioning ``main``, ``--dev-dependencies`` / ``-e <category>``, and the envvar name so the message can't silently weaken), orphans are assigned to ``main``, ``solve_conda`` returns instead of raising. - ``test_solve_conda_hard_fails_on_unrecoverable_orphan``: the classic conda#896-corrupt-cache shape -- ``zlib`` planned with empty depends, no categorized package transitively requires it -- hard-fails with regenerate-from-sources guidance and the envvar escape named in the error message. - ``test_solve_conda_orphan_via_dep_breakage_still_hard_fails``: multi-category corrupt-cache variant. - ``test_solve_conda_passes_when_dependency_graph_is_intact``: positive sanity check.
1 parent e01c171 commit a7e93ac

4 files changed

Lines changed: 694 additions & 9 deletions

File tree

conda_lock/conda_solver.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
1+
"""Orchestration entry points for conda-lock's solver pipeline.
2+
3+
The narrow responsibilities split out of this module live under
4+
``conda_lock.solver``:
5+
6+
- ``solver.repodata_cache``: URL normalization, cache path
7+
derivation, record identity checks, mamba 2.1.1-2.3.3 stub-record
8+
detection, and ``info/index.json``-based healing.
9+
- ``solver.dry_run``: normalize a solver's ``--dry-run --json``
10+
output into a uniform shape with rich FETCH actions, falling back
11+
to disk when the LINK metadata is sparse.
12+
- ``solver.lockfile_heal``: repair carry-forward empty
13+
``dependencies`` from the local cache before
14+
``fake_conda_environment`` propagates them.
15+
- ``solver.graph_integrity``: forward-reachability check on the
16+
planned package set, with the
17+
``CONDA_LOCK_ALLOW_ORPHANED_LOCKFILE`` escape hatch.
18+
19+
What's left here is glue: drive the conda/mamba subprocess for the
20+
fresh-solve and update paths, translate the dryrun into
21+
``LockedDependency`` shapes, run categorization, and assert graph
22+
integrity. The fake-prefix machinery used by ``--update`` also
23+
lives here because it is a peer of the subprocess invocation, not
24+
a cache or graph concern.
25+
"""
26+
127
import json
228
import logging
329
import os
@@ -27,6 +53,7 @@
2753
from conda_lock.models.dry_run_install import DryRunInstall, LinkAction
2854
from conda_lock.models.lock_spec import Dependency, VersionedDependency
2955
from conda_lock.solver.dry_run import reconstruct_fetch_actions
56+
from conda_lock.solver.graph_integrity import assert_no_orphaned_conda_packages
3057
from conda_lock.solver.lockfile_heal import heal_locked_dependencies_from_cache
3158
from conda_lock.tempdir_manager import temporary_directory
3259

@@ -150,6 +177,12 @@ def solve_conda(
150177
mapping_url=mapping_url,
151178
)
152179

180+
# Forward-reachability check on the planned package set. The
181+
# policy, escape hatch, and the rationale for not
182+
# reverse-propagating categories all live in
183+
# ``conda_lock.solver.graph_integrity``.
184+
assert_no_orphaned_conda_packages(planned, platform)
185+
153186
return planned
154187

155188

@@ -331,14 +364,14 @@ def update_specs_for_arch(
331364
# elsewhere is not evidence about an ambiguous entry; partial
332365
# caches are normal.
333366
#
334-
# Two failure modes follow. The orphan check downstream catches
335-
# the silent-vanishing variant (corrupt entry's transitive deps
336-
# become orphans). It does NOT catch the "categorized
337-
# corrupt-carrier" variant where the corrupt entry is itself a
338-
# requested or otherwise-reachable root and its missing
339-
# transitive deps are reachable via other paths; the lockfile
340-
# remains internally inconsistent and re-locks may silently
341-
# drift. The WARNING below is visibility for that
367+
# Two failure modes follow. ``assert_no_orphaned_conda_packages``
368+
# downstream catches the silent-vanishing variant (corrupt
369+
# entry's transitive deps become orphans). It does NOT catch
370+
# the "categorized corrupt-carrier" variant where the corrupt
371+
# entry is itself a requested or otherwise-reachable root and
372+
# its missing transitive deps are reachable via other paths;
373+
# the lockfile remains internally inconsistent and re-locks may
374+
# silently drift. The WARNING below is visibility for that
342375
# harder-to-detect case so an operator can regenerate from
343376
# sources.
344377
logger.warning(
@@ -547,7 +580,6 @@ def make_fake_python_binary(prefix: str) -> None:
547580
This was called as:
548581
{cmd}
549582
'''
550-
551583
print(stderr_message, file=sys.stderr, flush=True, end='')
552584
553585
if "-m pip" in cmd:

conda_lock/errors.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,28 @@ class ChannelAggregationError(CondaLockError):
2121
"""
2222
Error thrown when lists of channels cannot be combined
2323
"""
24+
25+
26+
class OrphanLockedDependencyError(CondaLockError):
27+
"""
28+
Raised when planned packages cannot be reached from the
29+
requested input specs through declared ``dependencies`` edges.
30+
31+
Such packages would silently vanish from the on-disk lockfile (the
32+
v1 serialization emits one entry per category, so an empty category
33+
set produces no entries) and the resulting environment would install
34+
fewer packages than the solver actually planned. An orphan is
35+
proof that the lockfile dependency graph is broken: every package
36+
in the plan must be reachable from some input spec.
37+
38+
The usual root cause is a corrupt ``repodata_record.json`` from
39+
mamba/micromamba versions 2.1.1-2.3.3 (mamba-org/mamba#4052,
40+
mamba-org/mamba#4110) leaving the package -- *or one of its
41+
dependents* -- with empty ``depends``, which breaks the forward
42+
dependency walk. See conda/conda-lock#896.
43+
44+
Emergency escape: ``CONDA_LOCK_ALLOW_ORPHANED_LOCKFILE=1`` demotes
45+
the error to a loud warning and assigns orphans to ``main`` so
46+
they survive v1 serialization. This is intentionally ugly and is
47+
not a supported configuration knob.
48+
"""
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Graph-integrity invariants for solved conda plans.
2+
3+
After ``apply_categories`` walks forward from each requested input
4+
spec, every planned package should have at least one category. A
5+
package without any category is an orphan: the lockfile dependency
6+
graph cannot explain why this package is in the plan.
7+
8+
Orphans are *not* tolerated. They would silently vanish from the v1
9+
lockfile output (which emits one entry per category, so a package
10+
with no category produces zero entries) and the resulting
11+
environment would install fewer packages than the solver actually
12+
planned. ``assert_no_orphaned_conda_packages`` is the guard that
13+
prevents that.
14+
15+
This module owns the entire orphan policy in one place:
16+
17+
- the definition of an orphaned planned package;
18+
- why we do **not** reverse-propagate categories (would hide the
19+
broken-graph signal in single-category projects, and would
20+
launder dev-only solver artifacts into main installs);
21+
- why ``pip`` is not normally an orphan
22+
(``add_pip_as_python_dependency`` in conda; equivalent
23+
injection in libmamba), so an orphaned ``pip`` indicates
24+
abnormal solver metadata rather than expected behavior;
25+
- the ``CONDA_LOCK_ALLOW_ORPHANED_LOCKFILE=1`` escape hatch and
26+
the over-install consequence of promoting orphans to ``main``.
27+
28+
The orphan check is one of the central invariants of the
29+
lockfile, not a local cleanup step; it lives here so a future
30+
reader sees that on first opening the file.
31+
"""
32+
33+
import logging
34+
import os
35+
36+
from conda_lock.errors import OrphanLockedDependencyError
37+
from conda_lock.lockfile.v2prelim.models import LockedDependency
38+
39+
40+
logger = logging.getLogger(__name__)
41+
42+
43+
_ORPHAN_ESCAPE_ENVVAR = "CONDA_LOCK_ALLOW_ORPHANED_LOCKFILE"
44+
45+
46+
def assert_no_orphaned_conda_packages(
47+
planned: dict[str, LockedDependency], platform: str
48+
) -> None:
49+
"""Raise ``OrphanLockedDependencyError`` if any planned package is
50+
unreachable from the requested input specs.
51+
52+
Called after ``apply_categories``: any conda entry still without a
53+
category at this point was not reached by the forward walk from
54+
the requested roots through declared ``.dependencies`` edges.
55+
That is the broken-graph signal we hard-fail on.
56+
57+
No reverse-propagation. ``apply_categories``' forward walk is the
58+
only categorization path. The reasons are policy-level,
59+
independent of whether the orphan happens to be a
60+
"solver auto-install" like ``pip``:
61+
62+
- Reverse-prop hides the broken-graph signal. In single-category
63+
projects (only ``main`` requested), every orphan with any
64+
categorized dependency would silently inherit ``main`` and the
65+
hard-fail would never fire on real conda/conda-lock#896-style
66+
breakage.
67+
- "P depends on X (main)" does not prove "main needs P"; it
68+
only correlates. A permissive variant would launder dev-only
69+
solver artifacts into main installs.
70+
71+
In a healthy conda/mamba dryrun this should never fire even for
72+
``pip``: both ``conda.core.subdir_data.add_pip_as_python_dependency``
73+
and libmamba's repo-load injection mutate ``python``'s declared
74+
dependencies to include ``pip`` at metadata-load time, and
75+
libmamba's install API additionally injects ``pip`` as an
76+
explicit root request whenever the user asks for ``python``. So
77+
a forward walk from ``python`` reaches ``pip`` in normal
78+
operation, and an orphaned ``pip`` is a sign of broken solver
79+
metadata rather than the expected shape.
80+
81+
Escape hatch: ``CONDA_LOCK_ALLOW_ORPHANED_LOCKFILE=1`` demotes
82+
the hard-fail to a loud WARNING and assigns orphans to ``main``
83+
so they survive v1 serialization. This is intentionally ugly
84+
and not documented as a stable interface; it exists for
85+
implementation-bug emergencies and one-off recovery (a buggy
86+
custom solver, missing dependency injection on an exotic
87+
channel, etc.). Promoting orphans to ``main`` will cause every
88+
``conda-lock install`` invocation -- including ones that do
89+
NOT pass ``--dev-dependencies`` or ``-e <category>`` -- to
90+
install them, potentially over-installing dev-only solver
91+
artifacts into production environments.
92+
"""
93+
orphans = sorted(name for name, dep in planned.items() if not dep.categories)
94+
if not orphans:
95+
return
96+
97+
message = (
98+
f"{len(orphans)} planned conda package(s) on platform "
99+
f"{platform} are unreachable from the requested input "
100+
f"specs through declared `dependencies` edges: {orphans}. "
101+
f"The lockfile dependency graph is broken: these "
102+
f"packages would silently vanish from the v1 lockfile "
103+
f"output (which emits one entry per category, so a "
104+
f"package with no category produces zero entries) and "
105+
f"the resulting environment would install fewer "
106+
f"packages than the solver actually planned.\n\n"
107+
f"In a healthy conda/mamba dryrun every planned package "
108+
f"is forward-reachable from some requested spec. Even "
109+
f"``pip`` -- often described as a 'solver auto-install' "
110+
f"-- is normally reachable because conda and libmamba "
111+
f"both inject ``pip`` into ``python``'s declared "
112+
f"dependencies at repodata-load time "
113+
f"(``add_pip_as_python_dependency``). An orphan therefore "
114+
f"indicates either: (1) corrupt ``repodata_record.json`` "
115+
f"metadata from mamba/micromamba 2.1.1-2.3.3 (see "
116+
f"conda/conda-lock#896 / mamba-org/mamba#4110) leaving "
117+
f"the package -- or one of its dependents -- with empty "
118+
f"``depends``, breaking the forward walk; or (2) a "
119+
f"non-standard solver / channel / metadata source that "
120+
f"omits the usual dependency injections.\n\n"
121+
f"To resolve: regenerate the lockfile from sources on a "
122+
f"known-clean cache (`mamba clean -a` then `conda-lock "
123+
f"lock -f <your sources> ...`), or add the orphaned "
124+
f"package(s) as explicit input specs in the relevant "
125+
f"category. Re-running ``--update`` against the same "
126+
f"input lockfile after only clearing the local cache "
127+
f"will not recover packages that already vanished "
128+
f"during a previous v1 serialization.\n\n"
129+
f"Emergency escape hatch: set "
130+
f"``{_ORPHAN_ESCAPE_ENVVAR}=1`` to demote "
131+
f"this error to a warning and continue. Orphaned "
132+
f"packages will be assigned category ``main`` so they "
133+
f"survive v1 serialization. WARNING: assigning to "
134+
f"``main`` means a dev-only solver artifact (something "
135+
f"the solver pulled in only because a dev-category root "
136+
f"asked for it) will be installed by every "
137+
f"``conda-lock install`` invocation, including ones "
138+
f"that did NOT pass ``--dev-dependencies`` or "
139+
f"``-e <category>``. This can over-install dev-only "
140+
f"packages into production environments. The escape "
141+
f"hatch is a band-aid for implementation-bug "
142+
f"emergencies, not a supported configuration -- use it "
143+
f"only if you understand the consequences and have "
144+
f"verified that promoting the orphans to ``main`` is "
145+
f"acceptable for your install paths."
146+
)
147+
if os.environ.get(_ORPHAN_ESCAPE_ENVVAR) == "1":
148+
logger.warning(
149+
"%s=1 set; demoting orphaned-lockfile error to a "
150+
"warning and promoting orphans to category 'main'. "
151+
"This will cause every ``conda-lock install`` "
152+
"invocation -- including ones that do NOT pass "
153+
"``--dev-dependencies`` or ``-e <category>`` -- to "
154+
"install these packages, which may include dev-only "
155+
"solver artifacts. %s",
156+
_ORPHAN_ESCAPE_ENVVAR,
157+
message,
158+
)
159+
for name in orphans:
160+
planned[name].categories.add("main")
161+
return
162+
raise OrphanLockedDependencyError(message)

0 commit comments

Comments
 (0)