From 132cecbc4f30ef596335021713c646c5d3578904 Mon Sep 17 00:00:00 2001 From: Abraham Murciano Date: Tue, 16 Aug 2022 00:41:02 +0300 Subject: [PATCH 1/5] Refactor install command --- assets/conx.png | Bin 0 -> 14129 bytes assets/conx.svg | 134 ++++++++++++++++++++++++++++ condax/__init__.py | 6 +- condax/cli/__main__.py | 5 -- condax/cli/install.py | 20 ++--- condax/cli/options.py | 103 +++++++++++++++++++--- condax/cli/repair.py | 2 +- condax/conda.py | 151 +------------------------------- condax/conda/__init__.py | 3 + condax/conda/conda.py | 78 +++++++++++++++++ condax/conda/env_info.py | 47 ++++++++++ condax/conda/exceptions.py | 6 ++ condax/conda/installers.py | 77 ++++++++++++++++ condax/condax/__init__.py | 3 + condax/condax/condax.py | 55 ++++++++++++ condax/condax/exceptions.py | 18 ++++ condax/condax/links.py | 87 ++++++++++++++++++ condax/{ => condax}/metadata.py | 57 ++++++------ condax/config.py | 28 +++--- condax/consts.py | 54 ++++++++++++ condax/core.py | 96 +++----------------- condax/utils.py | 28 +++--- condax/wrapper.py | 8 +- docs/config.md | 4 +- tests/test_condax.py | 24 ++--- tests/test_condax_more.py | 8 +- tests/test_condax_repair.py | 14 +-- tests/test_condax_update.py | 8 +- tests/test_metadata.py | 2 +- 29 files changed, 780 insertions(+), 346 deletions(-) create mode 100644 assets/conx.png create mode 100644 assets/conx.svg create mode 100644 condax/conda/__init__.py create mode 100644 condax/conda/conda.py create mode 100644 condax/conda/env_info.py create mode 100644 condax/conda/exceptions.py create mode 100644 condax/conda/installers.py create mode 100644 condax/condax/__init__.py create mode 100644 condax/condax/condax.py create mode 100644 condax/condax/exceptions.py create mode 100644 condax/condax/links.py rename condax/{ => condax}/metadata.py (51%) create mode 100644 condax/consts.py diff --git a/assets/conx.png b/assets/conx.png new file mode 100644 index 0000000000000000000000000000000000000000..37368444c181b48258ec4c4113ce2c3128115509 GIT binary patch literal 14129 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 bK<4fyT?t71c@YK-7owu5`MBhvW!V1%+tTYY literal 0 HcmV?d00001 diff --git a/assets/conx.svg b/assets/conx.svg new file mode 100644 index 0000000..f0b2430 --- /dev/null +++ b/assets/conx.svg @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/condax/__init__.py b/condax/__init__.py index a96c2d4..012e76d 100644 --- a/condax/__init__.py +++ b/condax/__init__.py @@ -2,8 +2,8 @@ if sys.version_info >= (3, 8): - import importlib.metadata as metadata + import importlib.metadata as _metadata else: - import importlib_metadata as metadata + import importlib_metadata as _metadata -__version__ = metadata.version(__package__) +__version__ = _metadata.version(__package__) diff --git a/condax/cli/__main__.py b/condax/cli/__main__.py index 764d769..87283a6 100644 --- a/condax/cli/__main__.py +++ b/condax/cli/__main__.py @@ -1,7 +1,6 @@ import logging import sys from urllib.error import HTTPError -from condax import config from condax.exceptions import CondaxError from .install import install from .remove import remove, uninstall @@ -34,10 +33,6 @@ def main(): logger = logging.getLogger(__package__) try: - try: - config.set_via_file(config.DEFAULT_CONFIG) - except config.MissingConfigFileError: - pass cli() except CondaxError as e: if e.exit_code: diff --git a/condax/cli/install.py b/condax/cli/install.py index 2fa4a54..0498ae4 100644 --- a/condax/cli/install.py +++ b/condax/cli/install.py @@ -1,11 +1,8 @@ import logging -from typing import List +from typing import Iterable, List -import click - -import condax.config as config -import condax.core as core -from condax import __version__ +from condax import __version__, consts, core +from condax.condax import Condax from . import cli, options @@ -15,7 +12,7 @@ Install a package with condax. This will install a package into a new conda environment and link the executable - provided by it to `{config.DEFAULT_BIN_DIR}`. + provided by it to `{consts.DEFAULT_PATHS.bin_dir}`. """ ) @options.channels @@ -25,10 +22,13 @@ def install( packages: List[str], is_forcing: bool, - log_level: int, + channels: Iterable[str], + condax: Condax, **_, ): for pkg in packages: - core.install_package( - pkg, is_forcing=is_forcing, conda_stdout=log_level <= logging.INFO + condax.install_package( + pkg, + is_forcing=is_forcing, + channels=channels, ) diff --git a/condax/cli/options.py b/condax/cli/options.py index 4220b00..02f2ad4 100644 --- a/condax/cli/options.py +++ b/condax/cli/options.py @@ -1,22 +1,27 @@ import logging +import subprocess import rainbowlog +import yaml from statistics import median -from typing import Callable, Sequence +from typing import Any, Callable, Mapping, Optional, Sequence from pathlib import Path from functools import wraps -from condax import config +from condax import consts +from condax.condax import Condax +from condax.conda import Conda import click +from condax.utils import FullPath + def common(f: Callable) -> Callable: """ This decorator adds common options to the CLI. """ options: Sequence[Callable] = ( - config_file, - log_level, + condax, click.help_option("-h", "--help"), ) @@ -28,12 +33,27 @@ def common(f: Callable) -> Callable: packages = click.argument("packages", nargs=-1, required=True) -config_file = click.option( + +def _config_file_callback(_, __, config_file: Path) -> Mapping[str, Any]: + try: + with (config_file or consts.DEFAULT_PATHS.conf_file).open() as cf: + config = yaml.safe_load(cf) or {} + except FileNotFoundError: + config = {} + + if not isinstance(config, dict): + raise click.BadParameter( + f"Config file {config_file} must contain a dict as its root." + ) + + return config + + +config = click.option( "--config", - "config_file", type=click.Path(exists=True, path_type=Path), - help=f"Custom path to a condax config file in YAML. Default: {config.DEFAULT_CONFIG}", - callback=lambda _, __, f: (f and config.set_via_file(f)) or f, + help=f"Custom path to a condax config file in YAML. Default: {consts.DEFAULT_PATHS.conf_file}", + callback=_config_file_callback, ) channels = click.option( @@ -41,9 +61,7 @@ def common(f: Callable) -> Callable: "-c", "channels", multiple=True, - help=f"""Use the channels specified to install. If not specified condax will - default to using {config.DEFAULT_CHANNELS}, or 'channels' in the config file.""", - callback=lambda _, __, c: (c and config.set_via_value(channels=c)) or c, + help="Use the channels specified in addition to those in the configuration files of condax, conda, and/or mamba.", ) envname = click.option( @@ -80,6 +98,69 @@ def common(f: Callable) -> Callable: help="Decrease verbosity level.", ) +bin_dir = click.option( + "-b", + "--bin-dir", + type=click.Path(exists=True, path_type=Path), + help=f"Custom path to the condax bin directory. Default: {consts.DEFAULT_PATHS.bin_dir}", +) + + +def conda(f: Callable) -> Callable: + """ + This click option decorator adds the --channel and --config options as well as all those added by `options.log_level` to the CLI. + It constructs a `Conda` object and passes it to the decorated function as `conda`. + It reads the config file and passes it as a dict to the decorated function as `config`. + """ + + @log_level + @config + @wraps(f) + def construct_conda_hook(config: Mapping[str, Any], log_level: int, **kwargs): + return f( + conda=Conda( + config.get("channels", []), + stdout=subprocess.DEVNULL if log_level >= logging.INFO else None, + stderr=subprocess.DEVNULL if log_level >= logging.CRITICAL else None, + ), + config=config, + log_level=log_level, + **kwargs, + ) + + return construct_conda_hook + + +def condax(f: Callable) -> Callable: + """ + This click option decorator adds the --bin-dir option as well as all those added by `options.conda` to the CLI. + It then constructs a `Condax` object and passes it to the decorated function as `condax`. + """ + + @conda + @bin_dir + @wraps(f) + def construct_condax_hook( + conda: Conda, config: Mapping[str, Any], bin_dir: Optional[Path], **kwargs + ): + return f( + condax=Condax( + conda, + bin_dir + or config.get("bin_dir", None) + or config.get("target_destination", None) # Compatibility <=0.0.5 + or consts.DEFAULT_PATHS.bin_dir, + FullPath( + config.get("prefix_dir", None) + or config.get("prefix_path", None) # Compatibility <=0.0.5 + or consts.DEFAULT_PATHS.prefix_dir + ), + ), + **kwargs, + ) + + return construct_condax_hook + def log_level(f: Callable) -> Callable: """ diff --git a/condax/cli/repair.py b/condax/cli/repair.py index a9f8cbd..eed4b10 100644 --- a/condax/cli/repair.py +++ b/condax/cli/repair.py @@ -27,5 +27,5 @@ def repair(is_migrating, **_): if is_migrating: migrate.from_old_version() - conda.setup_micromamba() + conda.install_micromamba() core.fix_links() diff --git a/condax/conda.py b/condax/conda.py index 162baf3..c78bba2 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -1,3 +1,4 @@ +from functools import partial import io import json import logging @@ -15,71 +16,13 @@ from condax.config import C from condax.exceptions import CondaxError -from condax.utils import to_path +from condax.utils import FullPath import condax.utils as utils logger = logging.getLogger(__name__) -def _ensure(execs: Iterable[str], installer: Callable[[], Path]) -> Path: - for exe in execs: - exe_path = shutil.which(exe) - if exe_path is not None: - return to_path(exe_path) - - logger.info("No existing conda installation found. Installing the standalone") - return installer() - - -def ensure_conda() -> Path: - return _ensure(("conda", "mamba"), setup_conda) - - -def ensure_micromamba() -> Path: - return _ensure(("micromamba",), setup_micromamba) - - -def setup_conda() -> Path: - url = utils.get_conda_url() - resp = requests.get(url, allow_redirects=True) - resp.raise_for_status() - utils.mkdir(C.bin_dir()) - exe_name = "conda.exe" if os.name == "nt" else "conda" - target_filename = C.bin_dir() / exe_name - with open(target_filename, "wb") as fo: - fo.write(resp.content) - st = os.stat(target_filename) - os.chmod(target_filename, st.st_mode | stat.S_IXUSR) - return target_filename - - -def setup_micromamba() -> Path: - utils.mkdir(C.bin_dir()) - exe_name = "micromamba.exe" if os.name == "nt" else "micromamba" - umamba_exe = C.bin_dir() / exe_name - _download_extract_micromamba(umamba_exe) - return umamba_exe - - -def _download_extract_micromamba(umamba_dst: Path) -> None: - url = utils.get_micromamba_url() - print(f"Downloading micromamba from {url}") - response = requests.get(url, allow_redirects=True) - response.raise_for_status() - - utils.mkdir(umamba_dst.parent) - tarfile_obj = io.BytesIO(response.content) - with tarfile.open(fileobj=tarfile_obj) as tar, open(umamba_dst, "wb") as f: - p = "Library/bin/micromamba.exe" if os.name == "nt" else "bin/micromamba" - extracted = tar.extractfile(p) - if extracted: - shutil.copyfileobj(extracted, f) - - st = os.stat(umamba_dst) - os.chmod(umamba_dst, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - - ## Need to activate if using micromamba as drop-in replacement # def _activate_umamba(umamba_path: Path) -> None: # print("Activating micromamba") @@ -89,33 +32,6 @@ def _download_extract_micromamba(umamba_dst: Path) -> None: # ) -def create_conda_environment(spec: str, stdout: bool) -> None: - """Create an environment by installing a package. - - NOTE: `spec` may contain version specificaitons. - """ - conda_exe = ensure_conda() - prefix = conda_env_prefix(spec) - - channels = C.channels() - channels_args = [x for c in channels for x in ["--channel", c]] - - _subprocess_run( - [ - conda_exe, - "create", - "--prefix", - prefix, - "--override-channels", - *channels_args, - "--quiet", - "--yes", - shlex.quote(spec), - ], - suppress_stdout=not stdout, - ) - - def inject_to_conda_env(specs: Iterable[str], env_name: str, stdout: bool) -> None: """Add packages onto existing `env_name`. @@ -163,16 +79,6 @@ def uninject_from_conda_env( ) -def remove_conda_env(package: str, stdout: bool) -> None: - """Remove a conda environment.""" - conda_exe = ensure_conda() - - _subprocess_run( - [conda_exe, "remove", "--prefix", conda_env_prefix(package), "--all", "--yes"], - suppress_stdout=not stdout, - ) - - def update_conda_env(spec: str, update_specs: bool, stdout: bool) -> None: """Update packages in an environment. @@ -212,17 +118,6 @@ def update_conda_env(spec: str, update_specs: bool, stdout: bool) -> None: _subprocess_run(command, suppress_stdout=not stdout) -def has_conda_env(package: str) -> bool: - # TODO: check some properties of a conda environment - p = conda_env_prefix(package) - return p.exists() and p.is_dir() - - -def conda_env_prefix(spec: str) -> Path: - package, _ = utils.split_match_specs(spec) - return C.prefix_dir() / package - - def get_package_info(package: str, specific_name=None) -> Tuple[str, str, str]: env_prefix = conda_env_prefix(package) package_name = package if specific_name is None else specific_name @@ -245,46 +140,6 @@ def get_package_info(package: str, specific_name=None) -> Tuple[str, str, str]: return ("", "", "") -class DeterminePkgFilesError(CondaxError): - def __init__(self, package: str): - super().__init__(40, f"Could not determine package files: {package}.") - - -def determine_executables_from_env( - package: str, injected_package: Optional[str] = None -) -> List[Path]: - def is_good(p: Union[str, Path]) -> bool: - p = to_path(p) - return p.parent.name in ("bin", "sbin", "scripts", "Scripts") - - env_prefix = conda_env_prefix(package) - target_name = injected_package if injected_package else package - - conda_meta_dir = env_prefix / "conda-meta" - for file_name in conda_meta_dir.glob(f"{target_name}*.json"): - with file_name.open() as fo: - package_info = json.load(fo) - if package_info["name"] == target_name: - potential_executables: Set[str] = { - fn - for fn in package_info["files"] - if (fn.startswith("bin/") and is_good(fn)) - or (fn.startswith("sbin/") and is_good(fn)) - # They are Windows style path - or (fn.lower().startswith("scripts") and is_good(fn)) - or (fn.lower().startswith("library") and is_good(fn)) - } - break - else: - raise DeterminePkgFilesError(target_name) - - return sorted( - env_prefix / fn - for fn in potential_executables - if utils.is_executable(env_prefix / fn) - ) - - def _get_conda_package_dirs() -> List[Path]: """ Get the conda's global package directories. @@ -297,7 +152,7 @@ def _get_conda_package_dirs() -> List[Path]: return [] d = json.loads(res.stdout.decode()) - return [to_path(p) for p in d["pkgs_dirs"]] + return [FullPath(p) for p in d["pkgs_dirs"]] def _get_dependencies(package: str, pkg_dir: Path) -> List[str]: diff --git a/condax/conda/__init__.py b/condax/conda/__init__.py new file mode 100644 index 0000000..1df59fb --- /dev/null +++ b/condax/conda/__init__.py @@ -0,0 +1,3 @@ +from .conda import Conda + +__all__ = ["Conda"] diff --git a/condax/conda/conda.py b/condax/conda/conda.py new file mode 100644 index 0000000..c47d641 --- /dev/null +++ b/condax/conda/conda.py @@ -0,0 +1,78 @@ +import itertools +from pathlib import Path +import shlex +import subprocess +import logging +from typing import Iterable + +from condax import consts +from .installers import ensure_conda + + +logger = logging.getLogger(__name__) + + +class Conda: + def __init__( + self, + channels: Iterable[str], + stdout=subprocess.DEVNULL, + stderr=None, + ) -> None: + """This class is a wrapper for conda's CLI. + + Args: + channels: Additional channels to use. + stdout (optional): This is passed directly to `subprocess.run`. Defaults to subprocess.DEVNULL. + stderr (optional): This is passed directly to `subprocess.run`. Defaults to None. + """ + self.channels = tuple(channels) + self.stdout = stdout + self.stderr = stderr + self.exe = ensure_conda(consts.DEFAULT_PATHS.bin_dir) + + @classmethod + def is_env(cls, path: Path) -> bool: + return (path / "conda-meta").is_dir() + + def remove_env(self, env: Path) -> None: + """Remove a conda environment. + + Args: + env: The path to the environment to remove. + """ + self._run(f"remove --prefix {env} --all --yes") + + def create_env( + self, + prefix: Path, + spec: str, + extra_channels: Iterable[str] = (), + ) -> None: + """Create an environment by installing a package. + + NOTE: `spec` may contain version specificaitons. + + Args: + prefix: The path to the environment to create. + spec: Package spec to install. e.g. "python=3.6", "python>=3.6", "python", etc. + extra_channels: Additional channels to search for packages in. + """ + self._run( + f"create --prefix {prefix} {' '.join(f'--channel {c}' for c in itertools.chain(extra_channels, self.channels))} --quiet --yes {shlex.quote(spec)}" + ) + + def _run(self, command: str) -> subprocess.CompletedProcess: + """Run a conda command. + + Args: + command: The command to run excluding the conda executable. + """ + cmd = shlex.split(f"{self.exe} {command}") + logger.debug(f"Running: {cmd}") + return subprocess.run( + cmd, + stdout=self.stdout, + stderr=self.stderr, + text=True, + ) diff --git a/condax/conda/env_info.py b/condax/conda/env_info.py new file mode 100644 index 0000000..6835a6a --- /dev/null +++ b/condax/conda/env_info.py @@ -0,0 +1,47 @@ +import json +from pathlib import Path +from typing import List, Union, Set + +from condax.utils import FullPath +from condax import utils +from .exceptions import NoPackageMetadata + + +def find_exes(prefix: Path, package: str) -> List[Path]: + """Find executables in environment `prefix` provided py a given `package`. + + Args: + prefix: The environment to search in. + package: The package whose executables to search for. + + Returns: + A list of executables in `prefix` provided by `package`. + + Raises: + DeterminePkgFilesError: If the package files could not be determined. + """ + + def is_exe(p: Union[str, Path]) -> bool: + return FullPath(p).parent.name in ("bin", "sbin", "scripts", "Scripts") + + conda_meta_dir = prefix / "conda-meta" + for file_name in conda_meta_dir.glob(f"{package}*.json"): + with file_name.open() as fo: + package_info = json.load(fo) + if package_info["name"] == package: + potential_executables: Set[str] = { + fn + for fn in package_info["files"] + if (fn.startswith("bin/") and is_exe(fn)) + or (fn.startswith("sbin/") and is_exe(fn)) + # They are Windows style path + or (fn.lower().startswith("scripts") and is_exe(fn)) + or (fn.lower().startswith("library") and is_exe(fn)) + } + break + else: + raise NoPackageMetadata(package) + + return sorted( + prefix / fn for fn in potential_executables if utils.is_executable(prefix / fn) + ) diff --git a/condax/conda/exceptions.py b/condax/conda/exceptions.py new file mode 100644 index 0000000..ebe2b64 --- /dev/null +++ b/condax/conda/exceptions.py @@ -0,0 +1,6 @@ +from condax.exceptions import CondaxError + + +class NoPackageMetadata(CondaxError): + def __init__(self, package: str): + super().__init__(201, f"Could not determine package files: {package}.") diff --git a/condax/conda/installers.py b/condax/conda/installers.py new file mode 100644 index 0000000..f5aeca5 --- /dev/null +++ b/condax/conda/installers.py @@ -0,0 +1,77 @@ +import io +import shutil +import logging +import tarfile +import requests +import os +import stat +from functools import partial +from pathlib import Path +from typing import Callable, Iterable + +from condax.utils import FullPath +from condax import utils, consts + +logger = logging.getLogger(__name__) + + +DEFAULT_CONDA_BINS_DIR = consts.DEFAULT_PATHS.data_dir / "bins" + + +def _ensure(execs: Iterable[str], installer: Callable[[], Path]) -> Path: + path = os.pathsep.join((os.environ.get("PATH", ""), str(DEFAULT_CONDA_BINS_DIR))) + for exe in execs: + exe_path = shutil.which(exe, path=path) + if exe_path is not None: + return FullPath(exe_path) + + logger.info("No existing conda installation found. Installing the standalone") + return installer() + + +def ensure_conda(bin_dir: Path = DEFAULT_CONDA_BINS_DIR) -> Path: + return _ensure(("conda", "mamba"), partial(install_conda, bin_dir)) + + +def ensure_micromamba(bin_dir: Path = DEFAULT_CONDA_BINS_DIR) -> Path: + return _ensure(("micromamba",), partial(install_micromamba, bin_dir)) + + +def install_conda(bin_dir: Path = DEFAULT_CONDA_BINS_DIR) -> Path: + url = utils.get_conda_url() + resp = requests.get(url, allow_redirects=True) + resp.raise_for_status() + utils.mkdir(bin_dir) + exe_name = "conda.exe" if os.name == "nt" else "conda" + target_filename = bin_dir / exe_name + with open(target_filename, "wb") as fo: + fo.write(resp.content) + st = os.stat(target_filename) + os.chmod(target_filename, st.st_mode | stat.S_IXUSR) + return target_filename + + +def install_micromamba(bin_dir: Path = DEFAULT_CONDA_BINS_DIR) -> Path: + utils.mkdir(bin_dir) + exe_name = "micromamba.exe" if os.name == "nt" else "micromamba" + umamba_exe = bin_dir / exe_name + _download_extract_micromamba(umamba_exe) + return umamba_exe + + +def _download_extract_micromamba(umamba_dst: Path) -> None: + url = utils.get_micromamba_url() + print(f"Downloading micromamba from {url}") + response = requests.get(url, allow_redirects=True) + response.raise_for_status() + + utils.mkdir(umamba_dst.parent) + tarfile_obj = io.BytesIO(response.content) + with tarfile.open(fileobj=tarfile_obj) as tar, open(umamba_dst, "wb") as f: + p = "Library/bin/micromamba.exe" if os.name == "nt" else "bin/micromamba" + extracted = tar.extractfile(p) + if extracted: + shutil.copyfileobj(extracted, f) + + st = os.stat(umamba_dst) + os.chmod(umamba_dst, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) diff --git a/condax/condax/__init__.py b/condax/condax/__init__.py new file mode 100644 index 0000000..c46c37b --- /dev/null +++ b/condax/condax/__init__.py @@ -0,0 +1,3 @@ +from .condax import Condax + +__all__ = ["Condax"] diff --git a/condax/condax/condax.py b/condax/condax/condax.py new file mode 100644 index 0000000..ced4231 --- /dev/null +++ b/condax/condax/condax.py @@ -0,0 +1,55 @@ +from pathlib import Path +from typing import Iterable +import logging + +from condax import utils +from condax.conda import Conda, env_info +from .exceptions import PackageInstalledError, NotAnEnvError +from . import links, metadata + +logger = logging.getLogger(__name__) + + +class Condax: + def __init__(self, conda: Conda, bin_dir: Path, prefix_dir: Path) -> None: + """ + Args: + conda: A conda object to use for executing conda commands. + bin_dir: The directory to make executables available in. + prefix_dir: The directory where to create new conda environments. + """ + self.conda = conda + self.bin_dir = bin_dir + self.prefix_dir = prefix_dir + + def install_package( + self, + spec: str, + channels: Iterable[str], + is_forcing: bool = False, + ): + """Create a new conda environment with the package provided by `spec` and make all its executables available in `self.bin_dir`. + + Args: + spec: The package to install. Can have version constraints. + channels: Additional channels to search for packages in. + is_forcing: If True, install even if the package is already installed. + """ + package = utils.package_name(spec) + env = self.prefix_dir / package + + if self.conda.is_env(env): + if is_forcing: + logger.warning(f"Overwriting environment for {package}") + self.conda.remove_env(env) + else: + raise PackageInstalledError(package, env) + elif env.exists() and (not env.is_dir() or tuple(env.iterdir())): + raise NotAnEnvError(env, "Cannot install to this location") + + self.conda.create_env(env, spec, channels) + executables = env_info.find_exes(env, package) + utils.mkdir(self.bin_dir) + links.create_links(env, executables, self.bin_dir, is_forcing=is_forcing) + metadata.create_metadata(env, package, executables) + logger.info(f"`{package}` has been installed by condax") diff --git a/condax/condax/exceptions.py b/condax/condax/exceptions.py new file mode 100644 index 0000000..6f7d341 --- /dev/null +++ b/condax/condax/exceptions.py @@ -0,0 +1,18 @@ +from pathlib import Path +from condax.exceptions import CondaxError + + +class PackageInstalledError(CondaxError): + def __init__(self, package: str, location: Path): + super().__init__( + 101, + f"Package `{package}` is already installed at {location / package}. Use `--force` to overwrite.", + ) + + +class NotAnEnvError(CondaxError): + def __init__(self, location: Path, msg: str = ""): + super().__init__( + 102, + f"{location} exists, is not empty, and is not a conda environment. {msg}", + ) diff --git a/condax/condax/links.py b/condax/condax/links.py new file mode 100644 index 0000000..3aad779 --- /dev/null +++ b/condax/condax/links.py @@ -0,0 +1,87 @@ +import logging +import os +from pathlib import Path +import shutil +from typing import Iterable + +from condax.conda import installers +from condax import utils + +logger = logging.getLogger(__name__) + + +def create_links( + env: Path, + executables_to_link: Iterable[Path], + location: Path, + is_forcing: bool = False, +): + """Create links to the executables in `executables_to_link` in `bin_dir`. + + Args: + env: The conda environment to link executables from. + executables_to_link: The executables to link. + location: The location to put the links in. + is_forcing: If True, overwrite existing links. + """ + linked = ( + exe.name + for exe in sorted(executables_to_link) + if create_link(env, exe, location, is_forcing=is_forcing) + ) + if executables_to_link: + logger.info("\n - ".join(("Created the following entrypoint links:", *linked))) + + +def create_link(env: Path, exe: Path, location: Path, is_forcing: bool = False) -> bool: + """Create a link to the executable in `exe` in `bin_dir`. + + Args: + env: The conda environment to link executables from. + exe: The executable to link. + location: The location to put the link in. + is_forcing: If True, overwrite existing links. + + Returns: + bool: True if a link was created, False otherwise. + """ + micromamba_exe = installers.ensure_micromamba() + if os.name == "nt": + script_lines = [ + "@rem Entrypoint created by condax\n", + f"@call {utils.quote(micromamba_exe)} run --prefix {utils.quote(env)} {utils.quote(exe)} %*\n", + ] + else: + script_lines = [ + "#!/usr/bin/env bash\n", + "\n", + "# Entrypoint created by condax\n", + f'{utils.quote(micromamba_exe)} run --prefix {utils.quote(env)} {utils.quote(exe)} "$@"\n', + ] + if utils.to_bool(os.environ.get("CONDAX_HIDE_EXITCODE", False)): + # Let scripts to return exit code 0 constantly + script_lines.append("exit 0\n") + + script_path = location / _get_wrapper_name(exe.name) + if script_path.exists() and not is_forcing: + answer = input(f"{exe.name} already exists. Overwrite? (y/N) ").strip().lower() + if answer not in ("y", "yes"): + logger.warning(f"Skipped creating entrypoint: {exe.name}") + return False + + if script_path.exists(): + logger.warning(f"Overwriting entrypoint: {exe.name}") + utils.unlink(script_path) + with open(script_path, "w") as fo: + fo.writelines(script_lines) + shutil.copystat(exe, script_path) + return True + + +def _get_wrapper_name(name: str) -> str: + """Get the file name of the entrypoint script for the executable with the given name. + + On Windows, the file name is the executable name with a .bat extension. + On Unix, the file name is unchanged. + """ + return f"{Path(name).stem}.bat" if os.name == "nt" else name diff --git a/condax/metadata.py b/condax/condax/metadata.py similarity index 51% rename from condax/metadata.py rename to condax/condax/metadata.py index 002b0a8..b41701f 100644 --- a/condax/metadata.py +++ b/condax/condax/metadata.py @@ -1,29 +1,44 @@ +from dataclasses import dataclass import json from pathlib import Path -from typing import List, Optional +from typing import Iterable, List, Optional -from condax.config import C +from condax.conda import env_info -class _PackageBase(object): +def create_metadata(env: Path, package: str, executables: Iterable[Path]): + """ + Create metadata file + """ + apps = [p.name for p in (executables or env_info.find_exes(env, package))] + main = MainPackage(package, env, apps) + meta = CondaxMetaData(main) + meta.save() + + +class _PackageBase: def __init__(self, name: str, apps: List[str], include_apps: bool): self.name = name self.apps = apps self.include_apps = include_apps + def __lt__(self, other): + return self.name < other.name + +@dataclass class MainPackage(_PackageBase): - def __init__(self, name: str, apps: List[str], include_apps: bool = True): - self.name = name - self.apps = apps - self.include_apps = True + name: str + prefix: Path + apps: List[str] + include_apps: bool = True class InjectedPackage(_PackageBase): pass -class CondaxMetaData(object): +class CondaxMetaData: """ Handle metadata information written in `condax_metadata.json` placed in each environment. @@ -31,37 +46,29 @@ class CondaxMetaData(object): metadata_file = "condax_metadata.json" - @classmethod - def get_path(cls, package: str) -> Path: - p = C.prefix_dir() / package / cls.metadata_file - return p - - def __init__(self, main: MainPackage, injected: List[InjectedPackage] = []): + def __init__(self, main: MainPackage, injected: Iterable[InjectedPackage] = ()): self.main_package = main - self.injected_packages = injected + self.injected_packages = tuple(sorted(injected)) def inject(self, package: InjectedPackage): - if self.injected_packages is None: - self.injected_packages = [] - already_injected = [p.name for p in self.injected_packages] - if package.name in already_injected: - return - self.injected_packages.append(package) + self.injected_packages = tuple(sorted(set(self.injected_packages) | {package})) def uninject(self, name: str): - self.injected_packages = [p for p in self.injected_packages if p.name != name] + self.injected_packages = tuple( + p for p in self.injected_packages if p.name != name + ) def to_json(self) -> str: return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4) def save(self) -> None: - p = CondaxMetaData.get_path(self.main_package.name) + p = self.main_package.prefix / self.metadata_file with open(p, "w") as fo: fo.write(self.to_json()) -def load(package: str) -> Optional[CondaxMetaData]: - p = CondaxMetaData.get_path(package) +def load(prefix: Path) -> Optional[CondaxMetaData]: + p = prefix / CondaxMetaData.metadata_file if not p.exists(): return None diff --git a/condax/config.py b/condax/config.py index 51cd125..83422d8 100644 --- a/condax/config.py +++ b/condax/config.py @@ -4,7 +4,7 @@ from typing import Any, Dict, List, Optional, Union from condax.exceptions import CondaxError -from condax.utils import to_path +from condax.utils import FullPath import condax.condarc as condarc import yaml @@ -17,7 +17,7 @@ _localappdata_dir, "condax", "condax", _config_filename ) _default_config = _default_config_windows if os.name == "nt" else _default_config_unix -DEFAULT_CONFIG = to_path(os.environ.get("CONDAX_CONFIG", _default_config)) +DEFAULT_CONFIG = FullPath(os.environ.get("CONDAX_CONFIG", _default_config)) _xdg_data_home = os.environ.get("XDG_DATA_HOME", "~/.local/share") _default_prefix_dir_unix = os.path.join(_xdg_data_home, "condax", "envs") @@ -25,22 +25,22 @@ _default_prefix_dir = ( _default_prefix_dir_win if os.name == "nt" else _default_prefix_dir_unix ) -DEFAULT_PREFIX_DIR = to_path(os.environ.get("CONDAX_PREFIX_DIR", _default_prefix_dir)) +DEFAULT_PREFIX_DIR = FullPath(os.environ.get("CONDAX_PREFIX_DIR", _default_prefix_dir)) -DEFAULT_BIN_DIR = to_path(os.environ.get("CONDAX_BIN_DIR", "~/.local/bin")) +DEFAULT_BIN_DIR = FullPath(os.environ.get("CONDAX_BIN_DIR", "~/.local/bin")) _channels_in_condarc = condarc.load_channels() DEFAULT_CHANNELS = ( os.environ.get("CONDAX_CHANNELS", " ".join(_channels_in_condarc)).strip().split() ) -CONDA_ENVIRONMENT_FILE = to_path("~/.conda/environments.txt") +CONDA_ENVIRONMENT_FILE = FullPath("~/.conda/environments.txt") conda_path = shutil.which("conda") MAMBA_ROOT_PREFIX = ( - to_path(conda_path).parent.parent + FullPath(conda_path).parent.parent if conda_path is not None - else to_path(os.environ.get("MAMBA_ROOT_PREFIX", "~/micromamba")) + else FullPath(os.environ.get("MAMBA_ROOT_PREFIX", "~/micromamba")) ) @@ -94,7 +94,7 @@ def set_via_file(config_file: Union[str, Path]): Raises: BadConfigFileError: If the config file is not valid. """ - config_file = to_path(config_file) + config_file = FullPath(config_file) try: with config_file.open() as f: config = yaml.safe_load(f) @@ -107,20 +107,20 @@ def set_via_file(config_file: Union[str, Path]): # For compatibility with condax 0.0.5 if "prefix_path" in config: - prefix_dir = to_path(config["prefix_path"]) + prefix_dir = FullPath(config["prefix_path"]) C._set("prefix_dir", prefix_dir) # For compatibility with condax 0.0.5 if "target_destination" in config: - bin_dir = to_path(config["target_destination"]) + bin_dir = FullPath(config["target_destination"]) C._set("bin_dir", bin_dir) if "prefix_dir" in config: - prefix_dir = to_path(config["prefix_dir"]) + prefix_dir = FullPath(config["prefix_dir"]) C._set("prefix_dir", prefix_dir) if "bin_dir" in config: - bin_dir = to_path(config["bin_dir"]) + bin_dir = FullPath(config["bin_dir"]) C._set("bin_dir", bin_dir) if "channels" in config: @@ -137,10 +137,10 @@ def set_via_value( Set a part of values in the object C by passing values directly. """ if prefix_dir: - C._set("prefix_dir", to_path(prefix_dir)) + C._set("prefix_dir", FullPath(prefix_dir)) if bin_dir: - C._set("bin_dir", to_path(bin_dir)) + C._set("bin_dir", FullPath(bin_dir)) if channels: C._set("channels", channels + C.channels()) diff --git a/condax/consts.py b/condax/consts.py new file mode 100644 index 0000000..91d474d --- /dev/null +++ b/condax/consts.py @@ -0,0 +1,54 @@ +import os +from dataclasses import dataclass +from pathlib import Path + + +from condax.utils import FullPath + + +IS_WIN = os.name == "nt" +IS_UNIX = not IS_WIN + + +@dataclass +class Paths: + conf_dir: Path + bin_dir: Path + data_dir: Path + conf_file_name: str = "config.yaml" + envs_dir_name: str = "envs" + + @property + def conf_file(self) -> Path: + return self.conf_dir / self.conf_file_name + + @property + def prefix_dir(self) -> Path: + return self.data_dir / self.envs_dir_name + + +class _WindowsPaths(Paths): + def __init__(self): + conf_dir = data_dir = ( + FullPath(os.environ.get("LOCALAPPDATA", "~/AppData/Local")) + / "condax/condax" + ) + super().__init__( + conf_dir=conf_dir, + bin_dir=conf_dir / "bin", + data_dir=data_dir, + ) + + +class _UnixPaths(Paths): + def __init__(self): + super().__init__( + conf_dir=FullPath(os.environ.get("XDG_CONFIG_HOME", "~/.config")) + / "condax", + bin_dir=FullPath("~/.local/bin"), + data_dir=FullPath(os.environ.get("XDG_DATA_HOME", "~/.local/share")) + / "condax", + ) + + +DEFAULT_PATHS: Paths = _UnixPaths() if IS_UNIX else _WindowsPaths() diff --git a/condax/core.py b/condax/core.py index a110e04..42bb564 100644 --- a/condax/core.py +++ b/condax/core.py @@ -10,7 +10,7 @@ import condax.conda as conda from condax.exceptions import CondaxError -import condax.metadata as metadata +import condax.condax.metadata as metadata import condax.wrapper as wrapper import condax.utils as utils import condax.config as config @@ -20,55 +20,6 @@ logger = logging.getLogger(__name__) -def create_link(package: str, exe: Path, is_forcing: bool = False) -> bool: - micromamba_exe = conda.ensure_micromamba() - executable_name = exe.name - # FIXME: Enforcing conda (not mamba) for `conda run` for now - prefix = conda.conda_env_prefix(package) - if os.name == "nt": - script_lines = [ - "@rem Entrypoint created by condax\n", - f"@call {utils.quote(micromamba_exe)} run --prefix {utils.quote(prefix)} {utils.quote(exe)} %*\n", - ] - else: - script_lines = [ - "#!/usr/bin/env bash\n", - "\n", - "# Entrypoint created by condax\n", - f'{utils.quote(micromamba_exe)} run --prefix {utils.quote(prefix)} {utils.quote(exe)} "$@"\n', - ] - if utils.to_bool(os.environ.get("CONDAX_HIDE_EXITCODE", False)): - # Let scripts to return exit code 0 constantly - script_lines.append("exit 0\n") - - script_path = _get_wrapper_path(executable_name) - if script_path.exists() and not is_forcing: - user_input = input(f"{executable_name} already exists. Overwrite? (y/N) ") - if user_input.strip().lower() not in ("y", "yes"): - logger.warning(f"Skipped creating entrypoint: {executable_name}") - return False - - if script_path.exists(): - logger.warning(f"Overwriting entrypoint: {executable_name}") - utils.unlink(script_path) - with open(script_path, "w") as fo: - fo.writelines(script_lines) - shutil.copystat(exe, script_path) - return True - - -def create_links( - package: str, executables_to_link: Iterable[Path], is_forcing: bool = False -): - linked = ( - exe.name - for exe in sorted(executables_to_link) - if create_link(package, exe, is_forcing=is_forcing) - ) - if executables_to_link: - logger.info("\n - ".join(("Created the following entrypoint links:", *linked))) - - def remove_links(package: str, app_names_to_unlink: Iterable[str]): unlinked: List[str] = [] if os.name == "nt": @@ -97,33 +48,29 @@ def remove_links(package: str, app_names_to_unlink: Iterable[str]): ) -class PackageInstalledError(CondaxError): - def __init__(self, package: str): - super().__init__( - 20, - f"Package `{package}` is already installed. Use `--force` to force install.", - ) - - def install_package( spec: str, + location: Path, + bin_dir: Path, + channels: Iterable[str], is_forcing: bool = False, conda_stdout: bool = False, ): package, _ = utils.split_match_specs(spec) + env = location / package - if conda.has_conda_env(package): + if conda.is_conda_env(env): if is_forcing: logger.warning(f"Overwriting environment for {package}") - conda.remove_conda_env(package, conda_stdout) + conda.remove_conda_env(env, conda_stdout) else: - raise PackageInstalledError(package) + raise PackageInstalledError(package, location) - conda.create_conda_environment(spec, conda_stdout) - executables_to_link = conda.determine_executables_from_env(package) - utils.mkdir(C.bin_dir()) - create_links(package, executables_to_link, is_forcing=is_forcing) - _create_metadata(package) + conda.create_conda_environment(env, spec, conda_stdout, channels, bin_dir) + executables_to_link = conda.determine_executables_from_env(env, package) + utils.mkdir(bin_dir) + create_links(env, executables_to_link, bin_dir, is_forcing=is_forcing) + _create_metadata(env, package) logger.info(f"`{package}` has been installed by condax") @@ -372,16 +319,6 @@ def update_package( _inject_to_metadata(env, pkg) -def _create_metadata(package: str): - """ - Create metadata file - """ - apps = [p.name for p in conda.determine_executables_from_env(package)] - main = metadata.MainPackage(package, apps) - meta = metadata.CondaxMetaData(main) - meta.save() - - class NoMetadataError(CondaxError): def __init__(self, env: str): super().__init__(22, f"Failed to recreate condax_metadata.json in {env}") @@ -486,13 +423,6 @@ def _get_apps(env_name: str) -> List[str]: ] -def _get_wrapper_path(cmd_name: str) -> Path: - p = C.bin_dir() / cmd_name - if os.name == "nt": - p = p.parent / (p.stem + ".bat") - return p - - def export_all_environments(out_dir: str, conda_stdout: bool = False) -> None: """Export all environments to a directory. diff --git a/condax/utils.py b/condax/utils.py index de285de..daaedfb 100644 --- a/condax/utils.py +++ b/condax/utils.py @@ -1,14 +1,15 @@ import os from pathlib import Path import platform -from typing import List, Tuple, Union +import shlex +from typing import Tuple, Union import re import urllib.parse from condax.exceptions import CondaxError -pat = re.compile(r"<=|>=|==|!=|<|>|=") +pat = re.compile(r"(?=<=|>=|==|!=|<|>|=|$)") def split_match_specs(package_with_specs: str) -> Tuple[str, str]: @@ -36,26 +37,29 @@ def split_match_specs(package_with_specs: str) -> Tuple[str, str]: >>> split_match_specs("numpy") ("numpy", "") """ - name, *_ = pat.split(package_with_specs) - # replace with str.removeprefix() once Python>=3.9 is assured - match_specs = package_with_specs[len(name) :] + name, match_specs = pat.split(package_with_specs, 1) return name.strip(), match_specs.strip() -def to_path(path: Union[str, Path]) -> Path: +def package_name(package_with_specs: str) -> str: """ - Convert a string to a pathlib.Path object. + Get the name of a conda environment from its specification. """ - return Path(path).expanduser().resolve() + return split_match_specs(package_with_specs)[0] -def mkdir(path: Union[Path, str]) -> None: +class FullPath(Path): + def __new__(cls, *args, **kwargs): + return super().__new__(Path, Path(*args, **kwargs).expanduser().resolve()) + + +def mkdir(path: Path) -> None: """mkdir -p path""" - to_path(path).mkdir(exist_ok=True, parents=True) + path.mkdir(exist_ok=True, parents=True) def quote(path: Union[Path, str]) -> str: - return f'"{str(path)}"' + return shlex.quote(str(path)) def is_executable(path: Path) -> bool: @@ -184,5 +188,5 @@ def to_bool(value: Union[str, bool]) -> bool: def is_env_dir(path: Union[Path, str]) -> bool: """Check if a path is a conda environment directory.""" - p = to_path(path) + p = FullPath(path) return (p / "conda-meta" / "history").exists() diff --git a/condax/wrapper.py b/condax/wrapper.py index 7677990..693cca6 100644 --- a/condax/wrapper.py +++ b/condax/wrapper.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Optional, List, Union -from condax.utils import to_path +from condax.utils import FullPath def read_env_name(script_path: Union[str, Path]) -> Optional[str]: @@ -15,7 +15,7 @@ def read_env_name(script_path: Union[str, Path]) -> Optional[str]: Returns the environment name within which conda run is executed. """ - path = to_path(script_path) + path = FullPath(script_path) script_name = path.name if not path.exists(): logging.warning(f"File missing: `{path}`.") @@ -52,7 +52,7 @@ def is_wrapper(exec_path: Union[str, Path]) -> bool: """ Check if a file is a condax wrapper script. """ - path = to_path(exec_path) + path = FullPath(exec_path) if not path.exists(): return False @@ -106,7 +106,7 @@ def _parse_line(cls, line: str) -> Optional[argparse.Namespace]: return None first_word = words[0] - cmd = to_path(first_word).stem + cmd = FullPath(first_word).stem if cmd not in ("conda", "mamba", "micromamba"): return None diff --git a/docs/config.md b/docs/config.md index 66d69d1..05e5fe9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,8 +1,8 @@ Condax generally requires very little configuration. -Condax will read configuration settings from a `~/.config/condax/config.yaml` file. +Condax will read configuration settings from a `~/.config/condax/config.yaml` file. This path can be overridden by the `--config` command line argument. -This is the default state for this file. +This is the expected format for the configuration file. All settings are optional. ```yaml prefix_dir: "~/.local/share/condax/envs" diff --git a/tests/test_condax.py b/tests/test_condax.py index 081d386..6dad8cb 100644 --- a/tests/test_condax.py +++ b/tests/test_condax.py @@ -8,12 +8,12 @@ def test_pipx_install_roundtrip(): """ from condax.core import install_package, remove_package import condax.config as config - from condax.utils import to_path + from condax.utils import FullPath prefix_fp = tempfile.TemporaryDirectory() - prefix_dir = to_path(prefix_fp.name) + prefix_dir = FullPath(prefix_fp.name) bin_fp = tempfile.TemporaryDirectory() - bin_dir = to_path(bin_fp.name) + bin_dir = FullPath(bin_fp.name) channels = ["conda-forge", "default"] config.set_via_value(prefix_dir=prefix_dir, bin_dir=bin_dir, channels=channels) @@ -53,12 +53,12 @@ def test_install_specific_version(): """ from condax.core import install_package, remove_package import condax.config as config - from condax.utils import to_path + from condax.utils import FullPath prefix_fp = tempfile.TemporaryDirectory() - prefix_dir = to_path(prefix_fp.name) + prefix_dir = FullPath(prefix_fp.name) bin_fp = tempfile.TemporaryDirectory() - bin_dir = to_path(bin_fp.name) + bin_dir = FullPath(bin_fp.name) channels = ["conda-forge", "default"] config.set_via_value(prefix_dir=prefix_dir, bin_dir=bin_dir, channels=channels) @@ -102,12 +102,12 @@ def test_inject_then_uninject(): """ from condax.core import install_package, inject_package_to, uninject_package_from import condax.config as config - from condax.utils import to_path + from condax.utils import FullPath prefix_fp = tempfile.TemporaryDirectory() - prefix_dir = to_path(prefix_fp.name) + prefix_dir = FullPath(prefix_fp.name) bin_fp = tempfile.TemporaryDirectory() - bin_dir = to_path(bin_fp.name) + bin_dir = FullPath(bin_fp.name) channels = ["conda-forge", "default"] config.set_via_value(prefix_dir=prefix_dir, bin_dir=bin_dir, channels=channels) @@ -194,13 +194,13 @@ def test_inject_with_include_apps(): remove_package, ) import condax.config as config - from condax.utils import to_path + from condax.utils import FullPath # prep prefix_fp = tempfile.TemporaryDirectory() - prefix_dir = to_path(prefix_fp.name) + prefix_dir = FullPath(prefix_fp.name) bin_fp = tempfile.TemporaryDirectory() - bin_dir = to_path(bin_fp.name) + bin_dir = FullPath(bin_fp.name) channels = ["conda-forge", "default"] config.set_via_value(prefix_dir=prefix_dir, bin_dir=bin_dir, channels=channels) diff --git a/tests/test_condax_more.py b/tests/test_condax_more.py index 1f034c6..3725545 100644 --- a/tests/test_condax_more.py +++ b/tests/test_condax_more.py @@ -15,18 +15,18 @@ def test_export_import(): import_environments, ) import condax.config as config - from condax.utils import to_path + from condax.utils import FullPath # prep prefix_fp = tempfile.TemporaryDirectory() - prefix_dir = to_path(prefix_fp.name) + prefix_dir = FullPath(prefix_fp.name) bin_fp = tempfile.TemporaryDirectory() - bin_dir = to_path(bin_fp.name) + bin_dir = FullPath(bin_fp.name) channels = ["conda-forge"] config.set_via_value(prefix_dir=prefix_dir, bin_dir=bin_dir, channels=channels) export_dir_fp = tempfile.TemporaryDirectory() - export_dir = to_path(export_dir_fp.name) + export_dir = FullPath(export_dir_fp.name) gh = "gh" injected_rg_name = "ripgrep" diff --git a/tests/test_condax_repair.py b/tests/test_condax_repair.py index e6a058e..92f2d3f 100644 --- a/tests/test_condax_repair.py +++ b/tests/test_condax_repair.py @@ -8,13 +8,13 @@ def test_fix_links(): """ from condax.core import install_package, inject_package_to, fix_links import condax.config as config - from condax.utils import to_path + from condax.utils import FullPath # prep prefix_fp = tempfile.TemporaryDirectory() - prefix_dir = to_path(prefix_fp.name) + prefix_dir = FullPath(prefix_fp.name) bin_fp = tempfile.TemporaryDirectory() - bin_dir = to_path(bin_fp.name) + bin_dir = FullPath(bin_fp.name) channels = ["conda-forge"] config.set_via_value(prefix_dir=prefix_dir, bin_dir=bin_dir, channels=channels) @@ -106,14 +106,14 @@ def test_fix_links_without_metadata(): fix_links, ) import condax.config as config - import condax.metadata as metadata - from condax.utils import to_path + import condax.condax.metadata as metadata + from condax.utils import FullPath # prep prefix_fp = tempfile.TemporaryDirectory() - prefix_dir = to_path(prefix_fp.name) + prefix_dir = FullPath(prefix_fp.name) bin_fp = tempfile.TemporaryDirectory() - bin_dir = to_path(bin_fp.name) + bin_dir = FullPath(bin_fp.name) channels = ["conda-forge"] config.set_via_value(prefix_dir=prefix_dir, bin_dir=bin_dir, channels=channels) diff --git a/tests/test_condax_update.py b/tests/test_condax_update.py index a31f9f9..2643b4d 100644 --- a/tests/test_condax_update.py +++ b/tests/test_condax_update.py @@ -9,14 +9,14 @@ def test_condax_update_main_apps(): update_package, ) import condax.config as config - from condax.utils import to_path, is_env_dir - import condax.metadata as metadata + from condax.utils import FullPath, is_env_dir + import condax.condax.metadata as metadata # prep prefix_fp = tempfile.TemporaryDirectory() - prefix_dir = to_path(prefix_fp.name) + prefix_dir = FullPath(prefix_fp.name) bin_fp = tempfile.TemporaryDirectory() - bin_dir = to_path(bin_fp.name) + bin_dir = FullPath(bin_fp.name) channels = ["conda-forge", "bioconda"] config.set_via_value(prefix_dir=prefix_dir, bin_dir=bin_dir, channels=channels) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 69e9119..82732b4 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,5 +1,5 @@ import textwrap -from condax.metadata import MainPackage, InjectedPackage, CondaxMetaData +from condax.condax.metadata import MainPackage, InjectedPackage, CondaxMetaData def test_metadata_to_json(): From d594085dff626d3e067557c95bbfe937e1267744 Mon Sep 17 00:00:00 2001 From: Abraham Murciano Date: Sun, 21 Aug 2022 00:24:39 +0300 Subject: [PATCH 2/5] metadata serialization --- condax/condax/exceptions.py | 7 ++ condax/condax/links.py | 4 +- condax/condax/metadata.py | 126 +++++++++++++++++++++++++++--------- condax/paths.py | 1 - 4 files changed, 104 insertions(+), 34 deletions(-) diff --git a/condax/condax/exceptions.py b/condax/condax/exceptions.py index 6f7d341..b15fa86 100644 --- a/condax/condax/exceptions.py +++ b/condax/condax/exceptions.py @@ -16,3 +16,10 @@ def __init__(self, location: Path, msg: str = ""): 102, f"{location} exists, is not empty, and is not a conda environment. {msg}", ) + + +class BadMetadataError(CondaxError): + def __init__(self, metadata_path: Path, msg: str): + super().__init__( + 103, f"Error loading condax metadata at {metadata_path}: {msg}" + ) diff --git a/condax/condax/links.py b/condax/condax/links.py index 3aad779..9d81649 100644 --- a/condax/condax/links.py +++ b/condax/condax/links.py @@ -64,8 +64,8 @@ def create_link(env: Path, exe: Path, location: Path, is_forcing: bool = False) script_path = location / _get_wrapper_name(exe.name) if script_path.exists() and not is_forcing: - answer = input(f"{exe.name} already exists. Overwrite? (y/N) ").strip().lower() - if answer not in ("y", "yes"): + answer = input(f"{exe.name} already exists in {location}. Overwrite? (y/N) ") + if answer.strip().lower() not in ("y", "yes"): logger.warning(f"Skipped creating entrypoint: {exe.name}") return False diff --git a/condax/condax/metadata.py b/condax/condax/metadata.py index b41701f..a6470f0 100644 --- a/condax/condax/metadata.py +++ b/condax/condax/metadata.py @@ -1,9 +1,11 @@ -from dataclasses import dataclass +from abc import ABC, abstractmethod import json from pathlib import Path -from typing import Iterable, List, Optional +from typing import Any, Dict, Iterable, Optional, Type, TypeVar from condax.conda import env_info +from condax.condax.exceptions import BadMetadataError +from condax.utils import FullPath def create_metadata(env: Path, package: str, executables: Iterable[Path]): @@ -16,29 +18,72 @@ def create_metadata(env: Path, package: str, executables: Iterable[Path]): meta.save() -class _PackageBase: - def __init__(self, name: str, apps: List[str], include_apps: bool): +S = TypeVar("S", bound="Serializable") + + +class Serializable(ABC): + @classmethod + @abstractmethod + def deserialize(cls: Type[S], serialized: Dict[str, Any]) -> S: + raise NotImplementedError() + + @abstractmethod + def serialize(self) -> Dict[str, Any]: + raise NotImplementedError() + + +class _PackageBase(Serializable): + def __init__(self, name: str, apps: Iterable[str], include_apps: bool): self.name = name - self.apps = apps + self.apps = set(apps) self.include_apps = include_apps def __lt__(self, other): return self.name < other.name + def serialize(self) -> Dict[str, Any]: + return { + "name": self.name, + "apps": list(self.apps), + "include_apps": self.include_apps, + } + + @classmethod + def deserialize(cls, serialized: Dict[str, Any]): + assert isinstance(serialized, dict) + assert isinstance(serialized["name"], str) + assert isinstance(serialized["apps"], list) + assert all(isinstance(app, str) for app in serialized["apps"]) + assert isinstance(serialized["include_apps"], bool) + serialized.update(apps=set(serialized["apps"])) + return cls(**serialized) + -@dataclass class MainPackage(_PackageBase): - name: str - prefix: Path - apps: List[str] - include_apps: bool = True + def __init__( + self, name: str, prefix: Path, apps: Iterable[str], include_apps: bool = True + ): + super().__init__(name, apps, include_apps) + self.prefix = prefix + + def serialize(self) -> Dict[str, Any]: + return { + **super().serialize(), + "prefix": str(self.prefix), + } + + @classmethod + def deserialize(cls, serialized: Dict[str, Any]): + assert isinstance(serialized["prefix"], str) + serialized.update(prefix=FullPath(serialized["prefix"])) + return super().deserialize(serialized) class InjectedPackage(_PackageBase): pass -class CondaxMetaData: +class CondaxMetaData(Serializable): """ Handle metadata information written in `condax_metadata.json` placed in each environment. @@ -46,25 +91,46 @@ class CondaxMetaData: metadata_file = "condax_metadata.json" - def __init__(self, main: MainPackage, injected: Iterable[InjectedPackage] = ()): - self.main_package = main - self.injected_packages = tuple(sorted(injected)) + def __init__( + self, + main_package: MainPackage, + injected_packages: Iterable[InjectedPackage] = (), + ): + self.main_package = main_package + self.injected_packages = {pkg.name: pkg for pkg in injected_packages} def inject(self, package: InjectedPackage): - self.injected_packages = tuple(sorted(set(self.injected_packages) | {package})) + self.injected_packages[package.name] = package def uninject(self, name: str): - self.injected_packages = tuple( - p for p in self.injected_packages if p.name != name + self.injected_packages.pop(name, None) + + def serialize(self) -> Dict[str, Any]: + return { + "main_package": self.main_package.serialize(), + "injected_packages": [ + pkg.serialize() for pkg in self.injected_packages.values() + ], + } + + @classmethod + def deserialize(cls, serialized: Dict[str, Any]): + assert isinstance(serialized, dict) + assert isinstance(serialized["main_package"], dict) + assert isinstance(serialized["injected_packages"], list) + serialized.update( + main_package=MainPackage.deserialize(serialized["main_package"]), + injected_packages=[ + InjectedPackage.deserialize(pkg) + for pkg in serialized["injected_packages"] + ], ) - - def to_json(self) -> str: - return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4) + return cls(**serialized) def save(self) -> None: - p = self.main_package.prefix / self.metadata_file - with open(p, "w") as fo: - fo.write(self.to_json()) + metadata_path = self.main_package.prefix / self.metadata_file + with metadata_path.open("w") as f: + json.dump(self.serialize(), f, indent=4) def load(prefix: Path) -> Optional[CondaxMetaData]: @@ -74,12 +140,10 @@ def load(prefix: Path) -> Optional[CondaxMetaData]: with open(p) as f: d = json.load(f) - if not d: - raise ValueError(f"Failed to read the metadata from {p}") - return _from_dict(d) - -def _from_dict(d: dict) -> CondaxMetaData: - main = MainPackage(**d["main_package"]) - injected = [InjectedPackage(**p) for p in d["injected_packages"]] - return CondaxMetaData(main, injected) + try: + return CondaxMetaData.deserialize(d) + except AssertionError as e: + raise BadMetadataError(p, f"A value is of the wrong type. {e}") from e + except KeyError as e: + raise BadMetadataError(p, f"Key {e} is missing.") from e diff --git a/condax/paths.py b/condax/paths.py index 47f15a1..3ee1051 100644 --- a/condax/paths.py +++ b/condax/paths.py @@ -1,5 +1,4 @@ import logging -import sys from pathlib import Path from typing import Union From 21b248d1093f7edf2afba5a9d7d9a4abfe5a4b07 Mon Sep 17 00:00:00 2001 From: Abraham Murciano Date: Sun, 21 Aug 2022 02:41:15 +0300 Subject: [PATCH 3/5] Refactor remove command --- condax/cli/options.py | 13 +-- condax/cli/remove.py | 6 +- condax/conda/conda.py | 87 ++++++++++----- condax/conda/env_info.py | 4 + condax/condax/condax.py | 17 ++- condax/condax/exceptions.py | 8 +- condax/condax/links.py | 37 +++++- condax/condax/metadata.py | 149 ------------------------- condax/condax/metadata/__init__.py | 0 condax/condax/metadata/exceptions.py | 14 +++ condax/condax/metadata/metadata.py | 128 +++++++++++++++++++++ condax/condax/metadata/package.py | 56 ++++++++++ condax/condax/metadata/serializable.py | 16 +++ condax/core.py | 98 +--------------- condax/utils.py | 9 +- poetry.lock | 64 ++++++++++- pyproject.toml | 1 + tests/test_condax_update.py | 9 +- 18 files changed, 410 insertions(+), 306 deletions(-) delete mode 100644 condax/condax/metadata.py create mode 100644 condax/condax/metadata/__init__.py create mode 100644 condax/condax/metadata/exceptions.py create mode 100644 condax/condax/metadata/metadata.py create mode 100644 condax/condax/metadata/package.py create mode 100644 condax/condax/metadata/serializable.py diff --git a/condax/cli/options.py b/condax/cli/options.py index 02f2ad4..4b6e01d 100644 --- a/condax/cli/options.py +++ b/condax/cli/options.py @@ -22,6 +22,7 @@ def common(f: Callable) -> Callable: """ options: Sequence[Callable] = ( condax, + log_level, click.help_option("-h", "--help"), ) @@ -108,23 +109,17 @@ def _config_file_callback(_, __, config_file: Path) -> Mapping[str, Any]: def conda(f: Callable) -> Callable: """ - This click option decorator adds the --channel and --config options as well as all those added by `options.log_level` to the CLI. + This click option decorator adds the --channel and --config options to the CLI. It constructs a `Conda` object and passes it to the decorated function as `conda`. It reads the config file and passes it as a dict to the decorated function as `config`. """ - @log_level @config @wraps(f) - def construct_conda_hook(config: Mapping[str, Any], log_level: int, **kwargs): + def construct_conda_hook(config: Mapping[str, Any], **kwargs): return f( - conda=Conda( - config.get("channels", []), - stdout=subprocess.DEVNULL if log_level >= logging.INFO else None, - stderr=subprocess.DEVNULL if log_level >= logging.CRITICAL else None, - ), + conda=Conda(config.get("channels", [])), config=config, - log_level=log_level, **kwargs, ) diff --git a/condax/cli/remove.py b/condax/cli/remove.py index 3e51d29..28df6d0 100644 --- a/condax/cli/remove.py +++ b/condax/cli/remove.py @@ -1,6 +1,6 @@ import logging from typing import List -import click +from condax.condax import Condax import condax.core as core from condax import __version__ @@ -18,9 +18,9 @@ ) @options.common @options.packages -def remove(packages: List[str], log_level: int, **_): +def remove(packages: List[str], condax: Condax, **_): for pkg in packages: - core.remove_package(pkg, conda_stdout=log_level <= logging.INFO) + condax.remove_package(pkg) @cli.command( diff --git a/condax/conda/conda.py b/condax/conda/conda.py index c47d641..9082403 100644 --- a/condax/conda/conda.py +++ b/condax/conda/conda.py @@ -3,7 +3,9 @@ import shlex import subprocess import logging -from typing import Iterable +import sys +from typing import IO, Iterable, Optional +from halo import Halo from condax import consts from .installers import ensure_conda @@ -13,35 +15,26 @@ class Conda: - def __init__( - self, - channels: Iterable[str], - stdout=subprocess.DEVNULL, - stderr=None, - ) -> None: + def __init__(self, channels: Iterable[str]) -> None: """This class is a wrapper for conda's CLI. Args: channels: Additional channels to use. - stdout (optional): This is passed directly to `subprocess.run`. Defaults to subprocess.DEVNULL. - stderr (optional): This is passed directly to `subprocess.run`. Defaults to None. """ self.channels = tuple(channels) - self.stdout = stdout - self.stderr = stderr self.exe = ensure_conda(consts.DEFAULT_PATHS.bin_dir) - @classmethod - def is_env(cls, path: Path) -> bool: - return (path / "conda-meta").is_dir() - def remove_env(self, env: Path) -> None: """Remove a conda environment. Args: env: The path to the environment to remove. """ - self._run(f"remove --prefix {env} --all --yes") + self._run( + f"env remove --prefix {env} --yes", + stdout_level=logging.DEBUG, + stderr_level=logging.INFO, + ) def create_env( self, @@ -58,21 +51,63 @@ def create_env( spec: Package spec to install. e.g. "python=3.6", "python>=3.6", "python", etc. extra_channels: Additional channels to search for packages in. """ - self._run( - f"create --prefix {prefix} {' '.join(f'--channel {c}' for c in itertools.chain(extra_channels, self.channels))} --quiet --yes {shlex.quote(spec)}" - ) + cmd = f"create --prefix {prefix} {' '.join(f'--channel {c}' for c in itertools.chain(extra_channels, self.channels))} --quiet --yes {shlex.quote(spec)}" + if logger.getEffectiveLevel() <= logging.INFO: + with Halo( + text=f"Creating environment for {spec}", + spinner="dots", + stream=sys.stderr, + ): + self._run(cmd) + else: + self._run(cmd) - def _run(self, command: str) -> subprocess.CompletedProcess: + def _run( + self, + command: str, + stdout_level: int = logging.DEBUG, + stderr_level: int = logging.ERROR, + ) -> subprocess.CompletedProcess: """Run a conda command. Args: command: The command to run excluding the conda executable. """ - cmd = shlex.split(f"{self.exe} {command}") + cmd = f"{self.exe} {command}" logger.debug(f"Running: {cmd}") - return subprocess.run( - cmd, - stdout=self.stdout, - stderr=self.stderr, - text=True, + cmd_list = shlex.split(cmd) + + p = subprocess.Popen( + cmd_list, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) + + stdout_done, stderr_done = False, False + while not stdout_done or not stderr_done: + stdout_done = self._log_stream(p.stdout, stdout_level) + stderr_done = self._log_stream(p.stderr, stderr_level) + + ret_code = p.wait() + + return subprocess.CompletedProcess( + cmd_list, + ret_code, + p.stdout.read() if p.stdout else None, + p.stderr.read() if p.stderr else None, + ) + + def _log_stream(self, stream: Optional[IO[str]], log_level: int) -> bool: + """Log one line of process ouput. + + Args: + stream: The stream to read from. + log_level: The log level to use. + + Returns: + True if the stream is depleted. False otherwise. + """ + if stream is None: + return True + line = stream.readline() + if line: + logger.log(log_level, f"\r{line.rstrip()}") + return not line diff --git a/condax/conda/env_info.py b/condax/conda/env_info.py index 6835a6a..b870267 100644 --- a/condax/conda/env_info.py +++ b/condax/conda/env_info.py @@ -7,6 +7,10 @@ from .exceptions import NoPackageMetadata +def is_env(path: Path) -> bool: + return (path / "conda-meta").is_dir() + + def find_exes(prefix: Path, package: str) -> List[Path]: """Find executables in environment `prefix` provided py a given `package`. diff --git a/condax/condax/condax.py b/condax/condax/condax.py index ced4231..016cd6f 100644 --- a/condax/condax/condax.py +++ b/condax/condax/condax.py @@ -5,7 +5,9 @@ from condax import utils from condax.conda import Conda, env_info from .exceptions import PackageInstalledError, NotAnEnvError -from . import links, metadata + +from . import links +from .metadata import metadata logger = logging.getLogger(__name__) @@ -38,7 +40,7 @@ def install_package( package = utils.package_name(spec) env = self.prefix_dir / package - if self.conda.is_env(env): + if env_info.is_env(env): if is_forcing: logger.warning(f"Overwriting environment for {package}") self.conda.remove_env(env) @@ -53,3 +55,14 @@ def install_package( links.create_links(env, executables, self.bin_dir, is_forcing=is_forcing) metadata.create_metadata(env, package, executables) logger.info(f"`{package}` has been installed by condax") + + def remove_package(self, package: str): + env = self.prefix_dir / package + if not env_info.is_env(env): + logger.warning(f"{package} is not installed with condax") + return + + apps_to_unlink = metadata.load(env).apps + links.remove_links(package, self.bin_dir, apps_to_unlink) + self.conda.remove_env(env) + logger.info(f"`{package}` has been removed from condax") diff --git a/condax/condax/exceptions.py b/condax/condax/exceptions.py index b15fa86..2aaeacc 100644 --- a/condax/condax/exceptions.py +++ b/condax/condax/exceptions.py @@ -18,8 +18,6 @@ def __init__(self, location: Path, msg: str = ""): ) -class BadMetadataError(CondaxError): - def __init__(self, metadata_path: Path, msg: str): - super().__init__( - 103, f"Error loading condax metadata at {metadata_path}: {msg}" - ) +class PackageNotInstalled(CondaxError): + def __init__(self, package: str): + super().__init__(103, f"Package `{package}` is not installed with condax") diff --git a/condax/condax/links.py b/condax/condax/links.py index 9d81649..def4b22 100644 --- a/condax/condax/links.py +++ b/condax/condax/links.py @@ -2,10 +2,10 @@ import os from pathlib import Path import shutil -from typing import Iterable +from typing import Iterable, List from condax.conda import installers -from condax import utils +from condax import utils, wrapper logger = logging.getLogger(__name__) @@ -49,7 +49,7 @@ def create_link(env: Path, exe: Path, location: Path, is_forcing: bool = False) if os.name == "nt": script_lines = [ "@rem Entrypoint created by condax\n", - f"@call {utils.quote(micromamba_exe)} run --prefix {utils.quote(env)} {utils.quote(exe)} %*\n", + f'@call "{micromamba_exe}" run --prefix "{env}" "{exe}" %*\n', ] else: script_lines = [ @@ -78,6 +78,37 @@ def create_link(env: Path, exe: Path, location: Path, is_forcing: bool = False) return True +def remove_links(package: str, location: Path, executables_to_unlink: Iterable[str]): + unlinked: List[str] = [] + for executable_name in executables_to_unlink: + link_path = location / _get_wrapper_name(executable_name) + if os.name == "nt": + # FIXME: this is hand-waving for now + utils.unlink(link_path) + else: + wrapper_env = wrapper.read_env_name(link_path) + + if wrapper_env is None: + utils.unlink(link_path) + unlinked.append(f"{executable_name} \t (failed to get env)") + continue + + if wrapper_env != package: + logger.warning( + f"Keeping {executable_name} as it runs in environment `{wrapper_env}`, not `{package}`." + ) + continue + + link_path.unlink() + + unlinked.append(executable_name) + + if executables_to_unlink: + logger.info( + "\n - ".join(("Removed the following entrypoint links:", *unlinked)) + ) + + def _get_wrapper_name(name: str) -> str: """Get the file name of the entrypoint script for the executable with the given name. diff --git a/condax/condax/metadata.py b/condax/condax/metadata.py deleted file mode 100644 index a6470f0..0000000 --- a/condax/condax/metadata.py +++ /dev/null @@ -1,149 +0,0 @@ -from abc import ABC, abstractmethod -import json -from pathlib import Path -from typing import Any, Dict, Iterable, Optional, Type, TypeVar - -from condax.conda import env_info -from condax.condax.exceptions import BadMetadataError -from condax.utils import FullPath - - -def create_metadata(env: Path, package: str, executables: Iterable[Path]): - """ - Create metadata file - """ - apps = [p.name for p in (executables or env_info.find_exes(env, package))] - main = MainPackage(package, env, apps) - meta = CondaxMetaData(main) - meta.save() - - -S = TypeVar("S", bound="Serializable") - - -class Serializable(ABC): - @classmethod - @abstractmethod - def deserialize(cls: Type[S], serialized: Dict[str, Any]) -> S: - raise NotImplementedError() - - @abstractmethod - def serialize(self) -> Dict[str, Any]: - raise NotImplementedError() - - -class _PackageBase(Serializable): - def __init__(self, name: str, apps: Iterable[str], include_apps: bool): - self.name = name - self.apps = set(apps) - self.include_apps = include_apps - - def __lt__(self, other): - return self.name < other.name - - def serialize(self) -> Dict[str, Any]: - return { - "name": self.name, - "apps": list(self.apps), - "include_apps": self.include_apps, - } - - @classmethod - def deserialize(cls, serialized: Dict[str, Any]): - assert isinstance(serialized, dict) - assert isinstance(serialized["name"], str) - assert isinstance(serialized["apps"], list) - assert all(isinstance(app, str) for app in serialized["apps"]) - assert isinstance(serialized["include_apps"], bool) - serialized.update(apps=set(serialized["apps"])) - return cls(**serialized) - - -class MainPackage(_PackageBase): - def __init__( - self, name: str, prefix: Path, apps: Iterable[str], include_apps: bool = True - ): - super().__init__(name, apps, include_apps) - self.prefix = prefix - - def serialize(self) -> Dict[str, Any]: - return { - **super().serialize(), - "prefix": str(self.prefix), - } - - @classmethod - def deserialize(cls, serialized: Dict[str, Any]): - assert isinstance(serialized["prefix"], str) - serialized.update(prefix=FullPath(serialized["prefix"])) - return super().deserialize(serialized) - - -class InjectedPackage(_PackageBase): - pass - - -class CondaxMetaData(Serializable): - """ - Handle metadata information written in `condax_metadata.json` - placed in each environment. - """ - - metadata_file = "condax_metadata.json" - - def __init__( - self, - main_package: MainPackage, - injected_packages: Iterable[InjectedPackage] = (), - ): - self.main_package = main_package - self.injected_packages = {pkg.name: pkg for pkg in injected_packages} - - def inject(self, package: InjectedPackage): - self.injected_packages[package.name] = package - - def uninject(self, name: str): - self.injected_packages.pop(name, None) - - def serialize(self) -> Dict[str, Any]: - return { - "main_package": self.main_package.serialize(), - "injected_packages": [ - pkg.serialize() for pkg in self.injected_packages.values() - ], - } - - @classmethod - def deserialize(cls, serialized: Dict[str, Any]): - assert isinstance(serialized, dict) - assert isinstance(serialized["main_package"], dict) - assert isinstance(serialized["injected_packages"], list) - serialized.update( - main_package=MainPackage.deserialize(serialized["main_package"]), - injected_packages=[ - InjectedPackage.deserialize(pkg) - for pkg in serialized["injected_packages"] - ], - ) - return cls(**serialized) - - def save(self) -> None: - metadata_path = self.main_package.prefix / self.metadata_file - with metadata_path.open("w") as f: - json.dump(self.serialize(), f, indent=4) - - -def load(prefix: Path) -> Optional[CondaxMetaData]: - p = prefix / CondaxMetaData.metadata_file - if not p.exists(): - return None - - with open(p) as f: - d = json.load(f) - - try: - return CondaxMetaData.deserialize(d) - except AssertionError as e: - raise BadMetadataError(p, f"A value is of the wrong type. {e}") from e - except KeyError as e: - raise BadMetadataError(p, f"Key {e} is missing.") from e diff --git a/condax/condax/metadata/__init__.py b/condax/condax/metadata/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/condax/condax/metadata/exceptions.py b/condax/condax/metadata/exceptions.py new file mode 100644 index 0000000..809f7b6 --- /dev/null +++ b/condax/condax/metadata/exceptions.py @@ -0,0 +1,14 @@ +from pathlib import Path +from condax.exceptions import CondaxError + + +class BadMetadataError(CondaxError): + def __init__(self, metadata_path: Path, msg: str): + super().__init__( + 301, f"Error loading condax metadata at {metadata_path}: {msg}" + ) + + +class NoMetadataError(CondaxError): + def __init__(self, prefix: Path): + super().__init__(302, f"Failed to recreate condax_metadata.json in {prefix}") diff --git a/condax/condax/metadata/metadata.py b/condax/condax/metadata/metadata.py new file mode 100644 index 0000000..10bec22 --- /dev/null +++ b/condax/condax/metadata/metadata.py @@ -0,0 +1,128 @@ +import json +from pathlib import Path +from typing import Any, Dict, Iterable, Optional, Set +import logging + +from condax.conda import env_info + +from .package import MainPackage, InjectedPackage +from .exceptions import BadMetadataError, NoMetadataError +from .serializable import Serializable + +logger = logging.getLogger(__name__) + + +class CondaxMetaData(Serializable): + """ + Handle metadata information written in `condax_metadata.json` + placed in each environment. + """ + + metadata_file = "condax_metadata.json" + + def __init__( + self, + main_package: MainPackage, + injected_packages: Iterable[InjectedPackage] = (), + ): + self.main_package = main_package + self.injected_packages = {pkg.name: pkg for pkg in injected_packages} + + def inject(self, package: InjectedPackage): + self.injected_packages[package.name] = package + + def uninject(self, name: str): + self.injected_packages.pop(name, None) + + @property + def apps(self) -> Set[str]: + return self.main_package.apps | self.injected_packages.keys() + + def serialize(self) -> Dict[str, Any]: + return { + "main_package": self.main_package.serialize(), + "injected_packages": [ + pkg.serialize() for pkg in self.injected_packages.values() + ], + } + + @classmethod + def deserialize(cls, serialized: Dict[str, Any]): + assert isinstance(serialized, dict) + assert isinstance(serialized["main_package"], dict) + assert isinstance(serialized["injected_packages"], list) + serialized.update( + main_package=MainPackage.deserialize(serialized["main_package"]), + injected_packages=[ + InjectedPackage.deserialize(pkg) + for pkg in serialized["injected_packages"] + ], + ) + return cls(**serialized) + + def save(self) -> None: + metadata_path = self.main_package.prefix / self.metadata_file + with metadata_path.open("w") as f: + json.dump(self.serialize(), f, indent=4) + + +def create_metadata( + prefix: Path, + package: Optional[str] = None, + executables: Optional[Iterable[Path]] = None, +): + """ + Create the metadata file. + + Args: + prefix: The conda environment to create the metadata file for. + package: The package to add to the metadata. By default it is the name of the environment's directory. + executables: The executables to add to the metadata. If not provided, they are searched for in conda's metadata. + """ + package = package or prefix.name + apps = [p.name for p in (executables or env_info.find_exes(prefix, package))] + main = MainPackage(package, prefix, apps) + meta = CondaxMetaData(main) + meta.save() + + +def load(prefix: Path) -> CondaxMetaData: + """Load the metadata object for the given environment. + + If the metadata doesn't exist, it is created. + + Args: + prefix (Path): The path to the environment. + + Returns: + CondaxMetaData: The metadata object for the environment. + """ + meta = _load(prefix) + # For backward compatibility: metadata can be absent + if meta is None: + logger.info(f"Recreating condax_metadata.json in {prefix}...") + create_metadata(prefix) + meta = _load(prefix) + if meta is None: + raise NoMetadataError(prefix) + return meta + + +def _load(prefix: Path) -> Optional[CondaxMetaData]: + """Does the heavy lifting for loading the metadata. + + `load` is the exposed wrapper that tries to create it if it doesn't exist. + """ + p = prefix / CondaxMetaData.metadata_file + if not p.exists(): + return None + + with open(p) as f: + d = json.load(f) + + try: + return CondaxMetaData.deserialize(d) + except AssertionError as e: + raise BadMetadataError(p, f"A value is of the wrong type. {e}") from e + except KeyError as e: + raise BadMetadataError(p, f"Key {e} is missing.") from e diff --git a/condax/condax/metadata/package.py b/condax/condax/metadata/package.py new file mode 100644 index 0000000..37f7b9e --- /dev/null +++ b/condax/condax/metadata/package.py @@ -0,0 +1,56 @@ +from pathlib import Path +from typing import Any, Dict, Iterable + +from condax.utils import FullPath +from .serializable import Serializable + + +class _PackageBase(Serializable): + def __init__(self, name: str, apps: Iterable[str], include_apps: bool): + self.name = name + self.apps = set(apps) + self.include_apps = include_apps + + def __lt__(self, other): + return self.name < other.name + + def serialize(self) -> Dict[str, Any]: + return { + "name": self.name, + "apps": list(self.apps), + "include_apps": self.include_apps, + } + + @classmethod + def deserialize(cls, serialized: Dict[str, Any]): + assert isinstance(serialized, dict) + assert isinstance(serialized["name"], str) + assert isinstance(serialized["apps"], list) + assert all(isinstance(app, str) for app in serialized["apps"]) + assert isinstance(serialized["include_apps"], bool) + serialized.update(apps=set(serialized["apps"])) + return cls(**serialized) + + +class MainPackage(_PackageBase): + def __init__( + self, name: str, prefix: Path, apps: Iterable[str], include_apps: bool = True + ): + super().__init__(name, apps, include_apps) + self.prefix = prefix + + def serialize(self) -> Dict[str, Any]: + return { + **super().serialize(), + "prefix": str(self.prefix), + } + + @classmethod + def deserialize(cls, serialized: Dict[str, Any]): + assert isinstance(serialized["prefix"], str) + serialized.update(prefix=FullPath(serialized["prefix"])) + return super().deserialize(serialized) + + +class InjectedPackage(_PackageBase): + pass diff --git a/condax/condax/metadata/serializable.py b/condax/condax/metadata/serializable.py new file mode 100644 index 0000000..6b1e517 --- /dev/null +++ b/condax/condax/metadata/serializable.py @@ -0,0 +1,16 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, Type, TypeVar + + +S = TypeVar("S", bound="Serializable") + + +class Serializable(ABC): + @classmethod + @abstractmethod + def deserialize(cls: Type[S], serialized: Dict[str, Any]) -> S: + raise NotImplementedError() + + @abstractmethod + def serialize(self) -> Dict[str, Any]: + raise NotImplementedError() diff --git a/condax/core.py b/condax/core.py index 42bb564..91a996d 100644 --- a/condax/core.py +++ b/condax/core.py @@ -15,65 +15,12 @@ import condax.utils as utils import condax.config as config from condax.config import C +from condax.conda import env_info logger = logging.getLogger(__name__) -def remove_links(package: str, app_names_to_unlink: Iterable[str]): - unlinked: List[str] = [] - if os.name == "nt": - # FIXME: this is hand-waving for now - for executable_name in app_names_to_unlink: - link_path = _get_wrapper_path(executable_name) - utils.unlink(link_path) - else: - for executable_name in app_names_to_unlink: - link_path = _get_wrapper_path(executable_name) - wrapper_env = wrapper.read_env_name(link_path) - if wrapper_env is None: - utils.unlink(link_path) - unlinked.append(f"{executable_name} \t (failed to get env)") - elif wrapper_env == package: - link_path.unlink() - unlinked.append(executable_name) - else: - logger.warning( - f"Keeping {executable_name} as it runs in environment `{wrapper_env}`, not `{package}`." - ) - - if app_names_to_unlink: - logger.info( - "\n - ".join(("Removed the following entrypoint links:", *unlinked)) - ) - - -def install_package( - spec: str, - location: Path, - bin_dir: Path, - channels: Iterable[str], - is_forcing: bool = False, - conda_stdout: bool = False, -): - package, _ = utils.split_match_specs(spec) - env = location / package - - if conda.is_conda_env(env): - if is_forcing: - logger.warning(f"Overwriting environment for {package}") - conda.remove_conda_env(env, conda_stdout) - else: - raise PackageInstalledError(package, location) - - conda.create_conda_environment(env, spec, conda_stdout, channels, bin_dir) - executables_to_link = conda.determine_executables_from_env(env, package) - utils.mkdir(bin_dir) - create_links(env, executables_to_link, bin_dir, is_forcing=is_forcing) - _create_metadata(env, package) - logger.info(f"`{package}` has been installed by condax") - - def inject_package_to( env_name: str, injected_specs: List[str], @@ -137,28 +84,6 @@ def uninject_package_from( logger.info(f"`{pkgs_str}` has been uninjected from `{env_name}`") -class PackageNotInstalled(CondaxError): - def __init__(self, package: str, error: bool = True): - super().__init__( - 21 if error else 0, - f"Package `{package}` is not installed with condax", - ) - - -def exit_if_not_installed(package: str, error: bool = True): - prefix = conda.conda_env_prefix(package) - if not prefix.exists(): - raise PackageNotInstalled(package, error) - - -def remove_package(package: str, conda_stdout: bool = False): - exit_if_not_installed(package, error=False) - apps_to_unlink = _get_apps(package) - remove_links(package, apps_to_unlink) - conda.remove_conda_env(package, conda_stdout) - logger.info(f"`{package}` has been removed from condax") - - def update_all_packages(update_specs: bool = False, is_forcing: bool = False): for package in _get_all_envs(): update_package(package, update_specs=update_specs, is_forcing=is_forcing) @@ -319,23 +244,6 @@ def update_package( _inject_to_metadata(env, pkg) -class NoMetadataError(CondaxError): - def __init__(self, env: str): - super().__init__(22, f"Failed to recreate condax_metadata.json in {env}") - - -def _load_metadata(env: str) -> metadata.CondaxMetaData: - meta = metadata.load(env) - # For backward compatibility: metadata can be absent - if meta is None: - logger.info(f"Recreating condax_metadata.json in {env}...") - _create_metadata(env) - meta = metadata.load(env) - if meta is None: - raise NoMetadataError(env) - return meta - - def _inject_to_metadata( env: str, packages_to_inject: Iterable[str], include_apps: bool = False ): @@ -367,9 +275,7 @@ def _get_all_envs() -> List[str]: """ utils.mkdir(C.prefix_dir()) return sorted( - pkg_dir.name - for pkg_dir in C.prefix_dir().iterdir() - if utils.is_env_dir(pkg_dir) + pkg_dir.name for pkg_dir in C.prefix_dir().iterdir() if env_info.is_env(pkg_dir) ) diff --git a/condax/utils.py b/condax/utils.py index daaedfb..c058696 100644 --- a/condax/utils.py +++ b/condax/utils.py @@ -1,8 +1,9 @@ +import logging import os from pathlib import Path import platform import shlex -from typing import Tuple, Union +from typing import Iterable, TextIO, Tuple, Union import re import urllib.parse @@ -184,9 +185,3 @@ def to_bool(value: Union[str, bool]) -> bool: pass return False - - -def is_env_dir(path: Union[Path, str]) -> bool: - """Check if a path is a conda environment directory.""" - p = FullPath(path) - return (p / "conda-meta" / "history").exists() diff --git a/poetry.lock b/poetry.lock index a4f4ea8..e1f080f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -148,6 +148,24 @@ python-dateutil = ">=2.6.0" requests = ">=2.18" uritemplate = ">=3.0.0" +[[package]] +name = "halo" +version = "0.0.31" +description = "Beautiful terminal spinners in Python" +category = "main" +optional = false +python-versions = ">=3.4" + +[package.dependencies] +colorama = ">=0.3.9" +log-symbols = ">=0.0.14" +six = ">=1.12.0" +spinners = ">=0.0.24" +termcolor = ">=1.1.0" + +[package.extras] +ipython = ["ipywidgets (==7.1.0)", "IPython (==5.7.0)"] + [[package]] name = "idna" version = "3.3" @@ -189,6 +207,17 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "log-symbols" +version = "0.0.14" +description = "Colored symbols for various log levels for Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +colorama = ">=0.3.9" + [[package]] name = "mypy" version = "0.971" @@ -418,10 +447,26 @@ python-versions = ">=3.5" name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "spinners" +version = "0.0.24" +description = "Spinners for terminals" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "termcolor" +version = "1.1.0" +description = "ANSII Color formatting for output in terminal." +category = "main" +optional = false +python-versions = "*" + [[package]] name = "tomli" version = "2.0.1" @@ -537,7 +582,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "7c447397b203af9883341e58d8d23c76ffd15dcdc122db06edb885cdd264d2dd" +content-hash = "f7ddfad4f6504ed349ee13d2e137c3e1e0c14bdc7cc8a5045460f1e50520f942" [metadata.files] atomicwrites = [ @@ -729,6 +774,10 @@ cryptography = [ {file = "github3.py-3.2.0-py2.py3-none-any.whl", hash = "sha256:a9016e40609c6f5cb9954dd188d08257dafd09c4da8c0e830a033fca00054b0d"}, {file = "github3.py-3.2.0.tar.gz", hash = "sha256:09b72be1497d346b0968cde8360a0d6af79dc206d0149a63cd3ec86c65c377cc"}, ] +halo = [ + {file = "halo-0.0.31-py2-none-any.whl", hash = "sha256:5350488fb7d2aa7c31a1344120cee67a872901ce8858f60da7946cef96c208ab"}, + {file = "halo-0.0.31.tar.gz", hash = "sha256:7b67a3521ee91d53b7152d4ee3452811e1d2a6321975137762eb3d70063cc9d6"}, +] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, @@ -744,6 +793,10 @@ iniconfig = [ lazyasd = [ {file = "lazyasd-0.1.4.tar.gz", hash = "sha256:a3196f05cff27f952ad05767e5735fd564b4ea4e89b23f5ea1887229c3db145b"}, ] +log-symbols = [ + {file = "log_symbols-0.0.14-py3-none-any.whl", hash = "sha256:4952106ff8b605ab7d5081dd2c7e6ca7374584eff7086f499c06edd1ce56dcca"}, + {file = "log_symbols-0.0.14.tar.gz", hash = "sha256:cf0bbc6fe1a8e53f0d174a716bc625c4f87043cc21eb55dd8a740cfe22680556"}, +] mypy = [ {file = "mypy-0.971-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2899a3cbd394da157194f913a931edfd4be5f274a88041c9dc2d9cdcb1c315c"}, {file = "mypy-0.971-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:98e02d56ebe93981c41211c05adb630d1d26c14195d04d95e49cd97dbc046dc5"}, @@ -894,6 +947,13 @@ six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +spinners = [ + {file = "spinners-0.0.24-py3-none-any.whl", hash = "sha256:2fa30d0b72c9650ad12bbe031c9943b8d441e41b4f5602b0ec977a19f3290e98"}, + {file = "spinners-0.0.24.tar.gz", hash = "sha256:1eb6aeb4781d72ab42ed8a01dcf20f3002bf50740d7154d12fb8c9769bf9e27f"}, +] +termcolor = [ + {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, +] tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, diff --git a/pyproject.toml b/pyproject.toml index aad14da..14064ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ userpath = "^1.8.0" PyYAML = "^6.0" importlib-metadata = "^4.12.0" rainbowlog = "^2.0.1" +halo = "^0.0.31" [tool.poetry.dev-dependencies] pytest = "^7.1.2" diff --git a/tests/test_condax_update.py b/tests/test_condax_update.py index 2643b4d..1720cdd 100644 --- a/tests/test_condax_update.py +++ b/tests/test_condax_update.py @@ -9,8 +9,9 @@ def test_condax_update_main_apps(): update_package, ) import condax.config as config - from condax.utils import FullPath, is_env_dir + from condax.utils import FullPath import condax.condax.metadata as metadata + from condax.conda.env_info import is_env # prep prefix_fp = tempfile.TemporaryDirectory() @@ -46,13 +47,13 @@ def test_condax_update_main_apps(): exe_main = bin_dir / "gff3-to-ddbj" # Before installation there should be nothing - assert not is_env_dir(env_dir) + assert not is_env(env_dir) assert all(not app.exists() for app in apps_before_update) install_package(main_spec_before_update) # After installtion there should be an environment and apps - assert is_env_dir(env_dir) + assert is_env(env_dir) assert all(app.exists() and app.is_file() for app in apps_before_update) # gff3-to-ddbj --version was not implemented as of 0.1.1 @@ -62,7 +63,7 @@ def test_condax_update_main_apps(): update_package(main_spec_after_update, update_specs=True) # After update there should be an environment and update apps - assert is_env_dir(env_dir) + assert is_env(env_dir) assert all(app.exists() and app.is_file() for app in apps_after_update) to_be_removed = apps_before_update - apps_after_update From 084a732de2b4fcd73a2eac5718570fc8b8fb4e60 Mon Sep 17 00:00:00 2001 From: Abraham Murciano Date: Sun, 21 Aug 2022 20:42:52 +0300 Subject: [PATCH 4/5] Use click-aliases to alias uninstall --- condax/cli/__init__.py | 4 +++- condax/cli/__main__.py | 3 +-- condax/cli/options.py | 9 ++++----- condax/cli/remove.py | 18 ++---------------- poetry.lock | 20 +++++++++++++++++++- pyproject.toml | 1 + 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/condax/cli/__init__.py b/condax/cli/__init__.py index e1c83de..aade6f7 100644 --- a/condax/cli/__init__.py +++ b/condax/cli/__init__.py @@ -1,4 +1,5 @@ import click +from click_aliases import ClickAliasedGroup import condax.config as config from condax import __version__ @@ -11,7 +12,8 @@ Conda environment location is {config.DEFAULT_PREFIX_DIR}\n Links to apps are placed in {config.DEFAULT_BIN_DIR} - """ + """, + cls=ClickAliasedGroup, ) @click.version_option( __version__, diff --git a/condax/cli/__main__.py b/condax/cli/__main__.py index 87283a6..63c7c42 100644 --- a/condax/cli/__main__.py +++ b/condax/cli/__main__.py @@ -3,7 +3,7 @@ from urllib.error import HTTPError from condax.exceptions import CondaxError from .install import install -from .remove import remove, uninstall +from .remove import remove from .update import update from .list import run_list from .ensure_path import ensure_path @@ -18,7 +18,6 @@ def main(): for subcommand in ( install, remove, - uninstall, update, run_list, ensure_path, diff --git a/condax/cli/options.py b/condax/cli/options.py index 4b6e01d..6b08053 100644 --- a/condax/cli/options.py +++ b/condax/cli/options.py @@ -22,7 +22,7 @@ def common(f: Callable) -> Callable: """ options: Sequence[Callable] = ( condax, - log_level, + setup_logging, click.help_option("-h", "--help"), ) @@ -109,7 +109,7 @@ def _config_file_callback(_, __, config_file: Path) -> Mapping[str, Any]: def conda(f: Callable) -> Callable: """ - This click option decorator adds the --channel and --config options to the CLI. + This click option decorator adds the --config option to the CLI. It constructs a `Conda` object and passes it to the decorated function as `conda`. It reads the config file and passes it as a dict to the decorated function as `config`. """ @@ -157,10 +157,9 @@ def construct_condax_hook( return construct_condax_hook -def log_level(f: Callable) -> Callable: +def setup_logging(f: Callable) -> Callable: """ This click option decorator adds -v and -q options to the CLI, then sets up logging with the specified level. - It passes the level to the decorated function as `log_level`. """ @verbose @@ -177,6 +176,6 @@ def setup_logging_hook(verbose: int, quiet: int, **kwargs): ) ) logger.setLevel(level) - return f(log_level=level, **kwargs) + return f(**kwargs) return setup_logging_hook diff --git a/condax/cli/remove.py b/condax/cli/remove.py index 28df6d0..98dfa89 100644 --- a/condax/cli/remove.py +++ b/condax/cli/remove.py @@ -1,10 +1,6 @@ -import logging from typing import List from condax.condax import Condax -import condax.core as core -from condax import __version__ - from . import cli, options @@ -14,21 +10,11 @@ This will remove a package installed with condax and destroy the underlying conda environment. - """ + """, + aliases=["uninstall"], ) @options.common @options.packages def remove(packages: List[str], condax: Condax, **_): for pkg in packages: condax.remove_package(pkg) - - -@cli.command( - help=""" - Alias for condax remove. - """ -) -@options.common -@options.packages -def uninstall(packages: List[str], **_): - remove(packages) diff --git a/poetry.lock b/poetry.lock index e1f080f..53d4327 100644 --- a/poetry.lock +++ b/poetry.lock @@ -85,6 +85,20 @@ python-versions = ">=3.7" colorama = {version = "*", markers = "platform_system == \"Windows\""} importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +[[package]] +name = "click-aliases" +version = "1.0.1" +description = "Enable aliases for Click" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +click = "*" + +[package.extras] +dev = ["wheel", "coveralls", "pytest-cov", "pytest", "tox-travis", "flake8-import-order", "flake8"] + [[package]] name = "colorama" version = "0.4.5" @@ -582,7 +596,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "f7ddfad4f6504ed349ee13d2e137c3e1e0c14bdc7cc8a5045460f1e50520f942" +content-hash = "b30c76dcea8398059c52429c6e7b3cf5579fb6cfef5ca1d6e0e008a8562e3a10" [metadata.files] atomicwrites = [ @@ -695,6 +709,10 @@ click = [ {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, ] +click-aliases = [ + {file = "click-aliases-1.0.1.tar.gz", hash = "sha256:f48012077e0788eb02f4f8ee458fef3601873fec6c998e9ea8b4554394e705a3"}, + {file = "click_aliases-1.0.1-py2.py3-none-any.whl", hash = "sha256:229ecab12a97d1d5ce3f1fd7ce16da0e4333a24ebe3b34d8b7a6d0a1d2cfab90"}, +] colorama = [ {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, diff --git a/pyproject.toml b/pyproject.toml index 14064ee..f3efb3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ PyYAML = "^6.0" importlib-metadata = "^4.12.0" rainbowlog = "^2.0.1" halo = "^0.0.31" +click-aliases = "^1.0.1" [tool.poetry.dev-dependencies] pytest = "^7.1.2" From d5d3575435ff82df1dee0513c48c1324279d0902 Mon Sep 17 00:00:00 2001 From: Abraham Murciano Date: Mon, 22 Aug 2022 00:43:36 +0300 Subject: [PATCH 5/5] Refactor update command --- condax/cli/update.py | 33 +++++++----- condax/conda.py | 39 -------------- condax/conda/conda.py | 45 ++++++++++++++-- condax/conda/env_info.py | 28 +++++++--- condax/conda/exceptions.py | 10 +++- condax/condax/condax.py | 70 ++++++++++++++++++++++-- condax/condax/links.py | 20 ++++--- condax/condax/metadata/metadata.py | 50 +++++++++++++---- condax/condax/metadata/package.py | 11 ++-- condax/core.py | 86 +----------------------------- condax/utils.py | 9 +++- condax/wrapper.py | 4 +- tests/conftest.py | 1 + tests/fixtures/__init__.py | 4 ++ tests/fixtures/conda.py | 8 +++ tests/fixtures/envs.py | 31 +++++++++++ tests/test_conda/test_env_info.py | 50 +++++++++++++++++ tests/test_metadata.py | 3 +- 18 files changed, 321 insertions(+), 181 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/conda.py create mode 100644 tests/fixtures/envs.py create mode 100644 tests/test_conda/test_env_info.py diff --git a/condax/cli/update.py b/condax/cli/update.py index 026f8cb..60754d8 100644 --- a/condax/cli/update.py +++ b/condax/cli/update.py @@ -1,11 +1,9 @@ -import logging -import sys from typing import List import click import condax.core as core -from condax import __version__ +from condax.condax import Condax from . import cli, options @@ -24,22 +22,29 @@ "--update-specs", is_flag=True, help="Update based on provided specifications." ) @options.common +@options.is_forcing +@options.channels @click.argument("packages", required=False, nargs=-1) -@click.pass_context def update( - ctx: click.Context, - all: bool, + condax: Condax, packages: List[str], + all: bool, update_specs: bool, - log_level: int, + is_forcing: bool, + channels: List[str], **_ ): + + if not (all or packages): + raise click.BadArgumentUsage( + "No packages specified. To update all packages use --all." + ) + + if all and packages: + raise click.BadArgumentUsage("Cannot specify packages and --all.") + if all: - core.update_all_packages(update_specs) - elif packages: - for pkg in packages: - core.update_package( - pkg, update_specs, conda_stdout=log_level <= logging.INFO - ) + condax.update_all_packages(channels, is_forcing) else: - ctx.fail("No packages specified.") + for pkg in packages: + condax.update_package(pkg, channels, update_specs, is_forcing) diff --git a/condax/conda.py b/condax/conda.py index c78bba2..3d7c9c6 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -79,45 +79,6 @@ def uninject_from_conda_env( ) -def update_conda_env(spec: str, update_specs: bool, stdout: bool) -> None: - """Update packages in an environment. - - NOTE: More controls of package updates might be needed. - """ - _, match_spec = utils.split_match_specs(spec) - conda_exe = ensure_conda() - prefix = conda_env_prefix(spec) - channels_args = [x for c in C.channels() for x in ["--channel", c]] - update_specs_args = ["--update-specs"] if update_specs else [] - # NOTE: `conda update` does not support version specification. - # It suggets to use `conda install` instead. - args: Iterable[str] - if conda_exe.name == "conda" and match_spec: - subcmd = "install" - args = (shlex.quote(spec),) - elif match_spec: - subcmd = "update" - args = (*update_specs_args, shlex.quote(spec)) - else: - ## FIXME: this update process is inflexible - subcmd = "update" - args = (*update_specs_args, "--all") - - command: List[Union[Path, str]] = [ - conda_exe, - subcmd, - "--prefix", - prefix, - "--override-channels", - "--quiet", - "--yes", - *channels_args, - *args, - ] - - _subprocess_run(command, suppress_stdout=not stdout) - - def get_package_info(package: str, specific_name=None) -> Tuple[str, str, str]: env_prefix = conda_env_prefix(package) package_name = package if specific_name is None else specific_name diff --git a/condax/conda/conda.py b/condax/conda/conda.py index 9082403..1c02141 100644 --- a/condax/conda/conda.py +++ b/condax/conda/conda.py @@ -7,7 +7,8 @@ from typing import IO, Iterable, Optional from halo import Halo -from condax import consts +from condax import consts, utils +from condax.conda.exceptions import CondaCommandError from .installers import ensure_conda @@ -15,7 +16,7 @@ class Conda: - def __init__(self, channels: Iterable[str]) -> None: + def __init__(self, channels: Iterable[str] = ()) -> None: """This class is a wrapper for conda's CLI. Args: @@ -51,7 +52,10 @@ def create_env( spec: Package spec to install. e.g. "python=3.6", "python>=3.6", "python", etc. extra_channels: Additional channels to search for packages in. """ - cmd = f"create --prefix {prefix} {' '.join(f'--channel {c}' for c in itertools.chain(extra_channels, self.channels))} --quiet --yes {shlex.quote(spec)}" + channels = " ".join( + f"--channel {c}" for c in itertools.chain(extra_channels, self.channels) + ) + cmd = f"create --prefix {prefix} {channels} --quiet --yes {shlex.quote(spec)}" if logger.getEffectiveLevel() <= logging.INFO: with Halo( text=f"Creating environment for {spec}", @@ -62,6 +66,34 @@ def create_env( else: self._run(cmd) + def update_env( + self, + prefix: Path, + spec: str, + update_specs: bool, + extra_channels: Iterable[str] = (), + ) -> None: + """Update packages in an environment.""" + version_info = utils.version_info(spec) + # NOTE: `conda update` does not support version specification. + # It suggets to use `conda install` instead. + ## FIXME: this update process is inflexible + subcmd = "install" if self.exe.name == "conda" and version_info else "update" + + channels = " ".join( + f"--channel {c}" for c in itertools.chain(extra_channels, self.channels) + ) + cmd = f"{subcmd} --prefix {prefix} {channels} --quiet --yes {'--update-specs' if update_specs else ''} {shlex.quote(spec) if version_info else '--all'}" + if logger.getEffectiveLevel() <= logging.INFO: + with Halo( + text=f"Updating environment for {spec}", + spinner="dots", + stream=sys.stderr, + ): + self._run(cmd) + else: + self._run(cmd) + def _run( self, command: str, @@ -86,11 +118,14 @@ def _run( stdout_done = self._log_stream(p.stdout, stdout_level) stderr_done = self._log_stream(p.stderr, stderr_level) - ret_code = p.wait() + p.wait() + + if p.returncode != 0: + raise CondaCommandError(cmd, p) return subprocess.CompletedProcess( cmd_list, - ret_code, + p.returncode, p.stdout.read() if p.stdout else None, p.stderr.read() if p.stderr else None, ) diff --git a/condax/conda/env_info.py b/condax/conda/env_info.py index b870267..bb3fb36 100644 --- a/condax/conda/env_info.py +++ b/condax/conda/env_info.py @@ -1,17 +1,17 @@ import json from pathlib import Path -from typing import List, Union, Set +from typing import Union, Set from condax.utils import FullPath from condax import utils -from .exceptions import NoPackageMetadata +from .exceptions import NoPackageMetadataError def is_env(path: Path) -> bool: - return (path / "conda-meta").is_dir() + return (path / "conda-meta/history").is_file() -def find_exes(prefix: Path, package: str) -> List[Path]: +def find_exes(prefix: Path, package: str) -> Set[Path]: """Find executables in environment `prefix` provided py a given `package`. Args: @@ -22,7 +22,7 @@ def find_exes(prefix: Path, package: str) -> List[Path]: A list of executables in `prefix` provided by `package`. Raises: - DeterminePkgFilesError: If the package files could not be determined. + NoPackageMetadataError: If the package files could not be determined. """ def is_exe(p: Union[str, Path]) -> bool: @@ -44,8 +44,20 @@ def is_exe(p: Union[str, Path]) -> bool: } break else: - raise NoPackageMetadata(package) + raise NoPackageMetadataError(package) - return sorted( + return { prefix / fn for fn in potential_executables if utils.is_executable(prefix / fn) - ) + } + + +def find_envs(directory: Path) -> Set[Path]: + """Find all environments in `directory`. + + Args: + directory: The directory to search in. + + Returns: + A list of environment prefixes in `directory`. + """ + return {prefix for prefix in directory.iterdir() if is_env(prefix)} diff --git a/condax/conda/exceptions.py b/condax/conda/exceptions.py index ebe2b64..289c497 100644 --- a/condax/conda/exceptions.py +++ b/condax/conda/exceptions.py @@ -1,6 +1,14 @@ +from subprocess import Popen from condax.exceptions import CondaxError -class NoPackageMetadata(CondaxError): +class NoPackageMetadataError(CondaxError): def __init__(self, package: str): super().__init__(201, f"Could not determine package files: {package}.") + + +class CondaCommandError(CondaxError): + def __init__(self, command: str, p: Popen[str]): + super().__init__( + 202, f"Conda command `{command}` failed with exit code {p.returncode}." + ) diff --git a/condax/condax/condax.py b/condax/condax/condax.py index 016cd6f..c6302e3 100644 --- a/condax/condax/condax.py +++ b/condax/condax/condax.py @@ -1,10 +1,10 @@ from pathlib import Path -from typing import Iterable +from typing import Iterable, Set import logging from condax import utils -from condax.conda import Conda, env_info -from .exceptions import PackageInstalledError, NotAnEnvError +from condax.conda import Conda, env_info, exceptions as conda_exceptions +from .exceptions import PackageInstalledError, NotAnEnvError, PackageNotInstalled from . import links from .metadata import metadata @@ -53,7 +53,7 @@ def install_package( executables = env_info.find_exes(env, package) utils.mkdir(self.bin_dir) links.create_links(env, executables, self.bin_dir, is_forcing=is_forcing) - metadata.create_metadata(env, package, executables) + metadata.create(env, package, executables) logger.info(f"`{package}` has been installed by condax") def remove_package(self, package: str): @@ -63,6 +63,66 @@ def remove_package(self, package: str): return apps_to_unlink = metadata.load(env).apps - links.remove_links(package, self.bin_dir, apps_to_unlink) + links.remove_links(env, apps_to_unlink, self.bin_dir) self.conda.remove_env(env) logger.info(f"`{package}` has been removed from condax") + + def update_all_packages( + self, channels: Iterable[str] = (), is_forcing: bool = False + ): + for env in env_info.find_envs(self.prefix_dir): + self.update_package(env.name, channels, is_forcing=is_forcing) + + def update_package( + self, + spec: str, + channels: Iterable[str] = (), + update_specs: bool = False, + is_forcing: bool = False, + ) -> None: + pkg_name = utils.package_name(spec) + env = self.prefix_dir / pkg_name + meta = metadata.load(env) + + if not env_info.is_env(env): + raise PackageNotInstalled(pkg_name) + + try: + exes_before = self._find_all_exes(env, meta) + self.conda.update_env(env, spec, update_specs, channels) + exes_after = self._find_all_exes(env, meta) + + to_create = exes_after - exes_before + to_remove = exes_before - exes_after + + links.create_links(env, to_create, self.bin_dir, is_forcing) + links.remove_links(env, (exe.name for exe in to_remove), self.bin_dir) + + logger.info(f"{pkg_name} updated successfully") + + except conda_exceptions.CondaCommandError as e: + logger.error(str(e)) + logger.error(f"Failed to update `{env}`") + logger.warning(f"Recreating the environment...") + + self.remove_package(pkg_name) + self.install_package(spec, channels, is_forcing=is_forcing) + for pkg in meta.injected_packages: + self.inject_package(pkg.name, env, is_forcing=is_forcing) + + # Update metadata file + metadata.create(env) + for pkg in meta.injected_packages: + metadata.inject(env, (pkg.name,), pkg.include_apps) + + def _find_all_exes(self, env: Path, meta: metadata.CondaxMetaData) -> Set[Path]: + """Get exes of main and injected packages in env directory (not in self.bin_dir)""" + return { + utils.FullPath(exe) + for exe in env_info.find_exes(env, meta.main_package.name).union( + *( + env_info.find_exes(env, injected.name) + for injected in meta.injected_packages + ) + ) + } diff --git a/condax/condax/links.py b/condax/condax/links.py index def4b22..a34e44d 100644 --- a/condax/condax/links.py +++ b/condax/condax/links.py @@ -16,7 +16,7 @@ def create_links( location: Path, is_forcing: bool = False, ): - """Create links to the executables in `executables_to_link` in `bin_dir`. + """Create links to the executables in `executables_to_link` in `location`. Args: env: The conda environment to link executables from. @@ -34,7 +34,7 @@ def create_links( def create_link(env: Path, exe: Path, location: Path, is_forcing: bool = False) -> bool: - """Create a link to the executable in `exe` in `bin_dir`. + """Create a link to the executable in `exe` in `location`. Args: env: The conda environment to link executables from. @@ -78,24 +78,32 @@ def create_link(env: Path, exe: Path, location: Path, is_forcing: bool = False) return True -def remove_links(package: str, location: Path, executables_to_unlink: Iterable[str]): +def remove_links(env: Path, executables_to_unlink: Iterable[str], location: Path): + """Remove links in `location` which point to to executables in `env` whose names match those in `executables_to_unlink`. + + Args: + env: The conda environment which the links must point to to be removed. + location: The location the links are in. + executables_to_unlink: The names of the executables to unlink. + """ unlinked: List[str] = [] + executables_to_unlink = tuple(executables_to_unlink) for executable_name in executables_to_unlink: link_path = location / _get_wrapper_name(executable_name) if os.name == "nt": # FIXME: this is hand-waving for now utils.unlink(link_path) else: - wrapper_env = wrapper.read_env_name(link_path) + wrapper_env = wrapper.read_prefix(link_path) if wrapper_env is None: utils.unlink(link_path) unlinked.append(f"{executable_name} \t (failed to get env)") continue - if wrapper_env != package: + if wrapper_env.samefile(env): logger.warning( - f"Keeping {executable_name} as it runs in environment `{wrapper_env}`, not `{package}`." + f"Keeping {executable_name} as it runs in environment `{wrapper_env}`, not `{env}`." ) continue diff --git a/condax/condax/metadata/metadata.py b/condax/condax/metadata/metadata.py index 10bec22..cd577b4 100644 --- a/condax/condax/metadata/metadata.py +++ b/condax/condax/metadata/metadata.py @@ -25,24 +25,35 @@ def __init__( main_package: MainPackage, injected_packages: Iterable[InjectedPackage] = (), ): - self.main_package = main_package - self.injected_packages = {pkg.name: pkg for pkg in injected_packages} + self._main_package = main_package + self._injected_packages = {pkg.name: pkg for pkg in injected_packages} def inject(self, package: InjectedPackage): - self.injected_packages[package.name] = package + self._injected_packages[package.name] = package def uninject(self, name: str): - self.injected_packages.pop(name, None) + self._injected_packages.pop(name, None) @property def apps(self) -> Set[str]: - return self.main_package.apps | self.injected_packages.keys() + """All the executable apps in the condax environment, including injected ones.""" + return self._main_package._apps.union( + *(pkg._apps for pkg in self._injected_packages.values()) + ) + + @property + def main_package(self) -> MainPackage: + return self._main_package + + @property + def injected_packages(self) -> Set[InjectedPackage]: + return set(self._injected_packages.values()) def serialize(self) -> Dict[str, Any]: return { - "main_package": self.main_package.serialize(), + "main_package": self._main_package.serialize(), "injected_packages": [ - pkg.serialize() for pkg in self.injected_packages.values() + pkg.serialize() for pkg in self._injected_packages.values() ], } @@ -61,12 +72,12 @@ def deserialize(cls, serialized: Dict[str, Any]): return cls(**serialized) def save(self) -> None: - metadata_path = self.main_package.prefix / self.metadata_file + metadata_path = self._main_package.prefix / self.metadata_file with metadata_path.open("w") as f: json.dump(self.serialize(), f, indent=4) -def create_metadata( +def create( prefix: Path, package: Optional[str] = None, executables: Optional[Iterable[Path]] = None, @@ -80,12 +91,29 @@ def create_metadata( executables: The executables to add to the metadata. If not provided, they are searched for in conda's metadata. """ package = package or prefix.name - apps = [p.name for p in (executables or env_info.find_exes(prefix, package))] + apps = (p.name for p in (executables or env_info.find_exes(prefix, package))) main = MainPackage(package, prefix, apps) meta = CondaxMetaData(main) meta.save() +def inject(prefix: Path, packages_to_inject: Iterable[str], include_apps: bool = False): + """ + Inject the given packages into the condax_metadata.json file for the environment at `prefix`. + + Args: + prefix: The path to the environment. + packages_to_inject: The names of the packages to inject. + include_apps: Whether to make links to the executables of the injected packages. + """ + meta = load(prefix) + for pkg in packages_to_inject: + apps = (p.name for p in env_info.find_exes(prefix, pkg)) + pkg_to_inject = InjectedPackage(pkg, apps, include_apps=include_apps) + meta.inject(pkg_to_inject) + meta.save() + + def load(prefix: Path) -> CondaxMetaData: """Load the metadata object for the given environment. @@ -101,7 +129,7 @@ def load(prefix: Path) -> CondaxMetaData: # For backward compatibility: metadata can be absent if meta is None: logger.info(f"Recreating condax_metadata.json in {prefix}...") - create_metadata(prefix) + create(prefix) meta = _load(prefix) if meta is None: raise NoMetadataError(prefix) diff --git a/condax/condax/metadata/package.py b/condax/condax/metadata/package.py index 37f7b9e..48fdce5 100644 --- a/condax/condax/metadata/package.py +++ b/condax/condax/metadata/package.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Dict, Iterable +from typing import Any, Dict, Iterable, Set from condax.utils import FullPath from .serializable import Serializable @@ -8,16 +8,21 @@ class _PackageBase(Serializable): def __init__(self, name: str, apps: Iterable[str], include_apps: bool): self.name = name - self.apps = set(apps) + self._apps = set(apps) self.include_apps = include_apps + @property + def apps(self) -> Set[str]: + """The executable apps provided by the package.""" + return self._apps + def __lt__(self, other): return self.name < other.name def serialize(self) -> Dict[str, Any]: return { "name": self.name, - "apps": list(self.apps), + "apps": list(self._apps), "include_apps": self.include_apps, } diff --git a/condax/core.py b/condax/core.py index 91a996d..e052caf 100644 --- a/condax/core.py +++ b/condax/core.py @@ -84,11 +84,6 @@ def uninject_package_from( logger.info(f"`{pkgs_str}` has been uninjected from `{env_name}`") -def update_all_packages(update_specs: bool = False, is_forcing: bool = False): - for package in _get_all_envs(): - update_package(package, update_specs=update_specs, is_forcing=is_forcing) - - def list_all_packages(short=False, include_injected=False) -> None: if short: _list_all_packages_short(include_injected) @@ -180,85 +175,6 @@ def _print_condax_dirs() -> None: ) -def update_package( - spec: str, - update_specs: bool = False, - is_forcing: bool = False, - conda_stdout: bool = False, -) -> None: - - env, _ = utils.split_match_specs(spec) - exit_if_not_installed(env) - try: - main_apps_before_update = set(conda.determine_executables_from_env(env)) - injected_apps_before_update = { - injected: set(conda.determine_executables_from_env(env, injected)) - for injected in _get_injected_packages(env) - } - conda.update_conda_env(spec, update_specs, conda_stdout) - main_apps_after_update = set(conda.determine_executables_from_env(env)) - injected_apps_after_update = { - injected: set(conda.determine_executables_from_env(env, injected)) - for injected in _get_injected_packages(env) - } - - if ( - main_apps_before_update == main_apps_after_update - and injected_apps_before_update == injected_apps_after_update - ): - logger.info(f"No updates found: {env}") - - to_create = main_apps_after_update - main_apps_before_update - to_delete = main_apps_before_update - main_apps_after_update - to_delete_apps = [path.name for path in to_delete] - - # Update links of main apps - create_links(env, to_create, is_forcing) - remove_links(env, to_delete_apps) - - # Update links of injected apps - for pkg in _get_injected_packages(env): - to_delete = ( - injected_apps_before_update[pkg] - injected_apps_after_update[pkg] - ) - to_delete_apps = [p.name for p in to_delete] - remove_links(env, to_delete_apps) - - to_create = ( - injected_apps_after_update[pkg] - injected_apps_before_update[pkg] - ) - create_links(env, to_create, is_forcing) - - logger.info(f"{env} update successfully") - - except subprocess.CalledProcessError: - logger.error(f"Failed to update `{env}`") - logger.warning(f"Recreating the environment...") - - remove_package(env, conda_stdout) - install_package(env, is_forcing=is_forcing, conda_stdout=conda_stdout) - - # Update metadata file - _create_metadata(env) - for pkg in _get_injected_packages(env): - _inject_to_metadata(env, pkg) - - -def _inject_to_metadata( - env: str, packages_to_inject: Iterable[str], include_apps: bool = False -): - """ - Inject the package into the condax_metadata.json file for the env. - """ - meta = _load_metadata(env) - for pkg in packages_to_inject: - apps = [p.name for p in conda.determine_executables_from_env(env, pkg)] - pkg_to_inject = metadata.InjectedPackage(pkg, apps, include_apps=include_apps) - meta.uninject(pkg) # overwrites if necessary - meta.inject(pkg_to_inject) - meta.save() - - def _uninject_from_metadata(env: str, packages_to_uninject: Iterable[str]): """ Uninject the package from the condax_metadata.json file for the env. @@ -435,7 +351,7 @@ def _prune_links(): if not wrapper.is_wrapper(link): continue - target_env = wrapper.read_env_name(link) + target_env = wrapper.read_prefix(link) if target_env is None: logging.info(f"Failed to read env name from {link}") continue diff --git a/condax/utils.py b/condax/utils.py index c058696..2613f42 100644 --- a/condax/utils.py +++ b/condax/utils.py @@ -44,11 +44,18 @@ def split_match_specs(package_with_specs: str) -> Tuple[str, str]: def package_name(package_with_specs: str) -> str: """ - Get the name of a conda environment from its specification. + Get the package name from a match specification. """ return split_match_specs(package_with_specs)[0] +def version_info(package_with_specs: str) -> str: + """ + Get the version info from a match specification. + """ + return split_match_specs(package_with_specs)[1] + + class FullPath(Path): def __new__(cls, *args, **kwargs): return super().__new__(Path, Path(*args, **kwargs).expanduser().resolve()) diff --git a/condax/wrapper.py b/condax/wrapper.py index 693cca6..511b466 100644 --- a/condax/wrapper.py +++ b/condax/wrapper.py @@ -9,7 +9,7 @@ from condax.utils import FullPath -def read_env_name(script_path: Union[str, Path]) -> Optional[str]: +def read_prefix(script_path: Union[str, Path]) -> Optional[Path]: """ Read a condax bash script. @@ -45,7 +45,7 @@ def read_env_name(script_path: Union[str, Path]) -> Optional[str]: logging.warning(msg) return None - return namespace.prefix.name + return namespace.prefix def is_wrapper(exec_path: Union[str, Path]) -> bool: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b05447a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ["tests.fixtures"] diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..6129b37 --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1,4 @@ +from .conda import conda +from .envs import env_read_only, empty_env + +__all__ = ["conda", "env_read_only", "empty_env"] diff --git a/tests/fixtures/conda.py b/tests/fixtures/conda.py new file mode 100644 index 0000000..4529034 --- /dev/null +++ b/tests/fixtures/conda.py @@ -0,0 +1,8 @@ +import pytest + +from condax.conda.conda import Conda + + +@pytest.fixture(scope="session") +def conda() -> Conda: + return Conda(channels=("conda-forge",)) diff --git a/tests/fixtures/envs.py b/tests/fixtures/envs.py new file mode 100644 index 0000000..38de4d6 --- /dev/null +++ b/tests/fixtures/envs.py @@ -0,0 +1,31 @@ +from pathlib import Path +import shlex +import subprocess +import tempfile +from typing import Generator +import pytest + +from condax.conda.conda import Conda + + +@pytest.fixture(scope="session") +def env_read_only(conda: Conda) -> Generator[Path, None, None]: + """For efficiency, this env can be reused by all tests which won't modify it. + + This env is guaranteed to contain pip=22.2.2 and some version of python, which it depends on.""" + with tempfile.TemporaryDirectory() as tmp_path: + prefix = Path(tmp_path) / "env_read_only" + conda.create_env(prefix, "pip=22.2.2") + yield prefix + + +@pytest.fixture(scope="session") +def empty_env(conda: Conda) -> Generator[Path, None, None]: + """For efficiency, this env can be reused by all tests which won't modify it. + This env is guaranteed to contain no packages.""" + with tempfile.TemporaryDirectory() as tmp_path: + prefix = Path(tmp_path) / "empty_env" + subprocess.run( + shlex.split(f"{conda.exe} create --prefix {prefix} --yes --quiet") + ) + yield prefix diff --git a/tests/test_conda/test_env_info.py b/tests/test_conda/test_env_info.py new file mode 100644 index 0000000..749f14e --- /dev/null +++ b/tests/test_conda/test_env_info.py @@ -0,0 +1,50 @@ +from pathlib import Path +import pytest + +from condax.conda.env_info import find_envs, is_env, find_exes +from condax.conda.exceptions import NoPackageMetadataError + + +def test_is_env(env_read_only: Path): + assert is_env(env_read_only) + + +def test_is_env_empty_env(empty_env: Path): + assert is_env(empty_env) + + +def test_is_env_empty_dir(tmp_path: Path): + assert not is_env(tmp_path) + + +def test_is_env_file(tmp_path: Path): + (tmp_path / "foo.txt").touch() + assert not is_env(tmp_path) + + +def test_is_env_not_exists(tmp_path: Path): + assert not is_env(tmp_path / "foo/bar/biz/") + + +def test_find_exes(env_read_only: Path): + exes = {exe.name for exe in find_exes(env_read_only, "pip")} + assert "pip" in exes + assert "pip3" in exes + assert "python" not in exes + + with pytest.raises(NoPackageMetadataError): + assert not find_exes(env_read_only, "foo") + + +def test_find_exes_empty_env(empty_env: Path): + with pytest.raises(NoPackageMetadataError): + find_exes(empty_env, "pip") + + +def test_find_envs(env_read_only: Path, empty_env: Path): + for env in (env_read_only, empty_env): + assert env in find_envs(env.parent) + + +def test_find_envs_empty_dir(tmp_path: Path): + assert find_envs(tmp_path) == set() diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 82732b4..a6cda3b 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,5 +1,6 @@ import textwrap -from condax.condax.metadata import MainPackage, InjectedPackage, CondaxMetaData +from condax.condax.metadata.metadata import CondaxMetaData +from condax.condax.metadata.package import MainPackage, InjectedPackage def test_metadata_to_json():