"""Environment manager — create, update, and remove project-local envs.
Uses conda's Solver API to install packages into project-scoped
environments under ``.conda/envs/<name>/``. Each environment is
a standard conda prefix that can be activated with ``conda activate``.
"""
from __future__ import annotations
import logging
import shutil
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING
from conda.base.constants import ChannelPriority, UpdateModifier
from conda.base.context import context as conda_context
from conda.core.envs_manager import PrefixData, unregister_env
from conda.exceptions import UnsatisfiableError
from conda.gateways.disk.delete import rm_rf
from conda.models.match_spec import MatchSpec
from .exceptions import SolveError
if TYPE_CHECKING:
from collections.abc import Iterator
from .context import WorkspaceContext
from .resolver import ResolvedEnvironment
log = logging.getLogger(__name__)
def _iter_installed_prefixes(envs_dir: Path) -> Iterator[Path]:
"""Yield paths of valid conda environments under *envs_dir*."""
if not envs_dir.is_dir():
return
for d in envs_dir.iterdir():
if d.is_dir() and PrefixData(str(d)).is_environment():
yield d
@contextmanager
def _channel_priority_override(priority: str | None):
"""Context manager that temporarily overrides channel_priority."""
if priority is None:
yield
return
with conda_context._override("channel_priority", ChannelPriority(priority)):
yield
def _apply_system_requirements(
resolved: ResolvedEnvironment,
specs: list[MatchSpec],
) -> list[MatchSpec]:
"""Add virtual package constraints from system_requirements to the spec list."""
for pkg_name, version in resolved.system_requirements.items():
virtual_name = pkg_name if pkg_name.startswith("__") else f"__{pkg_name}"
specs.append(MatchSpec(f"{virtual_name} >={version}"))
return specs
def _apply_activation_env(prefix: Path, env_vars: dict[str, str]) -> None:
"""Write environment variables to the prefix state file.
These are automatically set/unset by ``conda activate``/``deactivate``.
"""
if not env_vars:
return
pd = PrefixData(str(prefix))
pd.set_environment_env_vars(env_vars)
n = len(env_vars)
noun = "variable" if n == 1 else "variables"
log.info("Set %d activation environment %s", n, noun)
def _apply_activation_scripts(prefix: Path, scripts: list[str]) -> None:
"""Copy activation scripts into ``$PREFIX/etc/conda/activate.d/``.
Conda sources all scripts in this directory on ``conda activate``.
Scripts are resolved relative to the workspace root (stored in the
manifest_path parent). Only files that exist are copied.
"""
if not scripts:
return
activate_d = prefix / "etc" / "conda" / "activate.d"
activate_d.mkdir(parents=True, exist_ok=True)
for script_path in scripts:
src = Path(script_path)
if not src.is_absolute():
log.warning(
"Activation script '%s' is not an absolute path; skipping. "
"Scripts should be resolved to absolute paths by the resolver.",
script_path,
)
continue
if not src.exists():
log.warning("Activation script '%s' not found; skipping", script_path)
continue
dest = activate_d / src.name
shutil.copy2(src, dest)
log.info("Copied activation script: %s -> %s", src, dest)
def _build_pypi_specs(
resolved: ResolvedEnvironment,
) -> list[MatchSpec]:
"""Translate PyPI dependencies into conda MatchSpecs.
Uses ``conda_pypi.translate.pypi_to_conda_name`` to map PyPI package
names to their conda equivalents (via the grayskull mapping). Only
simple version-spec dependencies are translated; path, git, and URL
deps are skipped (handled separately by ``_install_editable_deps``).
Returns an empty list if ``conda-pypi`` is not installed.
"""
pypi_deps = [
dep
for dep in resolved.pypi_dependencies.values()
if not dep.path and not dep.git and not dep.url
]
if not pypi_deps:
return []
try:
from conda_pypi.translate import ( # type: ignore[import-untyped]
pypi_to_conda_name,
)
except ImportError:
names = ", ".join(str(d) for d in pypi_deps)
log.warning(
"PyPI dependencies found but conda-pypi is not installed.\n"
" Skipped PyPI packages: %s\n"
" Install conda-pypi to enable: conda install conda-pypi",
names,
)
return []
specs: list[MatchSpec] = []
for dep in pypi_deps:
conda_name = pypi_to_conda_name(dep.name)
extras = f"[{','.join(dep.extras)}]" if dep.extras else ""
base = f"{conda_name}{extras}"
spec_str = f"{base}{dep.spec}" if dep.spec else base
specs.append(MatchSpec(spec_str))
return specs
def _install_path_deps(
prefix: Path,
resolved: ResolvedEnvironment,
) -> None:
"""Install local-path PyPI deps via conda-pypi's build system.
Only ``path`` deps are supported — these point to local Python
projects that conda-pypi can build into ``.conda`` packages.
Git and URL deps are not yet supported and are skipped with a
warning.
"""
path_deps = []
for dep in resolved.pypi_dependencies.values():
if dep.git or dep.url:
log.warning(
"Git/URL PyPI dependency '%s' is not yet supported; skipping",
dep,
)
elif dep.path:
path_deps.append(dep)
if not path_deps:
return
try:
from conda_pypi.build import pypa_to_conda # type: ignore[import-untyped]
from conda_pypi.installer import ( # type: ignore[import-untyped]
install_ephemeral_conda,
)
except ImportError:
names = ", ".join(str(d) for d in path_deps)
log.warning(
"Path PyPI dependencies found but conda-pypi is not installed.\n"
" Skipped: %s\n"
" Install conda-pypi to enable: conda install conda-pypi",
names,
)
return
for dep in path_deps:
source_path = Path(dep.path).expanduser() # type: ignore[arg-type]
distribution = "editable" if dep.editable else "wheel"
log.info("Building %s (%s) from %s", dep.name, distribution, source_path)
try:
with tempfile.TemporaryDirectory("conda-pypi") as output_dir:
package = pypa_to_conda(
source_path,
distribution=distribution,
output_path=Path(output_dir),
prefix=prefix,
)
install_ephemeral_conda(prefix, package)
except Exception as exc:
log.warning(
"Failed to install path PyPI dependency '%s': %s",
dep.name,
exc,
)
[docs]
def install_environment(
ctx: WorkspaceContext,
resolved: ResolvedEnvironment,
*,
force_reinstall: bool = False,
dry_run: bool = False,
) -> None:
"""Create or update a project-local environment.
Uses conda's Solver API directly instead of shelling out, which
avoids the overhead of a subprocess and gives full control over
the solve/install transaction.
PyPI dependencies are translated to conda names and merged into
the same solver call as conda dependencies, relying on
``conda-pypi``'s wheel extractor and ``conda-rattler-solver`` to
resolve and install them in a single pass.
Raises ``SolveError`` if dependency resolution fails.
"""
prefix = ctx.env_prefix(resolved.name)
exists = ctx.env_exists(resolved.name)
if exists and force_reinstall:
rm_rf(prefix)
exists = False
# Build the spec list from resolved dependencies
specs = [
MatchSpec(dep.conda_build_form())
for dep in resolved.conda_dependencies.values()
]
# Translate PyPI deps to conda specs and merge into the same list
specs.extend(_build_pypi_specs(resolved))
# Add system requirements as virtual package constraints
_apply_system_requirements(resolved, specs)
if not specs:
prefix.mkdir(parents=True, exist_ok=True)
_apply_activation_env(prefix, resolved.activation_env)
_apply_activation_scripts(prefix, resolved.activation_scripts)
return
# Get the solver backend (respects solver plugins)
solver_backend = conda_context.plugin_manager.get_cached_solver_backend()
if solver_backend is None:
raise SolveError(resolved.name, "No solver backend found")
channels = list(resolved.channels)
subdirs = conda_context.subdirs
with _channel_priority_override(resolved.channel_priority):
solver = solver_backend(
str(prefix),
channels,
subdirs,
specs_to_add=specs,
)
try:
if exists:
txn = solver.solve_for_transaction(
update_modifier=UpdateModifier.FREEZE_INSTALLED,
)
else:
txn = solver.solve_for_transaction()
except (UnsatisfiableError, SystemExit) as exc:
raise SolveError(resolved.name, str(exc)) from exc
sys.stdout.flush()
if txn.nothing_to_do:
_apply_activation_env(prefix, resolved.activation_env)
_apply_activation_scripts(prefix, resolved.activation_scripts)
return
if dry_run:
txn.print_transaction_summary()
sys.stdout.flush()
return
txn.download_and_extract()
txn.execute()
sys.stdout.flush()
_apply_activation_env(prefix, resolved.activation_env)
_apply_activation_scripts(prefix, resolved.activation_scripts)
# Install local-path PyPI deps that can't go through the solver
if not dry_run:
_install_path_deps(prefix, resolved)
[docs]
def remove_environment(ctx: WorkspaceContext, env_name: str) -> None:
"""Remove a project-local environment by deleting its prefix."""
prefix = ctx.env_prefix(env_name)
if prefix.is_dir():
unregister_env(str(prefix))
rm_rf(prefix)
[docs]
def clean_all(ctx: WorkspaceContext) -> None:
"""Remove all project-local environments."""
envs_dir = ctx.envs_dir
for d in _iter_installed_prefixes(envs_dir):
unregister_env(str(d))
if envs_dir.is_dir():
rm_rf(envs_dir)
[docs]
def list_installed_environments(ctx: WorkspaceContext) -> list[str]:
"""Return names of environments that are currently installed."""
return sorted(d.name for d in _iter_installed_prefixes(ctx.envs_dir))
[docs]
def get_environment_info(
ctx: WorkspaceContext, env_name: str
) -> dict[str, str | int | bool]:
"""Return basic info about an installed environment."""
prefix = ctx.env_prefix(env_name)
exists = ctx.env_exists(env_name)
info: dict[str, str | int | bool] = {
"name": env_name,
"prefix": str(prefix),
"exists": exists,
}
if exists:
# Count installed packages via conda-meta
meta_dir = prefix / "conda-meta"
pkg_count = sum(1 for f in meta_dir.glob("*.json") if f.name != "history")
info["packages"] = pkg_count
return info