Source code for nb2slurm.environment

"""Create the conda environment + Jupyter kernel the workflow runs in.

The expected users of nb2slurm are not Linux/conda experts, but the generated SLURM job
does ``conda activate <env>`` and papermill needs a *registered Jupyter kernel*
to execute the notebooks. This module writes an ``environment.yml`` and creates
the environment + kernel on the cluster (or locally), so the user never touches
the command line.

    from nb2slurm import Environment

    env = Environment(
        name="myenv",
        kernel="myenv",                       # must match Workflow(kernel=...)
        conda_packages=["xarray", "numpy"],
        pip_packages=["nb2slurm", "ewatercycle"],
    )
    env.write()                # -> environment.yml
    env.create(ssh=cfg)        # build env + register kernel on the HPC
"""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

from .ssh import CommandResult, SSHConfig, run_shell


[docs] @dataclass class Environment: """A conda environment + Jupyter kernel to create for the workflow.""" #: conda environment name (``conda activate <name>`` in the job) name: str #: Jupyter kernel to register; must match ``Workflow(kernel=...)`` kernel: str #: Python version for the environment python: str = "3.11" #: conda channels, in priority order channels: list[str] = field(default_factory=lambda: ["conda-forge"]) #: packages installed with conda/mamba conda_packages: list[str] = field(default_factory=list) #: packages installed with pip (nb2slurm itself is needed in the job) pip_packages: list[str] = field(default_factory=lambda: ["nb2slurm"])
[docs] def to_yaml(self) -> str: """Render an ``environment.yml`` for conda/mamba.""" lines = [f"name: {self.name}", "channels:"] lines += [f" - {c}" for c in self.channels] lines.append("dependencies:") lines.append(f" - python={self.python}") lines.append(" - pip") lines.append(" - ipykernel") # required so papermill can run the notebooks lines += [f" - {p}" for p in self.conda_packages] if self.pip_packages: lines.append(" - pip:") lines += [f" - {p}" for p in self.pip_packages] return "\n".join(lines) + "\n"
[docs] def write( self, project_dir: str | Path = ".", filename: str = "environment.yml" ) -> Path: """Write the ``environment.yml`` into the project directory.""" path = Path(project_dir) / filename path.write_text(self.to_yaml(), encoding="utf-8") return path
def _exists_test(self) -> str: """A shell test (exit 0 = env exists). Matches the name as a whole path component so ``montecarlo`` doesn't match ``montecarlo2``.""" return f'conda env list | grep -qE "[/ ]{self.name}([ /]|$)"' def _create_command(self, filename: str = "environment.yml") -> str: # mamba is much faster than conda; use it when available. return ( "set -e; " # There's no TTY over ssh, so any interactive prompt would hang the # build forever. Belt and suspenders: set always-yes AND pipe `yes` # into conda (CONDA_ALWAYS_YES alone is ignored by some mamba builds # for the 'Confirm changes? [Y/n]' transaction prompt). "export CONDA_ALWAYS_YES=yes; " "CONDA=conda; command -v mamba >/dev/null 2>&1 && CONDA=mamba; " # update an existing env in place rather than hitting the interactive # 'Found conda-prefix ... Overwrite? [y/N]' prompt. f"if {self._exists_test()}; then " f' echo "Updating existing environment {self.name} with $CONDA..."; ' f" yes | $CONDA env update -f {filename}; " f"else " f' echo "Creating environment {self.name} with $CONDA..."; ' f" yes | $CONDA env create -f {filename}; " f"fi; " f"conda run -n {self.name} python -m ipykernel install --user " f'--name {self.kernel} --display-name "{self.kernel}"; ' f'echo "Environment {self.name} ready; kernel {self.kernel} registered."' ) def _remove_command(self) -> str: return ( "export CONDA_ALWAYS_YES=yes; " f'echo "Removing environment {self.name} and kernel {self.kernel}..."; ' # `|| true`: removing a non-existent env/kernel is not an error here f"conda env remove -n {self.name} || true; " f"jupyter kernelspec remove -f {self.kernel} 2>/dev/null || true; " f'echo "Removed {self.name}."' )
[docs] def exists( self, ssh: Optional[SSHConfig] = None, project_dir: str | Path = ".", ) -> bool: """Return True if the conda env already exists — on the HPC (ssh) or locally.""" return run_shell(self._exists_test(), ssh, str(project_dir)).exit_status == 0
[docs] def remove( self, ssh: Optional[SSHConfig] = None, project_dir: str | Path = ".", stream: bool = True, ) -> CommandResult: """Delete the conda env and its Jupyter kernel — on the HPC (ssh) or locally. Safe to call when nothing is there yet (a missing env/kernel is ignored). Use it to recover from a half-built env or to force a clean rebuild. """ return run_shell( self._remove_command(), ssh, str(project_dir), stream=stream ).check()
[docs] def create( self, ssh: Optional[SSHConfig] = None, project_dir: str | Path = ".", filename: str = "environment.yml", stream: bool = True, overwrite: bool = False, ) -> CommandResult: """Create the env and register the kernel — on the HPC (ssh) or locally. Writes ``environment.yml`` first if it is missing. If the env already exists it is *updated* in place; pass ``overwrite=True`` to delete and rebuild it from scratch. ``stream=True`` (the default) echoes conda/pip output live, since a solve + downloads can take minutes and would otherwise look like a hang. """ if not (Path(project_dir) / filename).exists(): self.write(project_dir, filename) if overwrite: self.remove(ssh=ssh, project_dir=project_dir, stream=stream) command = self._create_command(filename) return run_shell(command, ssh, str(project_dir), stream=stream).check()