Source code for conda_workspaces.models

"""Data models for workspace configuration.

These dataclasses represent the parsed workspace manifest in a
format-agnostic way.  Parsers convert from pixi.toml / pyproject.toml /
conda.toml into these models; downstream code only works with
these types.

Conda dependencies use :class:`~conda.models.match_spec.MatchSpec`
directly, and channels use :class:`~conda.models.channel.Channel`,
so the workspace layer benefits from conda's own validation, URL
resolution, and spec parsing.
"""

from __future__ import annotations

import re
from collections.abc import Collection, Mapping
from dataclasses import dataclass, field, fields, replace
from pathlib import PurePosixPath
from typing import TYPE_CHECKING, ClassVar
from urllib.parse import unquote_to_bytes, urlsplit

if TYPE_CHECKING:
    from collections.abc import Iterable
    from typing import Any

from conda.base.constants import KNOWN_SUBDIRS
from conda.models.channel import Channel
from conda.models.match_spec import MatchSpec

from .exceptions import (
    EnvironmentNameInvalidError,
    EnvironmentNotFoundError,
    FeatureNotFoundError,
    PlatformError,
)
from .paths import is_path_segment, portable_path_key

_URL_CANDIDATE_RE = re.compile(r"(?i)(?:[a-z][a-z0-9+.-]*:)?//[^\s'\"<>]*")


def _decode_url_bytes(value: str) -> bytes | None:
    decoded = value.encode()
    for _ in range(4):
        next_value = unquote_to_bytes(decoded)
        if next_value == decoded:
            return decoded
        decoded = next_value
    return None


def _redact_url_once(url: str) -> str:
    """Redact sensitive URL structure visible without decoding the whole value."""
    absolute_like = re.match(r"(?i)^[a-z][a-z0-9+.-]*://", url) is not None
    scheme_relative_like = url.startswith("//")
    try:
        parts = urlsplit(url)
    except ValueError:
        return "<redacted-url>" if absolute_like or scheme_relative_like else url
    if absolute_like and not parts.netloc and parts.scheme != "file":
        return "<redacted-url>"
    if scheme_relative_like and not parts.netloc:
        return "<redacted-url>"
    if not parts.scheme and not scheme_relative_like:
        return url
    if parts.scheme != "file":
        try:
            hostname = parts.hostname
            _ = parts.port
        except ValueError:
            return "<redacted-url>"
        if (
            not hostname
            or "\\" in parts.netloc
            or any(character.isspace() for character in parts.netloc)
        ):
            return "<redacted-url>"

    decoded_path = _decode_url_bytes(parts.path)
    if decoded_path is None:
        return "<redacted-url>"
    if re.search(rb"(?i)(?:[a-z][a-z0-9+.-]*:)?//[^/]*@", decoded_path):
        return "<redacted-url>"

    segments: list[str] = []
    redact_next = False
    path_changed = False
    for raw_segment in parts.path.split("/"):
        decoded_segment = _decode_url_bytes(raw_segment)
        if decoded_segment is None:
            return "<redacted-url>"

        normalized_segment = decoded_segment.lower()
        if redact_next:
            redact_next = False
            path_changed = True
            continue
        if normalized_segment == b"t" or normalized_segment.endswith(b"/t"):
            redact_next = True
            path_changed = True
            continue
        if normalized_segment.startswith(b"t/") or b"/t/" in normalized_segment:
            path_changed = True
            continue
        segments.append(raw_segment)
    path = "/".join(segments) if path_changed else parts.path
    redacted = parts._replace(
        netloc=parts.netloc.rpartition("@")[2],
        path=path,
        query="",
        fragment="",
    ).geturl()
    separator = url.find(":")
    if separator >= 0 and url[:separator].lower() == parts.scheme:
        redacted = url[:separator] + redacted[separator:]
    return redacted


[docs] def redact_url(url: str) -> str: """Remove credentials, tokens, query, and fragment from an absolute URL.""" redacted = _redact_url_once(url) decoded = _decode_url_bytes(redacted) if decoded is None: return "<redacted-url>" try: decoded_text = decoded.decode() except UnicodeDecodeError: return "<redacted-url>" if decoded_text != redacted and _redact_url_once(decoded_text) != decoded_text: return "<redacted-url>" return redacted
[docs] def has_url_credentials(value: str) -> bool: """Return whether a manifest string contains sensitive URL material.""" decoded = _decode_url_bytes(value) if decoded is None: return True if re.search( rb"(?i)(?:^|[^a-z0-9._~-])t[\\/][^\\/\s?#]+(?:[\\/]|$)", decoded, ): return True candidates = [value] if decoded != value.encode(): candidates.append(decoded.decode("utf-8", errors="ignore")) return any( redact_url(match.group(0)) != match.group(0) for candidate in candidates for match in _URL_CANDIDATE_RE.finditer(candidate) )
[docs] def has_match_spec_url_credentials( spec: MatchSpec, *, include_channel: bool = True, ) -> bool: """Return whether a conda MatchSpec field contains sensitive URL material.""" return any( value is not None and has_url_credentials(str(value)) for field in MatchSpec.FIELD_NAMES if include_channel or field != "channel" for value in (spec.get_raw_value(field),) )
[docs] def redact_url_text(value: str) -> str: """Remove sensitive URL material embedded in a larger diagnostic string.""" pieces: list[str] = [] offset = 0 for match in _URL_CANDIDATE_RE.finditer(value): pieces.append(value[offset : match.start()]) pieces.append(redact_url(match.group(0))) offset = match.end() pieces.append(value[offset:]) redacted = "".join(pieces) if has_url_credentials(redacted): return "<redacted-url-value>" return redacted
[docs] def has_url_credentials_in_data(value: object) -> bool: """Return whether nested structured data contains URL credentials.""" values = [value] seen: set[int] = set() while values: current = values.pop() if isinstance(current, str): if has_url_credentials(current): return True continue if isinstance(current, Mapping): identity = id(current) if identity in seen: continue seen.add(identity) values.extend(current.keys()) values.extend(current.values()) elif isinstance(current, Collection) and not isinstance( current, (bytes, bytearray), ): identity = id(current) if identity in seen: continue seen.add(identity) values.extend(current) return False
[docs] def normalize_url_scheme(url: str) -> str: """Make URL schemes explicit and lowercase without changing URL identity.""" if url.startswith("//"): try: if urlsplit(url).netloc: return f"https:{url}" except ValueError: return url match = re.match(r"(?i)^([a-z][a-z0-9+.-]*)(?=://)", url) if match is None: return url return match.group(1).lower() + url[match.end(1) :]
[docs] def redact_channel_name(channel: str | Channel) -> str: """Return a credential-free channel name for display or export. Conda treats a relative ``t/<token>/<channel>`` value as a named channel, so its canonical name still contains the token. Resolve that form only when redaction changes the resolved URL, while preserving ordinary names such as ``conda-forge``. """ original = channel.canonical_name if isinstance(channel, Channel) else channel if original.startswith("//"): original = normalize_url_scheme(original) redacted = redact_url(original) if redacted != original: return redact_url_text(redacted) parsed = ( channel if isinstance(channel, Channel) else Channel(normalize_url_scheme(channel)) ) location = parsed.location or "" if location.startswith("//"): token_path = f"/t/{parsed.token}" if parsed.token else "" return redact_url_text( normalize_url_scheme(redact_url(f"{location}{token_path}/{parsed.name}")) ) resolved = str(parsed) redacted_resolved = redact_url(resolved) candidate = redacted_resolved if redacted_resolved != resolved else original return redact_url_text(candidate)
[docs] def redact_channel_url(channel: Channel) -> str: """Return a credential-free resolved URL for a channel or channel name.""" if not isinstance(channel, Channel): return redact_channel_name(str(channel)) canonical = redact_channel_name(channel) if canonical != channel.canonical_name: return canonical value = ( canonical if re.match(r"(?i)^[a-z][a-z0-9+.-]*://", canonical) else str(channel) ) return redact_url(value)
[docs] @dataclass(frozen=True) class LockfileStatus: """Status of the lockfile relative to the workspace manifest.""" UP_TO_DATE: ClassVar[str] = "up-to-date" OUT_OF_DATE: ClassVar[str] = "out-of-date" MISSING: ClassVar[str] = "missing" status: str reason: str = "" def __post_init__(self) -> None: object.__setattr__(self, "reason", redact_url_text(self.reason or ""))
[docs] @dataclass(frozen=True) class PyPIDependency: """A PyPI dependency (PEP 508 string). Version-only dependencies are translated to conda equivalents via ``conda-pypi``'s grayskull mapping and merged into the same solver call as conda deps. Local ``path`` dependencies are built and installed post-solve via conda-pypi's build system. Git and URL dependencies are parsed for pixi manifest compatibility but are not installed yet. """ name: str spec: str = "" extras: tuple[str, ...] = () path: str | None = None editable: bool = False git: str | None = None branch: str | None = None tag: str | None = None rev: str | None = None url: str | None = None def __str__(self) -> str: base = self.name if self.extras: base = f"{base}[{','.join(self.extras)}]" if self.git: return f"{base} @ git+{self.git}" if self.path: prefix = "-e " if self.editable else "" return f"{prefix}{base} @ {self.path}" if self.url: return f"{base} @ {self.url}" if self.spec: return f"{base}{self.spec}" return base
[docs] def to_toml(self) -> str | dict[str, object]: """Return this dependency in its manifest representation.""" fields: dict[str, object] = { "version": self.spec, "extras": list(self.extras), "path": self.path, "editable": self.editable, "git": self.git, "branch": self.branch, "tag": self.tag, "rev": self.rev, "url": self.url, } return {key: value for key, value in fields.items() if value} or "*"
[docs] def redacted(self) -> PyPIDependency: """Return a copy safe to include in diagnostic output.""" return replace( self, spec=redact_url_text(self.spec), path=redact_url_text(self.path) if self.path else None, git=redact_url_text(self.git) if self.git else None, url=redact_url_text(self.url) if self.url else None, )
[docs] def to_manifest_toml(self) -> str | dict[str, object]: """Return a safe manifest value without changing direct URL semantics.""" if self.redacted() != self: raise ValueError( f"PyPI dependency '{self.name}' has a direct URL that cannot be" " written safely. Remove embedded authentication, Anaconda token" " paths, queries, and fragments, then configure authentication" " outside the workspace manifest." ) return self.to_toml()
[docs] @dataclass class Feature: """A composable group of dependencies and settings. Features map directly to ``[feature.<name>]`` tables in a pixi manifest. They can provide conda dependencies, PyPI dependencies, channel overrides, platform restrictions, and environment variables. The special feature named ``"default"`` corresponds to the top-level workspace dependencies. """ DEFAULT_NAME: ClassVar[str] = "default" name: str conda_dependencies: dict[str, MatchSpec] = field(default_factory=dict) pypi_dependencies: dict[str, PyPIDependency] = field(default_factory=dict) channels: list[Channel] = field(default_factory=list) platforms: list[str] = field(default_factory=list) system_requirements: dict[str, str] = field(default_factory=dict) activation_scripts: list[str] = field(default_factory=list) activation_env: dict[str, str] = field(default_factory=dict) # Per-platform overrides: platform -> deps target_conda_dependencies: dict[str, dict[str, MatchSpec]] = field( default_factory=dict ) target_pypi_dependencies: dict[str, dict[str, PyPIDependency]] = field( default_factory=dict ) @property def is_default(self) -> bool: return self.name == self.DEFAULT_NAME
[docs] @dataclass class Environment: """A named environment composed from features and private dependencies. This maps to a ``[environments]`` entry in a pixi manifest. An environment inherits the ``default`` feature plus any additional features listed in *features*. Dependencies declared directly on the environment are private to it and override dependencies from its features. *no_default_feature* can be set to exclude the default feature, matching pixi's ``no-default-feature = true`` option. """ DEFAULT_NAME: ClassVar[str] = "default" name: str features: list[str] = field(default_factory=list) no_default_feature: bool = False conda_dependencies: dict[str, MatchSpec] = field(default_factory=dict) pypi_dependencies: dict[str, PyPIDependency] = field(default_factory=dict) target_conda_dependencies: dict[str, dict[str, MatchSpec]] = field( default_factory=dict ) target_pypi_dependencies: dict[str, dict[str, PyPIDependency]] = field( default_factory=dict ) @property def is_default(self) -> bool: return self.name == self.DEFAULT_NAME
[docs] @dataclass(frozen=True) class ArchiveConfig: """Archive settings from ``[workspace.archive]``.""" include: tuple[str, ...] = () exclude: tuple[str, ...] = () compression: str = "zst" compression_level: int | None = None
[docs] @dataclass class WorkspaceConfig: """Complete parsed workspace configuration. This is the top-level model that parsers produce. It contains all channels, platforms, features, and environments defined in a workspace manifest. *manifest_path* points to the file that was parsed (for error messages and relative path resolution). """ name: str | None = None version: str | None = None description: str | None = None channels: list[Channel] = field(default_factory=list) platforms: list[str] = field(default_factory=list) platform_subdirs: dict[str, str] = field(default_factory=dict) platform_system_requirements: dict[str, dict[str, str]] = field( default_factory=dict ) # Root-level dependency pool from [workspace.dependencies]. # Concrete feature dependencies may opt in with { workspace = true }. workspace_dependencies: dict[str, MatchSpec] = field(default_factory=dict) # Features keyed by name; always includes "default" features: dict[str, Feature] = field(default_factory=dict) # Environments keyed by name; always includes "default" environments: dict[str, Environment] = field(default_factory=dict) # Workspace root directory (parent of manifest file) root: str = "" # Path to the manifest file that was parsed manifest_path: str = "" # Directory for project-local environments (default: .conda/envs) envs_dir: str = ".conda/envs" # Preview / optional fields channel_priority: str | None = None # "strict" | "flexible" | "disabled" archive: ArchiveConfig = field(default_factory=ArchiveConfig) platform_requirement_toml_aliases: ClassVar[dict[str, str]] = { "glibc": "libc", "__glibc": "__glibc", "osx": "macos", "__osx": "__osx", "win": "windows", "__win": "__win", } rich_platform_name_order: ClassVar[tuple[str, ...]] = ( "cuda", "archspec", "glibc", "linux", "osx", "win", ) _platform_name_segment_re: ClassVar[re.Pattern[str]] = re.compile(r"[^A-Za-z0-9]+") @property def _manifest_text(self) -> str | None: """Return the exact manifest generation accepted by the parser.""" return self.__dict__.get("_accepted_manifest_text") @_manifest_text.setter def _manifest_text(self, value: str | None) -> None: self.__dict__["_accepted_manifest_text"] = value def __post_init__(self) -> None: """Ensure the default feature and environment always exist. Also validates that all declared platforms map to recognised conda subdirs (e.g. ``linux-64``, ``osx-arm64``). Pixi rich platforms may use workspace-scoped names such as ``linux-64-cuda``; those names stay in :attr:`platforms`, while :attr:`platform_subdirs` records the concrete conda subdir the solver should use. """ if Feature.DEFAULT_NAME not in self.features: self.features[Feature.DEFAULT_NAME] = Feature(name=Feature.DEFAULT_NAME) if Environment.DEFAULT_NAME not in self.environments: self.environments[Environment.DEFAULT_NAME] = Environment( name=Environment.DEFAULT_NAME ) environment_keys: dict[tuple[str, ...], str] = {} for name in self.environments: if not is_path_segment(name): raise EnvironmentNameInvalidError(name) key = portable_path_key(PurePosixPath(name)) existing = environment_keys.get(key) if existing is not None: raise EnvironmentNameInvalidError( name, reason=f"it conflicts with environment '{existing}'", ) environment_keys[key] = name for platform in self.platforms: self.platform_subdirs.setdefault(platform, platform) invalid = [ subdir for platform in self.platforms if (subdir := self.platform_subdirs[platform]) not in KNOWN_SUBDIRS ] if invalid: raise PlatformError( ", ".join(invalid), sorted(KNOWN_SUBDIRS), )
[docs] @staticmethod def default_system_requirements_for_subdir(subdir: str) -> dict[str, str]: """Return Pixi's default rich-platform requirements for *subdir*.""" if subdir.startswith("linux-"): return {"glibc": "2.28", "linux": "4.18"} if subdir.startswith("osx-"): return {"osx": "13.0"} return {}
[docs] @classmethod def platform_name_segment(cls, value: str) -> str: """Sanitize one rich-platform name segment the way Pixi does.""" return cls._platform_name_segment_re.sub("-", value).strip("-")
[docs] @classmethod def synthesize_platform_name( cls, subdir: str, requirements: dict[str, str], ) -> str: """Return Pixi's generated name for an unnamed rich platform.""" defaults = cls.default_system_requirements_for_subdir(subdir) segments = [subdir] canonical_requirements: dict[str, str] = {} raw_requirements: dict[str, str] = {} for name, value in requirements.items(): bare_name = name.removeprefix("__") if bare_name in cls.rich_platform_name_order: canonical_requirements[bare_name] = value else: raw_requirements[name] = value for name in cls.rich_platform_name_order: value = canonical_requirements.get(name) if value is None or defaults.get(name) == value: continue key = cls.platform_name_segment(name) val = cls.platform_name_segment(value) if key and val: segments.extend((key, val)) for name in sorted(raw_requirements): value = raw_requirements[name] key = cls.platform_name_segment(name.removeprefix("__")) val = cls.platform_name_segment(value) if key and val: segments.extend((key, val)) return "-".join(segment for segment in segments if segment)
[docs] def platform_subdir(self, platform: str) -> str: """Return the concrete conda subdir for a declared platform name.""" return self.platform_subdirs.get(platform, platform)
[docs] def platform_names_for_subdir( self, subdir: str, platforms: Iterable[str] | None = None, ) -> list[str]: """Return declared platform names that solve against *subdir*.""" candidates = list(platforms or self.platforms) return [ platform for platform in candidates if self.platform_subdir(platform) == subdir ]
[docs] def resolve_platform_name( self, requested: str, platforms: Iterable[str] | None = None, ) -> str: """Resolve a requested name or subdir to a declared platform name.""" candidates = list(platforms or self.platforms) if requested in candidates: return requested matches = self.platform_names_for_subdir(requested, candidates) if matches: return matches[0] raise PlatformError(requested, sorted(candidates))
[docs] def get_environment(self, name: str) -> Environment: """Return the environment with *name*, raising if not found.""" if name not in self.environments: raise EnvironmentNotFoundError(name, list(self.environments.keys())) return self.environments[name]
[docs] def resolve_features(self, environment: Environment) -> list[Feature]: """Return the ordered list of features for *environment*. By default, the ``default`` feature is prepended unless the environment sets ``no_default_feature``. """ result: list[Feature] = [] if not environment.no_default_feature: result.append(self.features[Feature.DEFAULT_NAME]) for fname in environment.features: if fname not in self.features: raise FeatureNotFoundError(fname, environment.name) feat = self.features[fname] if feat not in result: result.append(feat) return result
[docs] def merged_conda_dependencies( self, environment: Environment, platform: str | None = None, ) -> dict[str, MatchSpec]: """Merge conda dependencies across features for *environment*. Later features override earlier ones. If *platform* is given, target-specific dependencies are also merged in. """ merged: dict[str, MatchSpec] = {} platform_keys = self.target_platform_keys(platform) for feature in self.resolve_features(environment): merged.update(feature.conda_dependencies) for key in platform_keys: if key in feature.target_conda_dependencies: merged.update(feature.target_conda_dependencies[key]) merged.update(environment.conda_dependencies) for key in platform_keys: if key in environment.target_conda_dependencies: merged.update(environment.target_conda_dependencies[key]) return merged
[docs] def merged_pypi_dependencies( self, environment: Environment, platform: str | None = None, ) -> dict[str, PyPIDependency]: """Merge PyPI dependencies across features for *environment*.""" merged: dict[str, PyPIDependency] = {} platform_keys = self.target_platform_keys(platform) for feature in self.resolve_features(environment): merged.update(feature.pypi_dependencies) for key in platform_keys: if key in feature.target_pypi_dependencies: merged.update(feature.target_pypi_dependencies[key]) merged.update(environment.pypi_dependencies) for key in platform_keys: if key in environment.target_pypi_dependencies: merged.update(environment.target_pypi_dependencies[key]) return merged
[docs] def merged_system_requirements( self, environment: Environment, platform: str | None = None, ) -> dict[str, str]: """Merge system requirements across features for *environment*.""" merged: dict[str, str] = {} for feature in self.resolve_features(environment): merged.update(feature.system_requirements) if platform and platform in self.platform_system_requirements: merged.update(self.platform_system_requirements[platform]) return merged
[docs] def target_platform_keys(self, platform: str | None) -> tuple[str, ...]: """Return target table keys that apply to *platform* in merge order.""" if platform is None: return () subdir = self.platform_subdir(platform) if subdir == platform: return (platform,) return (subdir, platform)
[docs] def platforms_for_toml(self) -> list[str | dict[str, str]]: """Return platforms in a TOML shape compatible with Pixi.""" result: list[str | dict[str, str]] = [] for platform in self.platforms: subdir = self.platform_subdir(platform) requirements = self.platform_system_requirements.get(platform) if not requirements: if platform == subdir: result.append(platform) else: result.append({"name": platform, "platform": subdir}) continue entry = {"platform": subdir} if platform != self.synthesize_platform_name(subdir, requirements): entry["name"] = platform entry.update( { self.platform_requirement_toml_aliases.get(name, name): value for name, value in requirements.items() } ) result.append(entry) return result
[docs] def merged_channels(self, environment: Environment) -> list[Channel]: """Merge channels across features for *environment*. Feature-specific channels are appended after the workspace-level channels, preserving priority order. Duplicates are removed. """ seen: set[str] = set() result: list[Channel] = [] for ch in self.channels: if ch.canonical_name not in seen: seen.add(ch.canonical_name) result.append(ch) for feature in self.resolve_features(environment): for ch in feature.channels: if ch.canonical_name not in seen: seen.add(ch.canonical_name) result.append(ch) return result
[docs] @dataclass class TaskArg: """A named argument that can be passed to a task.""" name: str default: str | None = None choices: list[str] | None = None
[docs] def to_toml(self) -> dict[str, object]: """Serialize to a TOML-compatible dict.""" entry: dict[str, object] = {"arg": self.name} if self.default is not None: entry["default"] = self.default if self.choices is not None: entry["choices"] = self.choices return entry
[docs] @dataclass class TaskDependency: """A reference to another task that must run first.""" task: str args: list[str | dict[str, str]] = field(default_factory=list) environment: str | None = None
[docs] def to_toml(self) -> str | dict[str, object]: """Serialize to a TOML-compatible value (string or dict).""" if self.args or self.environment: entry: dict[str, object] = {"task": self.task} if self.args: entry["args"] = self.args if self.environment: entry["environment"] = self.environment return entry return self.task
[docs] @dataclass class TaskOverride: """Per-platform override for any task field. Non-None fields replace the base task's values when the override is merged into a Task via ``Task.resolve_for_platform``. """ cmd: str | list[str] | None = None args: list[TaskArg] | None = None depends_on: list[TaskDependency] | None = None cwd: str | None = None env: dict[str, str] | None = None inputs: list[str] | None = None outputs: list[str] | None = None clean_env: bool | None = None
[docs] @dataclass class Task: """A single task definition with all its configuration.""" name: str cmd: str | list[str] | None = None args: list[TaskArg] = field(default_factory=list) depends_on: list[TaskDependency] = field(default_factory=list) cwd: str | None = None env: dict[str, str] = field(default_factory=dict) description: str | None = None inputs: list[str] = field(default_factory=list) outputs: list[str] = field(default_factory=list) clean_env: bool = False default_environment: str | None = None platforms: dict[str, TaskOverride] | None = None @property def is_alias(self) -> bool: """True when the task is just a dependency grouping with no command.""" return self.cmd is None and bool(self.depends_on) @property def is_hidden(self) -> bool: """Hidden tasks (prefixed with ``_``) are omitted from listings.""" return self.name.startswith("_")
[docs] def resolve_for_platform(self, subdir: str) -> Task: """Return a copy of this task with platform overrides merged in. *subdir* is a conda platform string such as ``linux-64`` or ``osx-arm64``. If there is no matching override the task is returned unchanged. """ if not self.platforms or subdir not in self.platforms: return self override = self.platforms[subdir] kwargs: dict[str, Any] = {} for f in fields(self): if f.name in ("name", "platforms", "description", "default_environment"): kwargs[f.name] = getattr(self, f.name) continue override_val = ( getattr(override, f.name, None) if hasattr(override, f.name) else None ) if override_val is not None: kwargs[f.name] = override_val else: kwargs[f.name] = getattr(self, f.name) return Task(**kwargs)