Source code for conda_workspaces.lockfile

"""Lockfile generation, consumption and env-spec plugin for ``conda.lock``.

Single source of truth for every ``conda.lock`` concern.  The file owns
the read path, the write path, the ``CondaEnvironmentSpecifier`` plugin
class (:class:`CondaLockLoader`), and the plugin metadata (name,
aliases, default filename) consumed by ``plugin.py`` and
:mod:`.export`.

The ``conda.lock`` format is a *derivative* of rattler-lock v6
(``pixi.lock``): same schema machinery, same top-level keys
(``version``, ``environments``, ``packages``), but with an on-disk
``version: 1`` byte that identifies the file as conda-workspaces-owned.
:class:`CondaLockLoader` shares rattler-lock v6 conversion models with
:mod:`conda_lockfiles.rattler_lock.v6`. The read path performs an in-memory
``version: 6`` swap before delegating YAML -> ``Environment`` conversion; the
write path uses the same public package model while preserving conda-workspaces'
multi-environment and external-package layout.

The file layout is::

    version: 1
    environments:
      <name>:
        channels: [{url: ...}, ...]
        packages:
          <platform>: [{conda: <url>}, ...]
    packages:
      - conda: <url>
        sha256: ...
        md5: ...
        depends: [...]
        ...

On the *write* side, :func:`generate_lockfile` solves each environment
and delegates YAML serialisation to the ``multiplatform_export`` hook
in :mod:`.export` (the same path ``conda export`` uses), so every
``conda.lock`` on disk comes out of a single formatter.  On the *read*
side, :func:`install_from_lockfile` extracts the package list for one
environment + platform via :class:`CondaLockLoader` and installs the
exact URLs, bypassing the solver entirely.
"""

from __future__ import annotations

import io
import os
import stat
import sys
import tempfile
from collections.abc import Mapping
from contextlib import contextmanager, nullcontext
from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, cast
from urllib.parse import unquote, urlsplit

from conda.common.io import dashlist
from conda.common.serialize import yaml
from conda.core.prefix_data import delete_prefix_from_linked_data
from conda.models.dist import Dist
from conda.plugins.types import EnvironmentSpecBase

from .context import isolated_package_cache
from .exceptions import (
    AllTargetsUnsolvableError,
    CondaWorkspacesError,
    EnvironmentNotFoundError,
    LockfileIntegrityError,
    LockfileMergeError,
    LockfileNotFoundError,
    LockfileStaleError,
    PlatformError,
    SolveError,
)
from .models import (
    LockfileStatus,
    has_url_credentials,
    has_url_credentials_in_data,
    redact_channel_name,
    redact_channel_url,
    redact_url,
)
from .parsing import (
    decode_limited_text,
    read_limited_text,
    validate_document_limits,
)
from .paths import (
    anchored_directory,
    atomic_write_text,
    canonicalize_system_path_alias,
    output_paths_collide,
    read_regular_file_bytes,
    regular_file_generation,
    validate_directory_output,
    validate_file_output,
)

if TYPE_CHECKING:
    from collections.abc import Callable, Iterable, Iterator, Sequence
    from typing import Any, ClassVar, Final

    from conda.common.path import PathType
    from conda.core.link import PrefixSetup
    from conda.models.environment import Environment, EnvironmentConfig
    from conda.models.match_spec import MatchSpec
    from conda.models.records import PackageRecord

    from .context import WorkspaceContext
    from .models import Environment as WorkspaceEnvironment
    from .models import WorkspaceConfig
    from .paths import FileGeneration
    from .resolver import ResolvedEnvironment

#: On-disk lockfile format version.  Distinct from the rattler-lock v6
#: schema version so that tools can tell a conda-workspaces-owned lock
#: from a pixi-owned one at a glance.
LOCKFILE_VERSION: Final = 1

#: The canonical lockfile filename.
LOCKFILE_NAME: Final = "conda.lock"

#: Canonical, versioned plugin name.  Stable across schema bumps;
#: follows the ``conda-lockfiles`` alias policy (see
#: ``docs/reference/format-aliases.md``).
FORMAT: Final = "conda-workspaces-lock-v1"

#: User-friendly aliases.  Unversioned names are convenience handles
#: that may migrate to a newer ``FORMAT`` in the future.
ALIASES: Final = ("conda-workspaces-lock", "workspace-lock")

#: Default filenames this plugin handles.
DEFAULT_FILENAMES: Final = (LOCKFILE_NAME,)

MAX_LOCKFILE_BYTES: Final = 128 * 1024**2
MAX_LOCKFILE_DEPTH: Final = 128
MAX_LOCKFILE_COLLECTION_ITEMS: Final = 100_000
MAX_LOCKFILE_ITEMS: Final = 1_000_000
_CURRENT_LOCKFILE_OUTPUT_GENERATION = object()


@dataclass
class _SolvedEnvironment:
    """Solved lockfile row that can carry a Pixi rich-platform name."""

    name: str
    platform: str
    package_platform: str
    config: EnvironmentConfig
    explicit_packages: Sequence[PackageRecord]
    external_packages: dict[str, list[str]] = field(default_factory=dict)


[docs] def load_lockfile_data(content: str | bytes) -> dict[str, Any]: """Parse in-memory lockfile YAML with the same safe loader as disk reads.""" text = decode_limited_text( content, maximum_bytes=MAX_LOCKFILE_BYTES, label="Lockfile YAML", ) try: data = yaml.load(io.StringIO(text)) except Exception as exc: raise ValueError("Invalid lockfile YAML") from exc if not isinstance(data, Mapping): raise ValueError("Lockfile YAML must contain a mapping") validate_document_limits( data, label="Lockfile YAML", maximum_depth=MAX_LOCKFILE_DEPTH, maximum_collection_items=MAX_LOCKFILE_COLLECTION_ITEMS, maximum_items=MAX_LOCKFILE_ITEMS, ) return dict(data)
def load_lockfile_path(path: Path) -> dict[str, Any]: """Read and parse one lockfile under explicit resource limits.""" content = read_limited_text( path, maximum_bytes=MAX_LOCKFILE_BYTES, label="Lockfile YAML", ) return load_lockfile_data(content) def lockfile_path(ctx: WorkspaceContext) -> Path: """Return the path to the workspace lockfile (``<root>/conda.lock``).""" return ctx.root / LOCKFILE_NAME def _normalize_channel_urls(urls: Iterable[str]) -> list[str]: """Strip trailing slashes from channel URLs for comparison.""" return [url.rstrip("/") for url in urls] def _manifest_channel_entries( config: WorkspaceConfig, environment: WorkspaceEnvironment, ) -> list[dict[str, str]]: """Return manifest channel entries without conda canonical-name deduplication.""" seen: set[str] = set() entries: list[dict[str, str]] = [] for ch in config.channels: url = redact_channel_url(ch) normalized = url.rstrip("/") if normalized not in seen: seen.add(normalized) entries.append({"url": url}) for feature in config.resolve_features(environment): for ch in feature.channels: url = redact_channel_url(ch) normalized = url.rstrip("/") if normalized not in seen: seen.add(normalized) entries.append({"url": url}) return entries def lockfile_status( ctx: WorkspaceContext, config: WorkspaceConfig, *, platform: str | None = None, ) -> LockfileStatus: """Determine the lockfile status relative to the workspace manifest.""" lock = lockfile_path(ctx) if not lock.is_file(): return LockfileStatus(status=LockfileStatus.MISSING) data = load_lockfile_path(lock) return check_lockfile_satisfiability(config, data, platform or ctx.platform)
[docs] def check_lockfile_satisfiability( config: WorkspaceConfig, lockfile_data: dict[str, Any], current_platform: str, *, environment: str | None = None, ) -> LockfileStatus: """Check whether *lockfile_data* satisfies the manifest's requirements. Returns a :class:`LockfileStatus` with ``status=UP_TO_DATE`` when the lockfile covers every environment, platform, channel, and dependency declared in *config*. Returns ``status=OUT_OF_DATE`` with a human-readable *reason* otherwise. Set *environment* to check package requirements only for that environment on *current_platform*. Environment names, channels and declared platforms are still checked across the complete workspace. """ _stale = LockfileStatus.OUT_OF_DATE if environment is not None: config.get_environment(environment) try: lockfile_data = CondaLockLoader.redact_data_urls(lockfile_data) except ValueError as exc: return LockfileStatus(status=_stale, reason=str(exc)) if lockfile_data.get("version") != LOCKFILE_VERSION: return LockfileStatus( status=_stale, reason=( f"Lockfile version {lockfile_data.get('version')!r} " f"does not match expected version {LOCKFILE_VERSION}" ), ) from .resolver import resolve_environment lock_envs = lockfile_data.get("environments", {}) extra_envs = sorted(set(lock_envs) - set(config.environments)) if extra_envs: names = ", ".join(f"'{name}'" for name in extra_envs) return LockfileStatus( status=_stale, reason=( f"Environment{'' if len(extra_envs) == 1 else 's'} {names} " f"{'is' if len(extra_envs) == 1 else 'are'} present in the " "lockfile but not declared in the manifest" ), ) for env_name, env_obj in config.environments.items(): if env_name not in lock_envs: return LockfileStatus( status=_stale, reason=( f"Environment '{env_name}' is declared in the manifest " f"but missing from the lockfile" ), ) lock_env = lock_envs[env_name] manifest_channels = _manifest_channel_entries(config, env_obj) manifest_urls = _normalize_channel_urls( entry["url"] for entry in manifest_channels ) lock_channel_entries = lock_env.get("channels", []) lock_urls = _normalize_channel_urls( entry.get("url", "") for entry in lock_channel_entries ) if manifest_urls != lock_urls: return LockfileStatus( status=_stale, reason=( f"Channel mismatch for environment '{env_name}': " "manifest declares " f"{[redact_channel_name(url) for url in manifest_urls]} " "but lockfile has " f"{[redact_channel_name(url) for url in lock_urls]}" ), ) lock_platforms = set(lock_env.get("packages", {})) resolved_platforms = resolve_environment(config, env_name).platforms manifest_platforms = resolved_platforms or config.platforms for platform in manifest_platforms: if platform not in lock_platforms: return LockfileStatus( status=_stale, reason=( f"Platform '{platform}' is declared in the " f"manifest but missing from environment " f"'{env_name}' in the lockfile" ), ) if environment is not None and env_name != environment: continue lock_packages = lock_env.get("packages", {}) try: current_lock_platform = config.resolve_platform_name( current_platform, manifest_platforms, ) except PlatformError: current_lock_platform = current_platform platform_refs = lock_packages.get(current_lock_platform) if platform_refs is None: continue for ref in platform_refs: if not isinstance(ref, dict) or set(ref) != {"conda"}: return LockfileStatus( status=_stale, reason=( f"Environment '{env_name}' package refs must contain " "exactly one 'conda' entry" ), ) package_platform = config.platform_subdir(current_lock_platform) try: channel_urls = CondaLockLoader.channel_urls_for_env_data( lock_env, package_platform, ) records = CondaLockLoader.package_records_for_env_data( lockfile_data, env_name, current_lock_platform, package_platform=package_platform, ) except ValueError as exc: return LockfileStatus(status=_stale, reason=str(exc)) records_by_name = {} for record in records: url = record.url if not isinstance(url, str) or not url: return LockfileStatus( status=_stale, reason=( f"Environment '{env_name}' contains an invalid " f"package ref on '{current_lock_platform}'" ), ) if not CondaLockLoader.url_matches_channel(url, channel_urls): return LockfileStatus( status=_stale, reason=( f"Package URL '{redact_url(url)}' in environment " f"'{env_name}' " f"on '{current_lock_platform}' is not under a declared " "channel" ), ) if record.name in records_by_name: return LockfileStatus( status=_stale, reason=( f"Environment '{env_name}' contains more than one " f"'{record.name}' package on '{current_lock_platform}'" ), ) records_by_name[record.name] = record from conda.base.context import context as conda_context from conda.models.match_spec import MatchSpec from .envs import _build_pypi_specs target_resolved = resolve_environment( config, env_name, current_lock_platform, ) requested_specs = [ *target_resolved.conda_dependencies.values(), *_build_pypi_specs(target_resolved), ] requested_specs.extend(target_resolved.system_requirement_specs()) try: dependencies = [ (record, MatchSpec(dependency)) for record in records for dependency in record.depends ] constraints = [ (record, MatchSpec(constraint)) for record in records for constraint in record.constrains ] except ValueError as exc: return LockfileStatus(status=_stale, reason=str(exc)) virtual_names = { spec.name for spec in ( *requested_specs, *(spec for _, spec in dependencies), *(spec for _, spec in constraints), ) if spec.name and spec.name.startswith("__") } candidates = list(records) if virtual_names: with ( target_resolved.scoped_virtual_packages(package_platform), conda_context._override("_subdir", package_platform), ): candidates.extend( conda_context.plugin_manager.get_virtual_package_records() ) candidates_by_name = {record.name: record for record in candidates} for spec in requested_specs: dep_name = spec.name record = candidates_by_name.get(dep_name) if record is None: return LockfileStatus( status=_stale, reason=( f"Dependency '{dep_name}' is required by " f"environment '{env_name}' but not found in " f"the lockfile for platform " f"'{current_lock_platform}'" ), ) if not spec.match(record): return LockfileStatus( status=_stale, reason=( f"Dependency '{dep_name}' in environment " f"'{env_name}' requires '{spec}' but locked package " f"'{record.dist_str()}' on '{current_lock_platform}' " "does not satisfy it" ), ) for record, spec in dependencies: dep_name = spec.get_exact_value("name") if dep_name is None: satisfied = any(spec.match(candidate) for candidate in candidates) else: candidate = candidates_by_name.get(dep_name) satisfied = candidate is not None and spec.match(candidate) if not satisfied: return LockfileStatus( status=_stale, reason=( f"Package '{record.dist_str()}' in environment " f"'{env_name}' requires '{spec}', which is missing " f"on '{current_lock_platform}'" ), ) for record, spec in constraints: candidate = candidates_by_name.get(spec.name) if candidate is not None and not spec.match(candidate): return LockfileStatus( status=_stale, reason=( f"Package '{record.dist_str()}' in environment " f"'{env_name}' constrains '{spec}', but " f"'{candidate.dist_str()}' does not satisfy it on " f"'{current_lock_platform}'" ), ) return LockfileStatus(status=LockfileStatus.UP_TO_DATE)
[docs] class CondaLockLoader(EnvironmentSpecBase): """Environment specifier + loader for ``conda.lock``. ``conda.lock`` is a derivative of rattler-lock v6 (``pixi.lock``); this loader shares the rattler-lock v6 conversion helper from :mod:`conda_lockfiles.rattler_lock.v6` by performing an in-memory ``version: 1 -> 6`` swap before handing the data off. The on-disk file keeps ``version: 1`` unchanged. Used by ``conda env create --file conda.lock`` (single platform via ``env``) and by ``conda workspace install`` (multi-platform via ``env_for``). """ detection_supported: ClassVar[bool] = True def __init__(self, path: PathType, *, data: dict[str, Any] | None = None) -> None: self.path = Path(path).resolve() self._data_cache = self.redact_data_urls(data) if data is not None else None def can_handle(self) -> bool: if self.path.name not in DEFAULT_FILENAMES: return False if not self.path.exists(): return False try: return self._data.get("version") == LOCKFILE_VERSION except Exception: return False @property def _data(self) -> dict[str, Any]: if self._data_cache is None: self._data_cache = self.redact_data_urls(load_lockfile_path(self.path)) return self._data_cache @property def available_platforms(self) -> tuple[str, ...]: """Platforms declared in this lockfile's default environment.""" return self.platforms_for() @property def available_environments(self) -> tuple[str, ...]: """Return the environment names declared in this lockfile.""" data = self._data if data.get("version") != LOCKFILE_VERSION: raise ValueError( f"Unsupported {LOCKFILE_NAME} version: {data.get('version')!r} " f"(expected {LOCKFILE_VERSION})" ) environments = data.get("environments") if not isinstance(environments, dict) or not all( isinstance(name, str) and name for name in environments ): raise ValueError("Lockfile environments must be a mapping of names") return tuple(sorted(environments))
[docs] def platforms_for(self, name: str = "default") -> tuple[str, ...]: """Return the platforms declared for lockfile environment *name*.""" env_data = self._env_data(name) return tuple(sorted(env_data.get("packages", {})))
[docs] def package_platform_for(self, platform: str, name: str = "default") -> str | None: """Find a target's conda subdir, or None without platform-specific records.""" from conda.base.context import context selected = self.select({name: (platform,)}) records = self.package_records_for_env_data(selected, name, platform) subdirs = {record.subdir for record in records if record.subdir != "noarch"} if platform in context.known_subdirs: subdirs.add(platform) if len(subdirs) > 1 or not subdirs.issubset(context.known_subdirs): raise ValueError( f"Cannot determine one conda subdir for environment {name!r} " f"target {platform!r}" ) return subdirs.pop() if subdirs else None
[docs] def select(self, selections: Mapping[str, Iterable[str]]) -> dict[str, Any]: """Copy selected solutions and their source metadata without solving.""" from conda.base.context import context if not isinstance(selections, Mapping) or not selections: raise ValueError("Select at least one lockfile environment and target") result = self.redact_data_urls(self._data) selected_environments: dict[str, Any] = {} selected_urls: set[str] = set() for name, requested in selections.items(): env_data = deepcopy(self._env_data(name)) if isinstance(requested, (str, bytes)): raise ValueError("Lockfile targets must be a collection of names") try: targets = tuple(requested) except TypeError as exc: raise ValueError( "Lockfile targets must be a collection of names" ) from exc if not targets or not all(isinstance(target, str) for target in targets): raise ValueError("Select at least one named lockfile target") for target in targets: if target not in env_data["packages"]: raise ValueError( f"Environment {name!r} does not include platform {target!r}" ) records = self.package_records_for_env_data(result, name, target) subdirs = { record.subdir for record in records if record.subdir != "noarch" } if len(subdirs) > 1 or not subdirs.issubset(context.known_subdirs): raise ValueError( f"Environment {name!r} target {target!r} contains " "packages without one supported conda subdir" ) self.validate_env_for_conversion( result, name, target, package_platform=next(iter(subdirs), target), ) selected_urls.update( ref["conda"] for ref in env_data["packages"][target] ) env_data["packages"] = { target: refs for target, refs in env_data["packages"].items() if target in targets } selected_environments[name] = env_data result["environments"] = selected_environments result["packages"] = [ record for url, record in self.package_records_by_url_from_data(result).items() if url in selected_urls ] return result
[docs] def env_for( self, platform: str, name: str = "default", *, package_platform: str | None = None, metadata_only: bool = False, ) -> Environment: """Return the conda ``Environment`` for *platform* and *name*. Raises ``PlatformMismatchError`` if *platform* is not in the lockfile or *name* does not identify a declared environment. *package_platform* supplies the backing conda subdir when *platform* is a rich workspace platform name. The returned environment keeps the backing subdir as its conda platform and records the logical lock key separately for round-trip serialization. *metadata_only* reconstructs exact records from the lockfile without accessing the package cache. """ self._env_data(name) payload = self.redact_data_urls(self._data) env_data = payload["environments"][name] platforms = tuple(sorted(env_data.get("packages", {}))) if platform not in platforms: from conda.exceptions import PlatformMismatchError raise PlatformMismatchError( incompatible=[(str(self.path), platforms)], subdir=platform, ) self.validate_env_for_conversion( payload, name, platform, package_platform=package_platform, ) conversion_platform = package_platform or platform records = None if metadata_only: records = self.package_records_for_env_data( payload, name, platform, package_platform=conversion_platform, ) if conversion_platform != platform: packages = payload["environments"][name]["packages"] packages[conversion_platform] = packages[platform] if records is None: from conda_lockfiles.rattler_lock.v6 import ( rattler_lock_v6_to_conda_env, ) from ._lockfile_compat import WorkspaceLock # The shared rattler model requires a default environment, while # conda.lock may contain only a named environment. Adapt the copy. payload.update(version=6, environments={"default": env_data}) lockfile_model = WorkspaceLock.model_validate(payload) env = rattler_lock_v6_to_conda_env( lockfile_model, name="default", platform=conversion_platform, ) else: from conda.models.channel import Channel from conda.models.environment import Environment, EnvironmentConfig from conda_lockfiles.rattler_lock.v6 import RattlerLockV6Environment lock_environment = RattlerLockV6Environment.model_validate(env_data) env = Environment( name=name, platform=conversion_platform, config=EnvironmentConfig( channels=tuple( Channel(channel.url).canonical_name for channel in lock_environment.channels ) ), explicit_packages=records, ) env.name = name if conversion_platform != platform: setattr(env, "lock_platform", platform) return env
def validate_env_for_conversion( self, data: dict[str, Any], name: str, platform: str, *, package_platform: str | None = None, ) -> None: """Validate one redacted slice before generic rattler conversion.""" env_data = data["environments"][name] refs = env_data.get("packages", {}).get(platform, ()) channel_urls = self.channel_urls_for_env_data( env_data, package_platform or platform, path=self.path, ) try: records_by_url = self.package_records_by_url_from_data(data) except ValueError as exc: raise LockfileIntegrityError(self.path, str(exc)) from exc for ref in refs: if not isinstance(ref, dict): raise LockfileIntegrityError( self.path, f"environment {name!r} has an invalid package ref", ) if set(ref) != {"conda"}: if ref: raise LockfileIntegrityError( self.path, "external package refs cannot be verified from conda.lock. " "Declare them in the workspace manifest, regenerate the " "lockfile, then use 'conda workspace install'", ) raise LockfileIntegrityError( self.path, f"environment {name!r} has an invalid package ref", ) url = ref["conda"] if not isinstance(url, str) or not url: raise LockfileIntegrityError( self.path, f"environment {name!r} has an invalid conda package ref", ) if not self.url_matches_channel(url, channel_urls): raise LockfileIntegrityError( self.path, f"package URL {redact_url(url)!r} is not under any channel " f"declared for environment {name!r}", ) record = records_by_url.get(url) if record is None: raise LockfileIntegrityError( self.path, f"package URL {redact_url(url)!r} has no top-level package record", ) self.digest_fragment_for_record(record, url, path=self.path) @property def env(self) -> Environment: """Return the default environment for the current subdir. Kept for backwards compatibility with ``conda env create --file conda.lock``; delegates to :meth:`env_for`. """ from conda.base.context import context return self.env_for(context.subdir) def _env_data(self, name: str = "default") -> dict[str, Any]: available = self.available_environments if name not in available: raise ValueError( f"Environment {name!r} not found in lockfile. " f"Available environments: {dashlist(available)}" ) env_data = self._data["environments"][name] if not isinstance(env_data, dict): raise ValueError(f"Lockfile environment {name!r} must be a mapping") packages = env_data.get("packages") if not isinstance(packages, dict) or not all( isinstance(target, str) and target and isinstance(refs, list) for target, refs in packages.items() ): raise ValueError( f"Lockfile environment {name!r} has invalid target packages" ) channels = env_data.get("channels") if not isinstance(channels, list) or not all( isinstance(channel, dict) and isinstance(channel.get("url"), str) and channel["url"] for channel in channels ): raise ValueError(f"Lockfile environment {name!r} has invalid channels") return env_data def explicit_package_specs_for( self, platform: str, name: str = "default", *, package_platform: str | None = None, ) -> list[str]: """Return hash-bearing explicit conda specs for *name* on *platform*.""" env_data = self._env_data(name) platform_refs = env_data.get("packages", {}).get(platform) if platform_refs is None: raise ValueError( f"Environment {name!r} does not include packages for {platform!r}" ) records_by_url = self.package_records_by_url() channel_urls = self.channel_urls_for( env_data, package_platform or platform, ) explicit_specs: list[str] = [] for ref in platform_refs: if not isinstance(ref, dict): raise LockfileIntegrityError( self.path, f"environment {name!r} has an invalid package ref", ) if set(ref) != {"conda"}: if ref: raise LockfileIntegrityError( self.path, "external package refs cannot be installed exactly from " "conda.lock. Declare them in the workspace manifest and " "regenerate the lockfile", ) raise LockfileIntegrityError( self.path, f"environment {name!r} has an invalid package ref", ) url = ref["conda"] if not isinstance(url, str) or not url: raise LockfileIntegrityError( self.path, f"environment {name!r} has an invalid conda package ref", ) if not self.url_matches_channel(url, channel_urls): raise LockfileIntegrityError( self.path, f"package URL {redact_url(url)!r} is not under any channel " "declared " f"for environment {name!r}", ) record = records_by_url.get(url) if record is None: raise LockfileIntegrityError( self.path, f"package URL {redact_url(url)!r} has no top-level package record", ) digest = self.digest_fragment_for(record, url) explicit_specs.append(f"{redact_url(url)}#{digest}") return explicit_specs def package_records_by_url(self) -> dict[str, dict[str, Any]]: """Return top-level conda package records keyed by their exact URL.""" return self.package_records_by_url_from_data(self._data) @staticmethod def package_records_by_url_from_data( data: dict[str, Any], ) -> dict[str, dict[str, Any]]: """Return top-level conda package records from *data* keyed by URL.""" records_by_url: dict[str, dict[str, Any]] = {} packages = data.get("packages", []) if not isinstance(packages, list): raise ValueError("Lockfile package records must be a list") for record in packages: if not isinstance(record, dict): continue url = record.get("conda") or record.get("url") or record.get("pypi") if isinstance(url, str) and url: existing = records_by_url.get(url) if existing is not None and existing != record: raise ValueError( f"Conflicting package metadata for URL {redact_url(url)!r}" ) records_by_url.setdefault(url, record) return records_by_url @staticmethod def merge_package_records( existing: dict[str, Any], update: dict[str, Any], url: str, ) -> dict[str, Any]: """Merge repodata patches with installed-prefix metadata. Those sources can disagree on non-integrity metadata for the same package URL. Only conflicting hashes identify an integrity conflict. """ conflicts = { key for key in (existing.keys() & update.keys()) & {"sha256", "md5"} if existing[key] != update[key] } if conflicts: raise ValueError( "Conflicting package metadata for URL" f" {redact_url(url)!r}: {', '.join(sorted(conflicts))}" ) source_keys = {"conda", "pypi", "url"} merged = { **{key: value for key, value in existing.items() if key not in source_keys}, **update, } for key in ("depends", "constrains", "features", "track_features"): if key in update and not update[key]: merged.pop(key, None) return merged @classmethod def package_records_for_env_data( cls, data: dict[str, Any], name: str, platform: str, *, package_platform: str | None = None, ) -> list[PackageRecord]: """Reconstruct package records for one lockfile environment slice. This path uses the metadata already embedded in ``conda.lock``. It deliberately avoids :meth:`env_for`, whose generic rattler-lock conversion may fetch package archives to fill missing metadata. """ from conda.base.context import context from conda.models.records import PackageRecord data = cls.redact_data_urls(data) environments = data.get("environments", {}) env_data = environments.get(name) if not isinstance(env_data, dict): raise ValueError(f"Environment {name!r} is missing from the lockfile") refs = env_data.get("packages", {}).get(platform) if refs is None: raise ValueError( f"Environment {name!r} does not include platform {platform!r}" ) records_by_url = cls.package_records_by_url_from_data(data) records: list[PackageRecord] = [] for ref in refs: if not isinstance(ref, dict) or "conda" not in ref: continue url = ref["conda"] if not isinstance(url, str) or not url: raise ValueError( f"Environment {name!r} has an invalid package reference" ) metadata = records_by_url.get(url) if metadata is None: raise ValueError( f"Package URL {redact_url(url)!r} has no top-level record" ) if "pypi" in metadata or ( "conda" in metadata and "url" in metadata and metadata["conda"] != metadata["url"] ): raise ValueError( f"Package URL {redact_url(url)!r} has inconsistent package sources" ) cls.digest_fragment_for_record(metadata, url) package_url = redact_url(url) dist = Dist(package_url) identity = { "name": dist.name, "version": dist.version, "build": dist.build_string, "subdir": dist.subdir, "fn": dist.to_filename(), } for key, value in identity.items(): if metadata.get(key) is not None and metadata[key] != value: raise ValueError( f"Package URL {package_url!r} disagrees with its {key} metadata" ) if ( dist.subdir is not None and (package_platform or platform) in context.known_subdirs and dist.subdir not in {"noarch", package_platform or platform} ): raise ValueError( f"Package URL {package_url!r} does not match target {platform!r}" ) records.append( PackageRecord.from_objects( metadata, name=dist.name, version=dist.version, build=dist.build_string, build_number=metadata.get("build_number", dist.build_number), channel=dist.channel, subdir=dist.subdir or package_platform or platform, fn=dist.to_filename(), url=package_url, ) ) return records @classmethod def seed_prefix_from_data( cls, data: dict[str, Any], name: str, platform: str, prefix: Path, requested_specs: Iterable[MatchSpec], *, package_platform: str | None = None, ) -> list[PackageRecord]: """Create a metadata-only prefix from one canonical lockfile slice.""" from conda.core.prefix_data import PrefixData from conda.history import History from conda.models.records import PrefixRecord records = cls.package_records_for_env_data( data, name, platform, package_platform=package_platform, ) (prefix / "conda-meta").mkdir(parents=True) prefix_data = PrefixData(str(prefix)) for record in records: prefix_data.insert(PrefixRecord.from_objects(record)) history = History(str(prefix)) history.write_changes(set(), {record.dist_str() for record in records}) history.write_specs(update_specs=tuple(requested_specs)) return records @staticmethod def redact_data_urls(data: dict[str, Any]) -> dict[str, Any]: """Return lockfile data with every serialized URL credential-free.""" result = deepcopy(data) environments = result.get("environments") if isinstance(environments, dict): for environment in environments.values(): if not isinstance(environment, dict): continue channels = environment.get("channels") if isinstance(channels, list): for index, entry in enumerate(channels): if isinstance(entry, str): channels[index] = {"url": redact_channel_name(entry)} elif isinstance(entry, dict): channel_entry = cast("dict[str, Any]", entry) url = channel_entry.get("url") if isinstance(url, str): channel_entry["url"] = redact_channel_name(url) values: list[dict[str, Any] | list[Any]] = [result] while values: container = values.pop() if isinstance(container, dict): for key, value in list(container.items()): if has_url_credentials(key): raise ValueError( "Lockfile contains credentials in a mapping key" ) if isinstance(value, str): container[key] = redact_url(value) elif isinstance(value, (dict, list)): values.append(value) else: for index, value in enumerate(container): if isinstance(value, str): container[index] = redact_url(value) elif isinstance(value, (dict, list)): values.append(value) if has_url_credentials_in_data(result): raise ValueError("Lockfile contains credentials in structured metadata") return result @classmethod def replace_solutions( cls, baseline: dict[str, Any], envs: Iterable[_SolvedEnvironment], ) -> dict[str, Any]: """Replace solved environment slices and canonicalize package records.""" result = cls.redact_data_urls(baseline) updates = cls.compose(envs) for name, update in updates["environments"].items(): if name not in result.get("environments", {}): raise ValueError(f"Environment {name!r} is missing from the lockfile") result_env = result["environments"][name] result_env["channels"] = update["channels"] for platform, refs in update["packages"].items(): if platform not in result_env.get("packages", {}): raise ValueError( f"Environment {name!r} does not include platform {platform!r}" ) result_env["packages"][platform] = refs packages_by_url = cls.package_records_by_url_from_data(result) update_packages_by_url = cls.package_records_by_url_from_data(updates) for url, update_record in update_packages_by_url.items(): existing_record = packages_by_url.get(url) if existing_record is None: packages_by_url[url] = update_record continue packages_by_url[url] = cls.merge_package_records( existing_record, update_record, url, ) packages: list[dict[str, Any]] = [] seen_urls: set[str] = set() for environment in result.get("environments", {}).values(): for platform in sorted(environment.get("packages", {})): for ref in environment["packages"][platform]: url = ref.get("conda") or ref.get("url") or ref.get("pypi") if not url or url in seen_urls: continue record = packages_by_url.get(url) if record is not None: packages.append(record) seen_urls.add(url) result["packages"] = packages return result def channel_urls_for( self, env_data: dict[str, Any], platform: str, ) -> tuple[str, ...]: """Return concrete package-containing channel URLs for *platform*.""" return self.channel_urls_for_env_data(env_data, platform, path=self.path) @staticmethod def channel_urls_for_env_data( env_data: dict[str, Any], platform: str, *, path: Path | None = None, ) -> tuple[str, ...]: """Return concrete package-containing channel URLs for *platform*.""" from conda.models.channel import Channel urls: set[str] = set() for entry in env_data.get("channels", []) or []: if not isinstance(entry, dict): continue raw_url = entry.get("url") if not isinstance(raw_url, str) or not raw_url: continue try: channel = Channel(raw_url) for with_credentials in (False, True): urls.update( channel.urls( with_credentials=with_credentials, subdirs=(platform, "noarch"), ) ) except Exception: reason = ( "environment channel" f" {redact_channel_name(raw_url)!r} cannot be resolved" ) if path is None: raise ValueError(reason) from None raise LockfileIntegrityError(path, reason) from None return tuple(sorted(url.rstrip("/") for url in urls)) @staticmethod def url_location( url: str, ) -> ( tuple[ tuple[str, str, int | None, str | None, str | None], tuple[str, ...], ] | None ): """Return a normalized URL origin and traversal-safe path segments.""" try: parsed = urlsplit(url) scheme = parsed.scheme.lower() hostname = parsed.hostname or "" if not scheme or (scheme != "file" and not hostname): return None hostname = hostname.encode("idna").decode("ascii").lower() port = parsed.port except (UnicodeError, ValueError): return None if port is None: port = {"http": 80, "https": 443}.get(scheme) origin = ( scheme, hostname, port, unquote(parsed.username) if parsed.username is not None else None, unquote(parsed.password) if parsed.password is not None else None, ) segments: list[str] = [] for raw_segment in parsed.path.split("/"): if not raw_segment: continue segment = unquote(raw_segment) if ( segment in {".", ".."} or "/" in segment or "\\" in segment or "\0" in segment or unquote(segment) != segment ): return None segments.append(segment) return origin, tuple(segments) @classmethod def url_matches_channel(cls, url: str, channel_urls: tuple[str, ...]) -> bool: """Return whether *url* is a traversal-safe child of a declared channel.""" package_location = cls.url_location(url) if package_location is None: return False package_origin, package_path = package_location for channel_url in channel_urls: channel_location = cls.url_location(channel_url) if channel_location is None: continue channel_origin, channel_path = channel_location if ( package_origin == channel_origin and len(package_path) > len(channel_path) and package_path[: len(channel_path)] == channel_path ): return True return False def digest_fragment_for(self, record: dict[str, Any], url: str) -> str: """Return the conda explicit-file digest fragment for *record*.""" return self.digest_fragment_for_record(record, url, path=self.path) @classmethod def digest_fragment_for_record( cls, record: dict[str, Any], url: str, *, path: Path | None = None, ) -> str: """Return the conda explicit-file digest fragment for *record*.""" sha256 = record.get("sha256") if sha256 is not None: if cls.is_hex_digest(sha256, 64): return f"sha256:{sha256.lower()}" reason = f"package URL {redact_url(url)!r} has an invalid sha256 digest" if path is None: raise ValueError(reason) raise LockfileIntegrityError(path, reason) md5 = record.get("md5") if md5 is not None: if cls.is_hex_digest(md5, 32): return md5.lower() reason = f"package URL {redact_url(url)!r} has an invalid md5 digest" if path is None: raise ValueError(reason) raise LockfileIntegrityError(path, reason) reason = f"package URL {redact_url(url)!r} is missing a sha256 or md5 digest" if path is None: raise ValueError(reason) raise LockfileIntegrityError(path, reason) @staticmethod def is_hex_digest(value: object, length: int) -> bool: """Return whether *value* is a hex digest with *length* characters.""" if not isinstance(value, str) or len(value) != length: return False try: int(value, 16) except ValueError: return False return True @classmethod def compose( cls, envs: Iterable[Environment | _SolvedEnvironment], ) -> dict[str, Any]: """Compose ``Environment`` objects into a ``conda.lock`` dict. The write-side companion to :meth:`env_for`: same loader class owns both directions. Returned dict has the canonical ``version`` / ``environments`` / ``packages`` shape that :func:`.export.multiplatform_export` (our ``conda-workspaces-lock-v1`` plugin callable) then hands to conda's YAML dumper. Exposed as a public classmethod because callers that want to inspect, merge, or hand off to a different serialiser can reuse the same composition logic without re-implementing it. """ from conda.models.environment import Environment, EnvironmentConfig from conda_lockfiles.validate_urls import validate_urls from ._lockfile_compat import WorkspaceLockPackage packages: list[dict[str, Any]] = [] environments: dict[str, dict[str, Any]] = {} for env in envs: # ruamel.yaml dispatches representers by exact type on dict # keys, so any ``str`` subclass reaching this point (e.g. a # leaked ``tomlkit.items.String``) raises ``TypeError: # Object of type ... is not YAML serializable``. Workspace # parsers unwrap tomlkit docs at load time; this is the # last-line guard for callers that build ``Environment`` # objects through other paths (``conda export`` plugin, # tests, third parties). env_name = str(env.name or "default") platform = str(getattr(env, "lock_platform", env.platform)) package_platform = str(getattr(env, "package_platform", env.platform)) validation_env = env if str(env.platform) != package_platform: validation_env = Environment( name=env_name, platform=package_platform, config=EnvironmentConfig(channels=tuple(env.config.channels)), explicit_packages=list(env.explicit_packages), external_packages=dict(env.external_packages), ) validate_urls(cast("Environment", validation_env), FORMAT) if env_name not in environments: environments[env_name] = { "channels": [ {"url": redact_channel_name(str(channel))} for channel in env.config.channels ], "packages": {}, } platform_refs: list[dict[str, str]] = [] for pkg in sorted(env.explicit_packages, key=lambda p: p.name): package_url = redact_url(pkg.url) platform_refs.append({"conda": package_url}) package_kwargs: dict[str, Any] = {"conda": package_url} for metadata_field in ( "build_number", "sha256", "md5", "depends", "constrains", "features", "track_features", "license", "license_family", "size", "python_site_packages_path", ): value = pkg.get(metadata_field, None) if metadata_field == "features" and isinstance( value, (list, tuple) ): value = " ".join(value) if value is not None and ( value or metadata_field in { "build_number", "depends", "constrains", "features", "track_features", } ): package_kwargs[metadata_field] = value package_kwargs = cls.redact_data_urls({"packages": [package_kwargs]})[ "packages" ][0] packages.append( WorkspaceLockPackage(**package_kwargs).model_dump(exclude_none=True) ) for manager, urls in env.external_packages.items(): for url in urls: platform_refs.append({manager: redact_url(url)}) environments[env_name]["packages"][platform] = platform_refs packages_by_url: dict[str, dict[str, Any]] = {} for package in packages: url = package["conda"] packages_by_url[url] = cls.merge_package_records( packages_by_url.get(url, {}), package, url, ) return cls.redact_data_urls( { "version": LOCKFILE_VERSION, "environments": environments, "packages": list(packages_by_url.values()), } )
def render_lockfile( ctx: WorkspaceContext, resolved_envs: dict[str, ResolvedEnvironment], *, config: WorkspaceConfig | None = None, platforms: tuple[str, ...] | None = None, progress: Callable[[str, str], None] | None = None, skip_unsolvable: bool = False, on_skip: Callable[[str, str, SolveError], None] | None = None, baseline_data: dict[str, Any] | None = None, update_targets: Mapping[tuple[str, str], set[str]] | None = None, dry_run: bool = False, ) -> str: """Solve workspace environments and serialize ``conda.lock`` content. Each environment in *resolved_envs* is solved in an empty temporary prefix for every declared platform, intersected with *platforms* when given. Selective updates seed the prefix from that target's locked packages. When *config* is supplied, each ``(environment, platform)`` pair is resolved from the manifest just before solving, so each target uses its own dependency tables. Serialisation is delegated to :func:`.export.multiplatform_export` so this function and ``conda export --format=conda-workspaces-lock-v1`` produce byte-identical output. Solver chatter is silenced inside :meth:`ResolvedEnvironment.solve_for_platform` itself, so the caller is free to render status through the optional *progress* callback without stdout bookkeeping. Fails fast by default: the first unsolvable ``(environment, platform)`` pair raises :class:`SolveError` with the platform named, and no lockfile is written. When *skip_unsolvable* is true, solver failures on an individual pair are reported via *on_skip* (if given) and the lockfile continues with the remaining pairs; :class:`AllTargetsUnsolvableError` is raised only if every pair fails. Non-solver errors (missing channel, invalid manifest, etc.) always abort. Returns the serialized lockfile without writing it. When *dry_run* is true, solver package-cache writes use disposable storage. """ from conda.common.serialize.yaml import dumps as yaml_dumps from conda.models.environment import EnvironmentConfig from .export import multiplatform_export from .resolver import resolve_environment host_platform = ctx.platform envs: list[_SolvedEnvironment] = [] failures: list[SolveError] = [] if update_targets is not None and baseline_data is None: raise ValueError("Selective lock updates require baseline lockfile data") baseline = {} if baseline_data is None else baseline_data if update_targets is not None: baseline = CondaLockLoader.redact_data_urls(baseline) solved_targets: set[tuple[str, str]] = set() with isolated_package_cache(dry_run): for name, resolved in resolved_envs.items(): declared = sorted(set(resolved.platforms or [host_platform])) if update_targets is not None: targets = [ target for target in declared if update_targets.get((name, target)) ] elif platforms is None: targets = declared else: targets = [] for requested in platforms: try: target = resolved.resolve_platform_name(requested, declared) except PlatformError: continue if target not in targets: targets.append(target) if not targets: continue for target in targets: if progress is not None: progress(name, target) target_resolved = ( resolve_environment(config, name, target) if config is not None else resolved ) package_platform = target_resolved.platform_subdir(target) channels = tuple( redact_channel_url(ch) for ch in target_resolved.channels ) try: with tempfile.TemporaryDirectory( prefix="conda-workspaces-lock-" ) as temp_dir: prefix = Path(temp_dir) try: if update_targets is not None: solved_targets.add((name, target)) update_names = update_targets[(name, target)] requested_specs = list( target_resolved.conda_dependencies.values() ) from .envs import _build_pypi_specs requested_specs.extend( _build_pypi_specs(target_resolved) ) try: records = CondaLockLoader.seed_prefix_from_data( baseline, name, target, prefix, requested_specs, package_platform=package_platform, ) except ValueError as exc: raise LockfileIntegrityError( lockfile_path(ctx), str(exc), ) from exc installed_names = {record.name for record in records} missing = update_names - installed_names if missing: names = ", ".join(sorted(missing)) raise LockfileIntegrityError( lockfile_path(ctx), f"environment {name!r} on {target!r} is missing" f" requested roots: {names}", ) records = target_resolved.solve_for_platform( package_platform, prefix=prefix, update_names=update_names, ) else: records = target_resolved.solve_for_platform( package_platform, prefix=prefix, ) finally: delete_prefix_from_linked_data(prefix) except SolveError as exc: if update_targets is not None or not skip_unsolvable: raise failures.append(exc) if on_skip is not None: on_skip(name, target, exc) continue envs.append( _SolvedEnvironment( name=name, platform=target, package_platform=package_platform, config=EnvironmentConfig(channels=channels), explicit_packages=records, ) ) if update_targets is not None: missing_targets = { target for target, names in update_targets.items() if names } - solved_targets if missing_targets: targets = ", ".join( f"{name}/{platform}" for name, platform in sorted(missing_targets) ) raise ValueError(f"Selective lock targets are not declared: {targets}") updated = CondaLockLoader.replace_solutions(baseline, envs) if config is not None: declared_platforms = { platform for resolved in resolved_envs.values() for platform in (resolved.platforms or [host_platform]) } for platform in declared_platforms: status = check_lockfile_satisfiability(config, updated, platform) if status.status != LockfileStatus.UP_TO_DATE: raise LockfileStaleError( config.manifest_path, lockfile_path(ctx), reason=status.reason, ) return yaml_dumps(updated) if failures and not envs: raise AllTargetsUnsolvableError(failures) return multiplatform_export(cast("Iterable[Environment]", envs)) def generate_lockfile( ctx: WorkspaceContext, resolved_envs: dict[str, ResolvedEnvironment], *, config: WorkspaceConfig | None = None, platforms: tuple[str, ...] | None = None, progress: Callable[[str, str], None] | None = None, skip_unsolvable: bool = False, on_skip: Callable[[str, str, SolveError], None] | None = None, output_path: Path | None = None, dry_run: bool = False, publish_lockfile: Callable[[str], None] | None = None, ) -> Path: """Solve workspace environments and write their ``conda.lock``. When *output_path* is given, the lockfile is written there instead of the default ``<workspace>/conda.lock``. Matrix CI runners use this to emit per-platform fragments that a coordinator job later combines with :func:`merge_lockfiles`. When *dry_run* is true, solving and serialization still run without creating or changing the output. *publish_lockfile* can serialize canonical publication with another workspace state file after every solve succeeds. """ path = output_path if output_path is not None else lockfile_path(ctx) validate_lockfile_output(ctx, path) output_generation = regular_file_generation(path) content = render_lockfile( ctx, resolved_envs, config=config, platforms=platforms, progress=progress, skip_unsolvable=skip_unsolvable, on_skip=on_skip, dry_run=dry_run, ) if dry_run: return path if publish_lockfile is not None: publish_lockfile(content) return path return write_lockfile( ctx, content, output_path=path, expected_generation=output_generation, ) def write_lockfile( ctx: WorkspaceContext, content: str, *, output_path: Path | None = None, expected_generation: FileGeneration | None | object = ( _CURRENT_LOCKFILE_OUTPUT_GENERATION ), ) -> Path: """Validate and write already-rendered canonical lockfile content.""" path = output_path if output_path is not None else lockfile_path(ctx) validate_lockfile_output(ctx, path) if expected_generation is _CURRENT_LOCKFILE_OUTPUT_GENERATION: expected_generation = regular_file_generation(path) atomic_write_text(path, content, expected_generation=expected_generation) return path def merge_lockfiles( paths: Sequence[Path], ctx: WorkspaceContext, *, dry_run: bool = False, publish_lockfile: Callable[[str], None] | None = None, ) -> Path: """Merge per-platform ``conda.lock`` fragments into a single lockfile. Designed for CI matrix pipelines that split locking across runners: each runner produces a fragment for one platform (typically via ``conda workspace lock --platform <subdir> --output conda.lock.<subdir>``), and a coordinator job stitches them back into a single ``<workspace>/conda.lock`` with this function. Every fragment must be a ``version: 1`` lockfile. When the current workspace manifest declares the fragment environment, its channel list is canonical: fragments may omit unused manifest channels, but any channels they do declare must be an ordered subset of the manifest's list. Fragment environments not declared in the manifest keep the stricter legacy rule and must agree on their ``channels`` list entry-for-entry and in the same order. Two fragments may not both carry entries for the same ``(environment, platform)`` pair — overlapping platforms indicate a misconfigured pipeline rather than a legitimate merge. Any violation raises :class:`LockfileMergeError` and nothing is written. The merge happens at the YAML layer — going through :class:`CondaLockLoader` would force :func:`conda_lockfiles.records_from_conda_urls.records_from_conda_urls` to fetch every package to populate :class:`PackageRecord` objects, which defeats the purpose of merging cached fragments. We stitch the dicts back together and hand them to conda's YAML dumper. The output is byte-stable with a single-run :func:`generate_lockfile` call over the same inputs: environments are walked in first-seen order across fragments, platforms in alphabetical order within each environment, and top-level ``packages`` are emitted in the same order :meth:`CondaLockLoader.compose` would produce. Duplicate top-level records for the same package URL are accepted only when their metadata matches exactly. When *dry_run* is true, every fragment is validated and the merged data is serialized, but the canonical lockfile remains unchanged. *publish_lockfile* can serialize the canonical write with another workspace state file. Returns the path to the merged lockfile. """ from conda.common.serialize.yaml import dump as yaml_dump if not paths: raise LockfileMergeError("no lockfile fragments were supplied") out_path = lockfile_path(ctx) validate_lockfile_output(ctx, out_path) output_generation = regular_file_generation(out_path) env_order: list[str] = [] env_channels: dict[str, list[dict[str, Any]]] = {} env_platforms: dict[str, dict[str, list[dict[str, Any]]]] = {} seen_pairs: dict[tuple[str, str], Path] = {} packages_by_url: dict[str, dict[str, Any]] = {} package_sources_by_url: dict[str, Path] = {} manifest_env_channels: dict[str, list[dict[str, str]]] = {} manifest_env_channel_urls: dict[str, list[str]] = {} fragment_bytes = 0 fragment_items = 0 for env_name, env_obj in ctx.config.environments.items(): entries = _manifest_channel_entries(ctx.config, env_obj) manifest_env_channels[env_name] = entries manifest_env_channel_urls[env_name] = _normalize_channel_urls( entry["url"] for entry in entries ) for path in paths: if path.is_symlink() or not path.is_file(): raise LockfileMergeError(f"fragment '{path}' does not exist") try: content = read_regular_file_bytes( path, maximum_bytes=MAX_LOCKFILE_BYTES, label="lockfile fragment", ) except ValueError as exc: raise LockfileMergeError( f"fragment '{path}' cannot be read safely" ) from exc fragment_bytes += len(content) if fragment_bytes > MAX_LOCKFILE_BYTES: raise LockfileMergeError( "lockfile fragments exceed the aggregate maximum size of " f"{MAX_LOCKFILE_BYTES:,} bytes" ) try: data = load_lockfile_data(content) fragment_items += validate_document_limits( data, label="Lockfile fragments", maximum_depth=MAX_LOCKFILE_DEPTH, maximum_collection_items=MAX_LOCKFILE_COLLECTION_ITEMS, maximum_items=MAX_LOCKFILE_ITEMS, ) if fragment_items > MAX_LOCKFILE_ITEMS: raise ValueError( "Lockfile fragments contain more than " f"{MAX_LOCKFILE_ITEMS:,} aggregate collection items" ) data = CondaLockLoader.redact_data_urls(data) except ValueError as exc: raise LockfileMergeError(str(exc)) from exc version = data.get("version") if version != LOCKFILE_VERSION: raise LockfileMergeError( f"fragment '{path}' has version {version!r}, " f"expected {LOCKFILE_VERSION}" ) for record in data.get("packages", []) or []: url = record.get("url") or record.get("conda") or record.get("pypi") if not url: continue existing = packages_by_url.get(url) if existing is None: packages_by_url[url] = record package_sources_by_url[url] = path elif existing != record: raise LockfileMergeError( f"fragment '{path}' has a conflicting package record for " f"URL '{redact_url(url)}'", hints=[ ( "The same package URL must have identical top-level" " metadata in every fragment." ), f"The first record came from '{package_sources_by_url[url]}'.", ], ) for env_name, env_data in (data.get("environments") or {}).items(): channels = list(env_data.get("channels") or []) manifest_channels = manifest_env_channels.get(env_name) if manifest_channels is not None: manifest_urls = manifest_env_channel_urls[env_name] fragment_urls = _normalize_channel_urls( str(entry.get("url", "")) for entry in channels ) next_index = 0 for url in fragment_urls: try: next_index = manifest_urls.index(url, next_index) + 1 except ValueError as exc: raise LockfileMergeError( f"environment '{env_name}' channels differ between " f"fragment '{path}' and the manifest", hints=[ ( "Every fragment channel list must be an ordered" " subset of the manifest channel list for a" " shared environment." ), ], ) from exc channels = list(manifest_channels) existing = env_channels.get(env_name) if existing is None: env_order.append(env_name) env_channels[env_name] = channels env_platforms[env_name] = {} elif existing != channels: raise LockfileMergeError( f"environment '{env_name}' channels differ between " f"fragments; '{path}' disagrees with an earlier fragment", hints=[ ( "Every fragment must declare the same channel list" " (same entries, same order) for a shared environment." ), ], ) for platform, refs in (env_data.get("packages") or {}).items(): pair = (env_name, platform) if pair in seen_pairs: raise LockfileMergeError( f"environment '{env_name}' on platform " f"'{platform}' is present in both " f"'{seen_pairs[pair]}' and '{path}'", hints=[ ( "Each (environment, platform) pair must come" " from exactly one fragment." ), ], ) seen_pairs[pair] = path package_platform = ctx.config.platform_subdir(platform) try: channel_urls = CondaLockLoader.channel_urls_for_env_data( {"channels": channels}, package_platform, ) except ValueError as exc: raise LockfileMergeError( f"environment '{env_name}' channels in fragment " f"'{path}' cannot be resolved" ) from exc validated_refs: list[dict[str, Any]] = [] for ref in refs or []: if not isinstance(ref, dict) or set(ref) != {"conda"}: raise LockfileMergeError( f"environment '{env_name}' package refs on " f"'{platform}' in fragment '{path}' must contain " "exactly one 'conda' entry" ) url = ref["conda"] if not isinstance(url, str): raise LockfileMergeError( f"environment '{env_name}' contains an invalid " f"package ref on '{platform}' in fragment '{path}'" ) if not CondaLockLoader.url_matches_channel(url, channel_urls): raise LockfileMergeError( f"package URL '{redact_url(url)}' in environment " f"'{env_name}' on '{platform}' is not under any " "declared channel" ) record = packages_by_url.get(url) if record is None: raise LockfileMergeError( f"package URL '{redact_url(url)}' in environment " f"'{env_name}' on '{platform}' has no top-level " "package record" ) try: CondaLockLoader.digest_fragment_for_record(record, url) except ValueError as exc: raise LockfileMergeError(str(exc)) from exc validated_refs.append(ref) env_platforms[env_name][platform] = validated_refs # Rebuild top-level ``packages`` in the same order # :meth:`CondaLockLoader.compose` would produce for a single-run # solve: iterate envs in first-seen order, platforms alphabetically, # then each platform's refs (already sorted by package name by the # producing fragment). merged_packages: list[dict[str, Any]] = [] emitted_urls: set[str] = set() for env_name in env_order: for platform in sorted(env_platforms[env_name]): for ref in env_platforms[env_name][platform]: url = ref.get("conda") if not url or url in emitted_urls: continue record = packages_by_url.get(url) if record is not None: merged_packages.append(record) emitted_urls.add(url) merged = { "version": LOCKFILE_VERSION, "environments": { name: { "channels": env_channels[name], "packages": { platform: env_platforms[name][platform] for platform in sorted(env_platforms[name]) }, } for name in env_order }, "packages": merged_packages, } try: validate_document_limits( merged, label="Merged lockfile", maximum_depth=MAX_LOCKFILE_DEPTH, maximum_collection_items=MAX_LOCKFILE_COLLECTION_ITEMS, maximum_items=MAX_LOCKFILE_ITEMS, ) merged = CondaLockLoader.redact_data_urls(merged) except ValueError as exc: raise LockfileMergeError(str(exc)) from exc buf = io.StringIO() yaml_dump(merged, buf) try: content = decode_limited_text( buf.getvalue(), maximum_bytes=MAX_LOCKFILE_BYTES, label="Merged lockfile", ) except ValueError as exc: raise LockfileMergeError(str(exc)) from exc if dry_run: return out_path if publish_lockfile is not None: publish_lockfile(content) else: atomic_write_text( out_path, content, expected_generation=output_generation, ) return out_path def validate_lockfile_output(ctx: WorkspaceContext, path: Path) -> None: """Reject lock outputs that could overwrite the selected manifest.""" manifest_path = Path(ctx.config.manifest_path) if output_paths_collide(path, manifest_path): raise ValueError("Lockfile output cannot overwrite the workspace manifest.") if path.is_symlink(): raise ValueError("Lockfile output cannot be a symbolic link.") validate_file_output(path) @dataclass class LockfileInstallPlan: """A fully validated exact install that can be executed without refetching.""" env_name: str prefix: Path target_prefix_override: str | Path | None records: list[PackageRecord] requested_specs: list[str] | None prune_setup: PrefixSetup | None resolved: ResolvedEnvironment | None update_path_dependencies: bool preflight_prefix_identity: tuple[int, int] | None expected_prefix_identity: tuple[int, int] | None validate_workspace: Callable[[], None] | None def validate_virtual_packages(self, platform: str) -> None: """Check required and optional virtual constraints against this machine.""" from conda.base.context import context as conda_context from conda.models.match_spec import MatchSpec requirements = [ (MatchSpec(spec), required) for record in self.records for specs, required in ((record.depends, True), (record.constrains, False)) for spec in specs if spec.startswith("__") ] if self.resolved is not None: requirements.extend( (spec, True) for spec in ( *self.resolved.conda_dependencies.values(), *self.resolved.system_requirement_specs(), ) if spec.name and spec.name.startswith("__") ) if not requirements: return with conda_context._override("_subdir", platform): actual = { record.name: record for record in conda_context.plugin_manager.get_virtual_package_records() } for requirement, required in requirements: record = actual.get(requirement.name) if (record is None and required) or ( record is not None and not requirement.match(record) ): raise CondaWorkspacesError( f"This machine does not satisfy {requirement} " f"required by environment '{self.env_name}'." ) def validate_workspace_generation(self) -> None: """Revalidate a guarded workspace immediately before path consumers.""" if self.validate_workspace is not None: self.validate_workspace() def remove_preflight_prefix(self, ctx: WorkspaceContext) -> None: """Remove the managed prefix generation inspected during preflight.""" identity = self.preflight_prefix_identity if identity is None: return self.validate_workspace_generation() managed_prefix = canonicalize_system_path_alias(ctx.env_prefix(self.env_name)) if self.prefix != managed_prefix: raise CondaWorkspacesError( "Cannot replace an explicit prefix through a workspace install." ) from .envs import remove_environment remove_environment( ctx, self.env_name, expected_prefix_identity=identity, ) @staticmethod def prefix_identity(path: Path) -> tuple[int, int] | None: """Return one regular directory generation without following links.""" try: current = path.lstat() except FileNotFoundError: return None if not stat.S_ISDIR(current.st_mode): raise CondaWorkspacesError( f"Environment prefix is not a regular directory: {path}" ) return current.st_dev, current.st_ino @classmethod def prepare( cls, ctx: WorkspaceContext, env_name: str, *, prefix: Path | None = None, platform: str | None = None, target_prefix_override: str | Path | None = None, lockfile_data: dict[str, Any] | None = None, update_names: set[str] | None = None, prune: bool = True, replace_existing: bool = False, require_absent: bool = False, validate_workspace: Callable[[], None] | None = None, ) -> LockfileInstallPlan: """Fetch packages and validate every non-mutating install input.""" from conda.base.context import context as conda_context from conda.core.link import PrefixSetup from conda.core.prefix_data import PrefixData from conda.history import History from conda.misc import get_package_records_from_explicit from conda.models.match_spec import MatchSpec path = lockfile_path(ctx) if lockfile_data is None: try: if validate_workspace is not None: validate_workspace() lockfile_data = load_lockfile_data( read_regular_file_bytes( path, maximum_bytes=MAX_LOCKFILE_BYTES, label="workspace lockfile", ) ) if validate_workspace is not None: validate_workspace() except (OSError, ValueError) as exc: raise LockfileNotFoundError("(all)", path) from exc loader = CondaLockLoader(path, data=lockfile_data) lock_platform = platform or ctx.platform package_platform = ctx.config.platform_subdir(lock_platform) resolved: ResolvedEnvironment | None = None try: from .resolver import resolve_environment try: resolved = resolve_environment( ctx.config, env_name, lock_platform ).with_absolute_paths(Path(ctx.config.root)) lock_platform = ctx.config.resolve_platform_name( lock_platform, resolved.platforms or ctx.config.platforms, ) package_platform = ctx.config.platform_subdir(lock_platform) except (EnvironmentNotFoundError, PlatformError): if platform is not None: raise pass if ( platform is not None and package_platform != conda_context._native_subdir() ): raise CondaWorkspacesError( f"Cannot install platform '{lock_platform}' on this machine " f"({conda_context._native_subdir()})." ) urls = loader.explicit_package_specs_for( lock_platform, env_name, package_platform=package_platform, ) except (ValueError, OSError) as exc: raise LockfileNotFoundError(env_name, path) from exc install_prefix = canonicalize_system_path_alias( prefix or ctx.env_prefix(env_name) ) initial_prefix_identity = cls.prefix_identity(install_prefix) if require_absent and initial_prefix_identity is not None: raise CondaWorkspacesError( f"Workspace environment prefix already exists: {install_prefix}" ) validate_directory_output(install_prefix) override = ( conda_context._override( "target_prefix_override", str(target_prefix_override), ) if target_prefix_override is not None else nullcontext() ) # Preparation only fetches packages. CLI dry runs isolate those cache # writes and must still finish validation before skipping installation. with override, conda_context._override("dry_run", False): if validate_workspace is not None: validate_workspace() records = cast( "list[PackageRecord]", list(get_package_records_from_explicit(urls)), ) if validate_workspace is not None: validate_workspace() requested_specs: list[MatchSpec] | None = None unlocked_requested_names: set[str | None] = set() if resolved is not None: from .envs import ( _build_pypi_specs, validate_path_dependencies, ) requested_specs = list(resolved.conda_dependencies.values()) requested_specs.extend(_build_pypi_specs(resolved)) for dependency in resolved.pypi_dependencies.values(): if dependency.path: spec = MatchSpec(dependency.name) requested_specs.append(spec) unlocked_requested_names.add(spec.name) if update_names is None: validate_path_dependencies(resolved) prune_setup = None existing_prefix = ( anchored_directory(install_prefix) if initial_prefix_identity is not None else nullcontext(None) ) if validate_workspace is not None: validate_workspace() with existing_prefix: if resolved is not None: from .envs import validate_activation_metadata validate_activation_metadata(install_prefix, resolved) if initial_prefix_identity is not None and ( cls.prefix_identity(install_prefix) != initial_prefix_identity ): raise CondaWorkspacesError( f"Environment prefix changed during preflight: {install_prefix}" ) prefix_data = PrefixData(str(install_prefix)) if prefix_data.is_environment(): locked_names = {record.name for record in records} requested_names = ( {spec.name for spec in requested_specs} if requested_specs is not None else None ) stale_requests = ( tuple( spec for name, spec in History(str(install_prefix)) .get_requested_specs_map() .items() if name not in requested_names ) if requested_names is not None else () ) extras = tuple( record for record in prefix_data.iter_records() if record.name not in locked_names and record.name not in unlocked_requested_names ) if prune and not replace_existing and (extras or stale_requests): prune_setup = PrefixSetup( str(install_prefix), extras, (), stale_requests, (), (), ) if initial_prefix_identity is not None and ( cls.prefix_identity(install_prefix) != initial_prefix_identity ): raise CondaWorkspacesError( f"Environment prefix changed during preflight: {install_prefix}" ) if validate_workspace is not None: validate_workspace() if require_absent and cls.prefix_identity(install_prefix) is not None: raise CondaWorkspacesError( f"Workspace environment prefix already exists: {install_prefix}" ) plan = cls( env_name=env_name, prefix=install_prefix, target_prefix_override=target_prefix_override, records=records, requested_specs=( [str(spec) for spec in requested_specs] if requested_specs is not None else None ), prune_setup=prune_setup, resolved=resolved, update_path_dependencies=update_names is None, preflight_prefix_identity=initial_prefix_identity, expected_prefix_identity=( None if replace_existing else initial_prefix_identity ), validate_workspace=validate_workspace, ) plan.validate_virtual_packages(package_platform) return plan @contextmanager def open_prefix(self) -> Iterator[Callable[[], None]]: """Create or reopen the planned prefix through an anchored parent.""" self.validate_workspace_generation() expected = self.expected_prefix_identity with anchored_directory(self.prefix.parent, create=True) as parent_descriptor: if parent_descriptor is None: current = self.prefix_identity(self.prefix) if current is None: if expected is not None: raise CondaWorkspacesError( f"Environment prefix changed before install: {self.prefix}" ) self.prefix.mkdir() current = self.prefix_identity(self.prefix) elif current != expected: raise CondaWorkspacesError( f"Environment prefix changed before install: {self.prefix}" ) assert current is not None def require_current_prefix() -> None: self.validate_workspace_generation() if self.prefix_identity(self.prefix) != current: raise CondaWorkspacesError( f"Environment prefix changed during install: {self.prefix}" ) yield require_current_prefix return opened_parent = os.fstat(parent_descriptor) try: current = os.stat( self.prefix.name, dir_fd=parent_descriptor, follow_symlinks=False, ) except FileNotFoundError: if expected is not None: raise CondaWorkspacesError( f"Environment prefix changed before install: {self.prefix}" ) from None os.mkdir(self.prefix.name, dir_fd=parent_descriptor) current = os.stat( self.prefix.name, dir_fd=parent_descriptor, follow_symlinks=False, ) else: identity = current.st_dev, current.st_ino if expected is None or identity != expected: raise CondaWorkspacesError( f"Environment prefix changed before install: {self.prefix}" ) if not stat.S_ISDIR(current.st_mode): raise CondaWorkspacesError( f"Environment prefix is not a regular directory: {self.prefix}" ) prefix_flags = ( os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) ) prefix_descriptor = os.open( self.prefix.name, prefix_flags, dir_fd=parent_descriptor, ) try: opened_prefix = os.fstat(prefix_descriptor) identity = opened_prefix.st_dev, opened_prefix.st_ino if identity != (current.st_dev, current.st_ino): raise CondaWorkspacesError( f"Environment prefix changed before install: {self.prefix}" ) def require_current_prefix() -> None: self.validate_workspace_generation() try: parent_entry = os.stat( self.prefix.name, dir_fd=parent_descriptor, follow_symlinks=False, ) live_parent = self.prefix.parent.lstat() live_prefix = self.prefix.lstat() except OSError as exc: raise CondaWorkspacesError( f"Environment prefix changed during install: {self.prefix}" ) from exc if ( not stat.S_ISDIR(parent_entry.st_mode) or (parent_entry.st_dev, parent_entry.st_ino) != identity or not stat.S_ISDIR(live_parent.st_mode) or (live_parent.st_dev, live_parent.st_ino) != (opened_parent.st_dev, opened_parent.st_ino) or not stat.S_ISDIR(live_prefix.st_mode) or (live_prefix.st_dev, live_prefix.st_ino) != identity ): raise CondaWorkspacesError( f"Environment prefix changed during install: {self.prefix}" ) require_current_prefix() yield require_current_prefix finally: os.close(prefix_descriptor) def execute(self) -> None: """Execute this plan while retaining its fetched package records.""" from conda.base.context import context as conda_context from conda.core.link import UnlinkLinkTransaction from conda.misc import install_explicit_packages override = ( conda_context._override( "target_prefix_override", str(self.target_prefix_override), ) if self.target_prefix_override is not None else nullcontext() ) with override, self.open_prefix() as require_current_prefix: require_current_prefix() if self.prune_setup is not None: UnlinkLinkTransaction(self.prune_setup).execute() require_current_prefix() install_explicit_packages( package_cache_records=self.records, prefix=str(self.prefix), requested_specs=self.requested_specs, ) require_current_prefix() if self.resolved is not None: from .envs import ( _apply_activation_env, _apply_activation_scripts, _install_path_deps, ) _apply_activation_env(self.prefix, self.resolved.activation_env) require_current_prefix() _apply_activation_scripts( self.prefix, self.resolved.activation_scripts, ) require_current_prefix() if self.update_path_dependencies: _install_path_deps(self.prefix, self.resolved) require_current_prefix() sys.stdout.flush() def install_from_lockfile( ctx: WorkspaceContext, env_name: str, *, prefix: Path | None = None, platform: str | None = None, target_prefix_override: str | Path | None = None, dry_run: bool = False, lockfile_data: dict[str, Any] | None = None, update_names: set[str] | None = None, prune: bool = True, replace_existing: bool = False, validate_workspace: Callable[[], None] | None = None, ) -> LockfileInstallPlan: """Install an environment from ``conda.lock``. Reads the lockfile via :class:`CondaLockLoader`, extracts the package list for *env_name* on the current platform, downloads the exact packages, optionally removes conda packages absent from the lock, and installs them into the environment prefix without a solver. Requested-spec history is reconciled to the resolved manifest roots. When *dry_run* is true, package records are fetched and the requested specs, prune transaction, activation inputs, and local project inputs are prepared without creating or changing the target prefix. *lockfile_data* installs a previously rendered in-memory solution so solving and installation cannot diverge. *update_names* suppresses rebuilding unrelated local path dependencies during selective updates. When *prune* is false, packages and requested specs absent from the lock are preserved. *replace_existing* removes the exact managed prefix generation inspected during preparation before recreating it. A dry run only prepares that replacement. It cannot be combined with *prefix*. Raises ``LockfileNotFoundError`` if the lockfile is missing or does not contain the requested environment/platform. """ if replace_existing and prefix is not None: raise CondaWorkspacesError( "Prefix replacement cannot be combined with an explicit prefix." ) plan = LockfileInstallPlan.prepare( ctx, env_name, prefix=prefix, platform=platform, target_prefix_override=target_prefix_override, lockfile_data=lockfile_data, update_names=update_names, prune=prune, replace_existing=replace_existing, validate_workspace=validate_workspace, ) if not dry_run: if replace_existing: plan.remove_preflight_prefix(ctx) plan.execute() return plan