"""Workspace context — lazy properties for conda & workspace state.
Provides a namespace of lazily-evaluated properties that downstream
code can use without importing conda at module level. This keeps
import-time overhead negligible.
"""
from __future__ import annotations
import os
import stat
from contextlib import contextmanager
from pathlib import Path
from threading import RLock
from typing import TYPE_CHECKING, cast
from .exceptions import CondaWorkspacesError, EnvironmentNameInvalidError
from .paths import is_path_segment
if TYPE_CHECKING:
from collections.abc import Iterator
from conda.models.environment import Environment
from .models import WorkspaceConfig
_package_cache_isolated = False
_package_cache_lock = RLock()
[docs]
@contextmanager
def isolated_package_cache(enabled: bool) -> Iterator[None]:
"""Route conda package-cache writes to disposable storage when enabled.
Conda solvers may populate ``context.pkgs_dirs`` while resolving, before
any install transaction runs. Dry-run solver paths copy cached repodata
into disposable storage while retaining configured package caches as
read sources.
"""
if not enabled:
yield
return
global _package_cache_isolated
with _package_cache_lock:
if _package_cache_isolated:
yield
return
import shutil
import tempfile
from conda.base.context import context
configured_caches = context.pkgs_dirs
with tempfile.TemporaryDirectory(prefix="conda-workspaces-pkgs-") as cache_dir:
scratch_cache = Path(cache_dir)
for configured_cache in configured_caches:
repodata_cache = Path(configured_cache) / "cache"
if repodata_cache.is_dir():
shutil.copytree(repodata_cache, scratch_cache / "cache")
break
_package_cache_isolated = True
try:
with context._override(
"_pkgs_dirs",
(scratch_cache, *configured_caches),
):
yield
finally:
_package_cache_isolated = False
[docs]
class WorkspaceContext:
"""Lazy-evaluated context for the current workspace.
Properties are resolved on first access and cached. Conda imports
are deferred to keep plugin load time under 1 ms.
"""
def __init__(self, config: WorkspaceConfig | None = None) -> None:
self._config = config
self._cache: dict[str, object] = {}
@property
def config(self) -> WorkspaceConfig:
"""The parsed workspace configuration."""
if self._config is None:
from .manifests import detect_and_parse
_, self._config = detect_and_parse()
return self._config
@property
def root(self) -> Path:
"""Workspace root directory."""
return Path(self.config.root)
@property
def envs_dir(self) -> Path:
"""Directory where project-local environments are stored."""
if "envs_dir" not in self._cache:
root = self.root
envs_dir = root / self.config.envs_dir
candidates: list[Path] = []
candidate = envs_dir
while candidate != root and candidate != candidate.parent:
candidates.append(candidate)
candidate = candidate.parent
for candidate in candidates:
if candidate.is_symlink():
raise CondaWorkspacesError(
f"Workspace environments path contains a symlink: {candidate}"
)
resolved_root = root.resolve(strict=False)
resolved = envs_dir.resolve(strict=False)
try:
relative = resolved.relative_to(resolved_root)
except ValueError as exc:
raise CondaWorkspacesError(
"Workspace environments directory escapes the workspace:"
f" {envs_dir}"
) from exc
if not relative.parts:
raise CondaWorkspacesError(
"Workspace environments directory cannot be the workspace root."
)
self._cache["envs_dir"] = envs_dir
self._cache["envs_dir_candidates"] = tuple(candidates)
envs_dir = cast("Path", self._cache["envs_dir"])
for candidate in cast(
"tuple[Path, ...]",
self._cache["envs_dir_candidates"],
):
if candidate.is_symlink():
raise CondaWorkspacesError(
f"Workspace environments path contains a symlink: {candidate}"
)
return envs_dir
[docs]
def envs_dir_identity(self) -> tuple[int, int] | None:
"""Return the current environments directory identity without following it."""
envs_dir = self.envs_dir
try:
current = envs_dir.lstat()
except FileNotFoundError:
return None
if not stat.S_ISDIR(current.st_mode):
raise CondaWorkspacesError(
f"Workspace environments path is not a directory: {envs_dir}"
)
return current.st_dev, current.st_ino
[docs]
def require_envs_dir_identity(self, expected: tuple[int, int]) -> None:
"""Reject replacement of the environments directory during an operation."""
current = self.envs_dir_identity()
if current != expected:
raise CondaWorkspacesError(
"Workspace environments directory changed while it was being used."
)
[docs]
def iter_installed_prefixes(self) -> Iterator[tuple[Path, tuple[int, int]]]:
"""Yield valid conda prefixes and the identities that were inspected."""
from conda.core.envs_manager import PrefixData
envs_dir = self.envs_dir
if not envs_dir.is_dir():
return
for prefix in envs_dir.iterdir():
try:
before = prefix.lstat()
except FileNotFoundError:
continue
if stat.S_ISLNK(before.st_mode):
raise CondaWorkspacesError(
f"Workspace environment prefix cannot be a symlink: {prefix}"
)
if (
not stat.S_ISDIR(before.st_mode)
or not PrefixData(str(prefix)).is_environment()
):
continue
try:
after = prefix.lstat()
except FileNotFoundError as exc:
raise CondaWorkspacesError(
"Workspace environment prefix changed while it was inspected:"
f" {prefix}"
) from exc
identity = before.st_dev, before.st_ino
if (
not stat.S_ISDIR(after.st_mode)
or (
after.st_dev,
after.st_ino,
)
!= identity
):
raise CondaWorkspacesError(
"Workspace environment prefix changed while it was inspected:"
f" {prefix}"
)
yield prefix, identity
@property
def platform(self) -> str:
"""Current conda subdir (e.g. ``osx-arm64``)."""
if "platform" not in self._cache:
from conda.base.context import context
self._cache["platform"] = context.subdir
return cast("str", self._cache["platform"])
@property
def root_prefix(self) -> Path:
"""Conda root prefix (base environment)."""
if "root_prefix" not in self._cache:
from conda.base.context import context
self._cache["root_prefix"] = Path(context.root_prefix)
return cast("Path", self._cache["root_prefix"])
@property
def is_ci(self) -> bool:
"""Whether the process is running in a CI environment."""
return os.environ.get("CI", "").lower() in ("true", "1", "yes")
[docs]
@staticmethod
def validate_environment_name(env_name: str) -> None:
"""Reject an environment name that cannot map to a local prefix."""
if not is_path_segment(env_name):
raise EnvironmentNameInvalidError(env_name)
[docs]
def env_prefix(self, env_name: str) -> Path:
"""Return the prefix path for a named environment."""
self.validate_environment_name(env_name)
prefix = self.envs_dir / env_name
if prefix.is_symlink():
raise CondaWorkspacesError(
f"Workspace environment prefix cannot be a symlink: {prefix}"
)
return prefix
[docs]
def env_exists(self, env_name: str) -> bool:
"""Check whether the prefix is a valid conda environment."""
from conda.core.envs_manager import PrefixData
prefix = self.env_prefix(env_name)
return PrefixData(str(prefix)).is_environment()
[docs]
def envs_from_manifest(
self,
env_name: str,
*,
requested_platforms: tuple[str, ...] = (),
) -> list[Environment]:
"""Build ``Environment`` objects from the workspace manifest.
Produces an :class:`~conda.models.environment.Environment` per
target platform with ``requested_packages`` populated from the
manifest's declared specs (no solver, no installed packages
required) — the novel capability of ``conda workspace export``
vs. ``conda export``, which always operates on an installed
prefix. When the manifest declares platforms conda doesn't
know, falls back to :attr:`platform` so the export still
produces something useful rather than crashing on validation.
This is the natural entry point for third-party exporter
plugins (or other tooling) that want to turn a ``conda.toml``
into a list of :class:`Environment` objects without going
through the CLI.
"""
from .export import envs_from_manifest
return envs_from_manifest(
self, env_name, requested_platforms=requested_platforms
)
[docs]
def envs_from_prefix(
self,
env_name: str,
*,
requested_platforms: tuple[str, ...] = (),
from_history: bool = False,
no_builds: bool = False,
ignore_channels: bool = False,
) -> list[Environment]:
"""Build ``Environment`` objects from an installed workspace prefix.
Thin wrapper around the same :meth:`Environment.from_prefix` +
:meth:`Environment.extrapolate` pair that
:func:`conda.cli.main_export.execute` uses; the only
workspace-specific pieces are the prefix lookup
(:meth:`env_prefix`) and the
:class:`EnvironmentNotInstalledError` guard.
When *requested_platforms* is empty or equals
``(self.platform,)``, a single :class:`Environment` for the
host platform is returned. Otherwise one
:class:`Environment` per requested platform is produced via
:meth:`Environment.extrapolate`.
"""
from .export import envs_from_prefix
return envs_from_prefix(
self,
env_name,
requested_platforms=requested_platforms,
from_history=from_history,
no_builds=no_builds,
ignore_channels=ignore_channels,
)
[docs]
def envs_from_lockfile(
self,
env_name: str,
*,
requested_platforms: tuple[str, ...] = (),
) -> list[Environment]:
"""Load ``Environment`` objects from the workspace ``conda.lock``.
Delegates to :class:`~conda_workspaces.lockfile.CondaLockLoader`,
the same entry point conda uses when it reads ``--file
conda.lock`` through
:meth:`Environment.from_cli_with_file_envs`.
When *requested_platforms* is empty, every platform present in
the lockfile is returned. Otherwise the list is filtered and
:class:`PlatformError` is raised for any requested platform the
lockfile does not contain.
"""
from .export import envs_from_lockfile
return envs_from_lockfile(
self, env_name, requested_platforms=requested_platforms
)
[docs]
class CondaContext:
"""Lazy-evaluated namespace exposed as ``conda.*`` in task templates.
Attribute access is deferred so conda internals load only when a
template references a variable.
"""
def __init__(
self,
manifest_path: Path | None = None,
target_prefix: Path | None = None,
) -> None:
self._manifest_path = manifest_path
self._target_prefix = target_prefix
@property
def platform(self) -> str:
"""The conda platform/subdir string, e.g. ``linux-64`` or ``osx-arm64``."""
from conda.base.context import context
return context.subdir
@property
def environment_name(self) -> str:
"""Name of the selected or currently active conda environment."""
if self._target_prefix is not None:
return self._target_prefix.name
from conda.base.context import context
if context.active_prefix:
return Path(context.active_prefix).name
return "base"
@property
def environment(self) -> _EnvironmentProxy:
"""Allows ``{{ conda.environment.name }}`` in templates."""
return _EnvironmentProxy(self.environment_name)
@property
def prefix(self) -> str:
"""Absolute path to the target conda environment prefix."""
if self._target_prefix is not None:
return str(self._target_prefix)
from conda.base.context import context
return str(context.target_prefix)
@property
def version(self) -> str:
"""The installed conda version string."""
from conda import __version__
return __version__
@property
def manifest_path(self) -> str:
"""Path to the task definition file, or empty string if unknown."""
return str(self._manifest_path) if self._manifest_path else ""
@property
def init_cwd(self) -> str:
"""The working directory at the time of context creation."""
return os.getcwd()
@property
def is_win(self) -> bool:
"""True when running on Windows."""
from conda.base.constants import on_win
return on_win
@property
def is_unix(self) -> bool:
"""True when running on a Unix-like system (Linux or macOS)."""
from conda.base.constants import on_win
return not on_win
@property
def is_osx(self) -> bool:
"""True when the host platform is macOS."""
from conda.base.context import context
return context.platform == "osx"
@property
def is_linux(self) -> bool:
"""True when the host platform is Linux."""
from conda.base.context import context
return context.platform == "linux"
class _EnvironmentProxy:
"""Allows ``{{ conda.environment.name }}`` in templates."""
def __init__(self, name: str) -> None:
self.name = name
[docs]
def build_template_context(
manifest_path: Path | None = None,
task_args: dict[str, str] | None = None,
target_prefix: Path | None = None,
) -> dict[str, object]:
"""Build the full Jinja2 template context dict.
The returned dict contains:
- ``conda``: a :class:`CondaContext` instance
- ``pixi``: alias to the same context (for pixi.toml compatibility)
- Any user-supplied task argument values
"""
ctx = CondaContext(manifest_path=manifest_path, target_prefix=target_prefix)
result: dict[str, object] = {"conda": ctx, "pixi": ctx}
if task_args:
reserved = {"conda", "pixi"}
for key, value in task_args.items():
if key not in reserved:
result[key] = value
return result