From 60265853a571892d6670a445e7bc5f912673f4ab Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Fri, 5 Aug 2022 01:05:34 -0400 Subject: [PATCH 01/34] Use `micromamba run` instead of `conda run` --- condax/conda.py | 70 ++++++++++++++++++++++++++++++++++------------- condax/config.py | 9 ++++++ condax/core.py | 6 ++-- condax/migrate.py | 1 - condax/paths.py | 1 - condax/utils.py | 41 ++++++++++++++++++++++++++- condax/wrapper.py | 6 ++-- 7 files changed, 106 insertions(+), 28 deletions(-) diff --git a/condax/conda.py b/condax/conda.py index 410b26a..caffaed 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -1,13 +1,14 @@ +import io import json import logging import os -import platform import shlex import shutil import stat import subprocess from pathlib import Path import sys +import tarfile from typing import Iterable, List, Optional, Tuple, Union import requests @@ -17,33 +18,31 @@ import condax.utils as utils +def ensure_conda(): + execs = ["mamba", "conda"] + for conda_exec in execs: + conda_path = shutil.which(conda_exec) + if conda_path is not None: + return conda_path -def ensure_conda(mamba_ok=True): - execs = ["conda", "conda.exe"] - if mamba_ok: - execs.insert(0, "mamba") - execs.insert(0, "mamba.exe") + logging.info("No existing conda installation found. Installing the standalone") + return setup_conda() + +def ensure_micromamba(): + execs = ["micromamba"] for conda_exec in execs: conda_path = shutil.which(conda_exec) if conda_path is not None: return conda_path logging.info("No existing conda installation found. Installing the standalone") - return install_conda_exe() - + return setup_micromamba() -def install_conda_exe(): - conda_exe_prefix = "https://repo.anaconda.com/pkgs/misc/conda-execs" - if platform.system() == "Linux": - conda_exe_file = "conda-latest-linux-64.exe" - elif platform.system() == "Darwin": - conda_exe_file = "conda-latest-osx-64.exe" - else: - # TODO: Support windows here - raise ValueError(f"Unsupported platform: {platform.system()}") - resp = requests.get(f"{conda_exe_prefix}/{conda_exe_file}", allow_redirects=True) +def setup_conda(): + url = utils.get_conda_url() + resp = requests.get(url, allow_redirects=True) resp.raise_for_status() utils.mkdir(C.bin_dir()) target_filename = C.bin_dir() / "conda.exe" @@ -54,6 +53,39 @@ def install_conda_exe(): return target_filename +def setup_micromamba() -> Path: + utils.mkdir(C.bin_dir()) + umamba_exe = C.bin_dir() / "micromamba" + _download_extract_micromamba(umamba_exe) + return umamba_exe + + +def _download_extract_micromamba(umamba_dst: Path): + 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: + extracted = tar.extractfile("bin/micromamba") + 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") +# _subprocess_run( +# f'eval "$({umamba_path} shell hook --shell posix --prefix {C.mamba_root_prefix()})"', +# shell=True, +# ) + + def create_conda_environment(package: str, match_specs=""): conda_exe = ensure_conda() prefix = conda_env_prefix(package) @@ -250,7 +282,7 @@ def get_dependencies(package: str) -> List[str]: def _subprocess_run( - args: List[Union[str, Path]], **kwargs + args: Union[str, List[Union[str, Path]]], **kwargs ) -> subprocess.CompletedProcess: """ Run a subprocess and return the CompletedProcess object. diff --git a/condax/config.py b/condax/config.py index 305df50..31b0fc7 100644 --- a/condax/config.py +++ b/condax/config.py @@ -25,15 +25,24 @@ CONDA_ENVIRONMENT_FILE = to_path("~/.conda/environments.txt") +MAMBA_ROOT_PREFIX = to_path( + os.environ.get("CONDA_PREFIX", os.environ.get("MAMBA_ROOT_PREFIX", "~/micromamba")) +) + # https://stackoverflow.com/questions/6198372/most-pythonic-way-to-provide-global-configuration-variables-in-config-py class C: __conf = { + "mamba_root_prefix": MAMBA_ROOT_PREFIX, "prefix_dir": DEFAULT_PREFIX_DIR, "bin_dir": DEFAULT_BIN_DIR, "channels": DEFAULT_CHANNELS, } + @staticmethod + def mamba_root_prefix() -> Path: + return C.__conf["mamba_root_prefix"] + @staticmethod def prefix_dir() -> Path: return C.__conf["prefix_dir"] diff --git a/condax/core.py b/condax/core.py index 4c6e2ec..32fa2bd 100644 --- a/condax/core.py +++ b/condax/core.py @@ -17,21 +17,21 @@ def create_link(package: str, exe: Path, is_forcing: bool = False): + micromamba_exe = conda.ensure_micromamba() executable_name = exe.name # FIXME: Enforcing conda (not mamba) for `conda run` for now - conda_exe = conda.ensure_conda(mamba_ok=False) prefix = conda.conda_env_prefix(package) if os.name == "nt": script_lines = [ "@rem Entrypoint created by condax\n", - f"@call {utils.quote(conda_exe)} run --no-capture-output --prefix {utils.quote(prefix)} {utils.quote(exe)} %*\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'{conda_exe} run --no-capture-output --prefix {utils.quote(prefix)} {utils.quote(exe)} "$@"\n', + f'{utils.quote(micromamba_exe)} run --prefix {utils.quote(prefix)} {utils.quote(exe)} "$@"\n', ] script_path = _get_wrapper_path(executable_name) diff --git a/condax/migrate.py b/condax/migrate.py index 5348f8f..9835ff8 100644 --- a/condax/migrate.py +++ b/condax/migrate.py @@ -7,7 +7,6 @@ import shutil import condax.config as config -import condax.paths as paths import condax.utils as utils def from_old_version() -> None: diff --git a/condax/paths.py b/condax/paths.py index 57a8479..5e056af 100644 --- a/condax/paths.py +++ b/condax/paths.py @@ -5,7 +5,6 @@ import userpath - def add_path_to_environment(path: Union[Path, str]) -> None: path = str(path) diff --git a/condax/utils.py b/condax/utils.py index 392fc95..5fd42ff 100644 --- a/condax/utils.py +++ b/condax/utils.py @@ -1,7 +1,9 @@ import os from pathlib import Path +import platform from typing import List, Tuple, Union import re +import urllib.parse pat = re.compile(r"<=|>=|==|!=|<|>|=") @@ -62,7 +64,10 @@ def is_executable(path: Path) -> bool: return False if os.name == "nt": - pathexts = [ext.strip().lower() for ext in os.environ.get("PATHEXT", "").split(os.pathsep)] + pathexts = [ + ext.strip().lower() + for ext in os.environ.get("PATHEXT", "").split(os.pathsep) + ] ext = path.suffix.lower() return ext and (ext in pathexts) @@ -109,3 +114,37 @@ def unlink(path: Path): """ if path.exists(): path.unlink() + + +def get_micromamba_url() -> str: + """ + Get the URL of the latest micromamba release. + """ + base = "https://micro.mamba.pm/api/micromamba/" + if platform.system() == "Linux" and platform.machine() == "x86_64": + subdir = "linux-64/latest" + elif platform.system() == "Darwin": + subdir = "osx-64/latest" + else: + # TODO: Support windows here + raise ValueError(f"Unsupported platform: {platform.system()}") + + url = urllib.parse.urljoin(base, subdir) + return url + + +def get_conda_url() -> str: + """ + Get the URL of the latest micromamba release. + """ + base = "https://repo.anaconda.com/pkgs/misc/conda-execs" + if platform.system() == "Linux" and platform.machine() == "x86_64": + subdir = "conda-latest-linux-64.exe" + elif platform.system() == "Darwin": + subdir = "conda-latest-osx-64.exe" + else: + # TODO: Support windows here + raise ValueError(f"Unsupported platform: {platform.system()}") + + url = urllib.parse.urljoin(base, subdir) + return url diff --git a/condax/wrapper.py b/condax/wrapper.py index 2ea468b..4dce22f 100644 --- a/condax/wrapper.py +++ b/condax/wrapper.py @@ -7,7 +7,7 @@ from typing import Optional, List, Union from condax.utils import to_path -import condax.config as config + def read_env_name(script_path: Union[str, Path]) -> Optional[str]: """ @@ -84,7 +84,7 @@ class Parser(object): """ p = argparse.ArgumentParser() - p.add_argument("--prefix", type=pathlib.Path) + p.add_argument("-p", "--prefix", type=pathlib.Path) p.add_argument("--no-capture-output", action="store_true") p.add_argument("exec_path", type=pathlib.Path) p.add_argument("args") @@ -108,7 +108,7 @@ def _parse_line(cls, line: str) -> Optional[argparse.Namespace]: first_word = words[0] cmd = to_path(first_word).stem - if cmd not in ("conda", "mamba"): + if cmd not in ("conda", "mamba", "micromamba"): return None if words[1] != "run": From 0d76b29b538b0ccbb0fcbc77c43fa2dc4c426676 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Fri, 5 Aug 2022 14:26:56 -0400 Subject: [PATCH 02/34] Ensure micromamba in `condax repair` --- condax/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/condax/cli.py b/condax/cli.py index d36ac74..9006e87 100644 --- a/condax/cli.py +++ b/condax/cli.py @@ -5,6 +5,7 @@ import click import condax.config as config +import condax.conda as conda import condax.core as core import condax.paths as paths import condax.migrate as migrate @@ -266,8 +267,8 @@ def repair(is_migrating): if is_migrating: migrate.from_old_version() core.fix_links() + conda.ensure_micromamba() if __name__ == "__main__": cli() - From 04ac843d21a658b6770e838068ff753e00d25d76 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Fri, 5 Aug 2022 15:46:17 -0400 Subject: [PATCH 03/34] Add option to hide exit code of wrappers Environment variable CONDAX_HIDE_EXITCODE=1 will trigger this option. --- condax/core.py | 3 +++ condax/utils.py | 19 +++++++++++++++++++ tests/_test_env_vars.py | 15 +++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/condax/core.py b/condax/core.py index 32fa2bd..cbf2640 100644 --- a/condax/core.py +++ b/condax/core.py @@ -33,6 +33,9 @@ def create_link(package: str, exe: Path, is_forcing: bool = False): "# 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: diff --git a/condax/utils.py b/condax/utils.py index 5fd42ff..d727823 100644 --- a/condax/utils.py +++ b/condax/utils.py @@ -148,3 +148,22 @@ def get_conda_url() -> str: url = urllib.parse.urljoin(base, subdir) return url + + +def to_bool(value: Union[str, bool]) -> bool: + if isinstance(value, bool): + return value + + if not value: + return False + + if value.lower() == "false": + return False + + try: + if int(value) > 0: + return True + except: + pass + + return False diff --git a/tests/_test_env_vars.py b/tests/_test_env_vars.py index 88a3312..b991072 100644 --- a/tests/_test_env_vars.py +++ b/tests/_test_env_vars.py @@ -15,3 +15,18 @@ def test_loading_env(mock_env_1): assert C.prefix_dir() == Path("/a/s/df/ghgg") assert C.bin_dir() == Path.home() / ".hhh/kkk" assert C.channels() == ["fastchan", "keke", "baba"] + + + +@pytest.fixture +def mock_env_2(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("CONDAX_HIDE_EXITCODE", "1") + + +# Test if C loads to_boto_boolol variables +def test_loading_env_others(mock_env_2): + import os + from condax.utils import to_bool + + print(f"os.environ.get('CONDAX_HIDE_EXITCODE') = {os.environ.get('CONDAX_HIDE_EXITCODE')}") + assert to_bool(os.environ.get("CONDAX_HIDE_EXITCODE", False)) From b1da4538320f6f7f3e25b04856bb69f6961fbdfe Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Fri, 5 Aug 2022 15:47:56 -0400 Subject: [PATCH 04/34] Improve handling of config values slightly --- condax/config.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/condax/config.py b/condax/config.py index 31b0fc7..056eb25 100644 --- a/condax/config.py +++ b/condax/config.py @@ -1,5 +1,6 @@ import os from pathlib import Path +import shutil from typing import List, Optional, Union from condax.utils import to_path @@ -21,12 +22,14 @@ DEFAULT_PREFIX_DIR = to_path(os.environ.get("CONDAX_PREFIX_DIR", _default_prefix_dir)) DEFAULT_BIN_DIR = to_path(os.environ.get("CONDAX_BIN_DIR", "~/.local/bin")) -DEFAULT_CHANNELS = os.environ.get("CONDAX_CHANNELS", "conda-forge defaults").split() +DEFAULT_CHANNELS = os.environ.get("CONDAX_CHANNELS", "conda-forge defaults").strip().split() CONDA_ENVIRONMENT_FILE = to_path("~/.conda/environments.txt") -MAMBA_ROOT_PREFIX = to_path( - os.environ.get("CONDA_PREFIX", os.environ.get("MAMBA_ROOT_PREFIX", "~/micromamba")) +conda_path = shutil.which("conda") +MAMBA_ROOT_PREFIX = ( + to_path(conda_path).parent.parent if conda_path is not None + else to_path(os.environ.get("MAMBA_ROOT_PREFIX", "~/micromamba")) ) @@ -56,7 +59,7 @@ def channels() -> List[str]: return C.__conf["channels"] @staticmethod - def _set(name: str, value): + def _set(name: str, value) -> None: if name in C.__conf: C.__conf[name] = value else: From c4d95b4cf5d52937ebe198fcde932de3d8d1f4ed Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Fri, 5 Aug 2022 22:13:08 -0400 Subject: [PATCH 05/34] Update README --- README.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 89f9745..5d2b307 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,35 @@ ## What is this? -`condax` is a package manager exclusively for installing commands from conda distribution channels. `condax` frees you from activating and deactivating conda environments while keeping programs in separate environments. +`condax` is a package manager exclusively for installing commands. `condax`, built on top of `conda` variants, frees you from activating and deactivating conda environments while keeping programs in separate environments. `condax` was originally developed by [Marius van Niekerk ](https://github.com/mariusvniekerk/condax). I'm adding features here after the version 0.0.5. ## Examples -Here is how to install `node`, and execute it. +Here is how you can install `node`. You can just execute it without activating its associated environment. `node` still lives in its own environment so there is no worry about dependencies. ```shell condax install nodejs + +# Then node is ready to run. node --version ``` -This operation is equivalent to the following; `condax` just keeps track of conda environments for commands. +There is no magic about the operation, and you can even do the same without having `condax`; the trick is to use `conda run`. (Or `micromamba run`.) ```shell -conda env create -c conda-forge -n nodejs nodejs -conda run -n nodejs 'node --version' +# Create an environent `nodejs`. +mamba env create -c conda-forge -n nodejs nodejs + +# Run `node --verion` within the nodejs environment. +micromamba run -n nodejs node --version ``` +`condax` just creates such scripts above in your path, by default in `~/.local/bin`, and manages them together with conda environments. I guess it's quite convenient when you deal with lots of commands from conda channels. (I'm looking at you, [`bioconda`](https://bioconda.github.io/) users 🤗.) + + ## How to install `condax` Use `pip` or `pipx` to install directly from this repository. @@ -37,13 +45,14 @@ $ pip install git+https://github.com/yamaton/condax ## Changes since the original [`condax 0.0.5`](https://github.com/mariusvniekerk/condax/) - Supports `condax list` to display installed executables. -- Uses `mamba` internally if available. +- Uses `mamba` to manipulate environments if available. +- Uses `micromamba` to run commands. - Supports installing a package with version constraints, like `condax install ipython=8.3`. - See [package match specifications](https://docs.conda.io/projects/conda/en/latest/user-guide/concepts/pkg-specs.html#package-match-specifications) for the detail. - Supports injecting/uninjecting packages onto/from an existing environment. - Supports configuring via environment variables, or by passing a config file. -- Supports exporting and importing conda environments (only the ones managed by `condax`). -- Internally, this fork creates scripts calling `conda run` instead of creating symbolic links. +- Supports exporting and importing condax environments for reproducibility. +- Internally, this fork creates scripts calling `micromamba run` instead of creating symbolic links. - ➡️ Solves [the issue](https://github.com/mariusvniekerk/condax/issues/13) with non-Python packages - Follows [XDG Base Directory Specification](https://stackoverflow.com/questions/1024114/location-of-ini-config-files-in-linux-unix) for directory and file locations: - Environments are created in `~/.local/share/condax/envs`. (Previously `~/.condax`.) @@ -53,7 +62,6 @@ $ pip install git+https://github.com/yamaton/condax ## Known issues -- ``ERROR conda.cli.main_run:execute(49): `conda run XXX` failed (see above for error)`` appears whenever a command returns a nonzero exit code. - Support of Windows platform is imperfect, though it should work fine on WSL. From 399fa83977b3e7bbf82bd576de40c3f9911e0d52 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Fri, 5 Aug 2022 22:55:42 -0400 Subject: [PATCH 06/34] Hotfix conda download url base --- condax/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/condax/utils.py b/condax/utils.py index d727823..4dbb817 100644 --- a/condax/utils.py +++ b/condax/utils.py @@ -137,7 +137,7 @@ def get_conda_url() -> str: """ Get the URL of the latest micromamba release. """ - base = "https://repo.anaconda.com/pkgs/misc/conda-execs" + base = "https://repo.anaconda.com/pkgs/misc/conda-execs/" if platform.system() == "Linux" and platform.machine() == "x86_64": subdir = "conda-latest-linux-64.exe" elif platform.system() == "Darwin": From 3b90207a16e26c9bfc3a905eb625bd8d89328c55 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Sat, 6 Aug 2022 00:57:18 -0400 Subject: [PATCH 07/34] Add `condax --version` --- condax/cli.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/condax/cli.py b/condax/cli.py index 9006e87..353935d 100644 --- a/condax/cli.py +++ b/condax/cli.py @@ -9,6 +9,7 @@ import condax.core as core import condax.paths as paths import condax.migrate as migrate +from condax import __version__ option_config = click.option( @@ -57,6 +58,10 @@ Links to apps are placed in {config.DEFAULT_BIN_DIR} """ ) +@click.version_option( + __version__, + message="%(prog)s %(version)s", +) @option_config def cli(config_file: Optional[Path]): if config_file: From 8a59a30fa7ae5266f27aff21b570e2ee5598f375 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Sat, 6 Aug 2022 01:09:32 -0400 Subject: [PATCH 08/34] Bump to 0.1.0 --- condax/__init__.py | 2 +- pyproject.toml | 2 +- setup.py | 11 +++-------- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/condax/__init__.py b/condax/__init__.py index 034f46c..3dc1f76 100644 --- a/condax/__init__.py +++ b/condax/__init__.py @@ -1 +1 @@ -__version__ = "0.0.6" +__version__ = "0.1.0" diff --git a/pyproject.toml b/pyproject.toml index 3aa7fcf..0eff7f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [metadata] name = 'condax' -version = '0.0.1' +version = '0.1.0' description = 'Install and run applications packaged with conda in isolated environments' author = 'Marius van Niekerk' author_email = 'marius.v.niekerk@gmail.com' diff --git a/setup.py b/setup.py index 537d18d..4b79e92 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ version = line.strip().split("=")[1].strip(" '\"") break else: - version = "0.0.1" + version = "0.1.0" with open("README.md", "r", encoding="utf-8") as f: readme = f.read() @@ -22,7 +22,7 @@ setup( name="condax", - version="0.0.6", + version="0.1.0", description="Install and run applications packaged with conda in isolated environments", long_description=readme, long_description_content_type="text/markdown", @@ -34,15 +34,10 @@ license="MIT", classifiers=[ "Development Status :: 4 - Beta", - "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Python :: 3", ], python_requires=">=3.7", install_requires=REQUIRES, From 0d6b42af81f6155ba6975e681ccd04f2998ee80d Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Mon, 8 Aug 2022 09:50:00 -0400 Subject: [PATCH 09/34] Show message to `condax ensure-path` even when it's already good --- condax/paths.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/condax/paths.py b/condax/paths.py index 5e056af..d667736 100644 --- a/condax/paths.py +++ b/condax/paths.py @@ -13,6 +13,10 @@ def add_path_to_environment(path: Union[Path, str]) -> None: " to take effect." ) if userpath.in_current_path(path): + print( + f"{path} has already been added to PATH.", + file=sys.stderr, + ) return if userpath.need_shell_restart(path): From 45f8500bad1169be88cc4a05eaae0c309260d12b Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Sat, 6 Aug 2022 11:26:47 -0400 Subject: [PATCH 10/34] Update README --- README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5d2b307..b82c611 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ `condax` is a package manager exclusively for installing commands. `condax`, built on top of `conda` variants, frees you from activating and deactivating conda environments while keeping programs in separate environments. -`condax` was originally developed by [Marius van Niekerk ](https://github.com/mariusvniekerk/condax). I'm adding features here after the version 0.0.5. +`condax` was originally developed by [Marius van Niekerk ](https://github.com/mariusvniekerk/condax). More features have been added here after the version 0.0.5. ## Examples @@ -18,7 +18,7 @@ condax install nodejs node --version ``` -There is no magic about the operation, and you can even do the same without having `condax`; the trick is to use `conda run`. (Or `micromamba run`.) +There is no magic about the operation, and you can even do the same without having `condax`; the trick is to use `conda run` (or `micromamba run`) which is effectively sandwitching a command with `conda activate` and `conda deactivate`. ```shell # Create an environent `nodejs`. @@ -28,19 +28,25 @@ mamba env create -c conda-forge -n nodejs nodejs micromamba run -n nodejs node --version ``` -`condax` just creates such scripts above in your path, by default in `~/.local/bin`, and manages them together with conda environments. I guess it's quite convenient when you deal with lots of commands from conda channels. (I'm looking at you, [`bioconda`](https://bioconda.github.io/) users 🤗.) +`condax` just creates scripts like this, by default in `~/.local/bin`, and manages them together with conda environments. It's simple, yet quite convenient when you deal with many commands from conda channels. (I'm looking at you, [`bioconda`](https://bioconda.github.io/) users 🤗.) -## How to install `condax` +## How to install and setup `condax` Use `pip` or `pipx` to install directly from this repository. -``` +```shell $ pip install git+https://github.com/yamaton/condax # Or, pipx install git+https://github.com/yamaton/condax if pipx is available. ``` +Then add `~/.local/bin` to your `$PATH` if not done already. This command will modify shell configuration. You may skip this if you manage `$PATH` by yourself. + +```shell +condax ensure-path +``` + ## Changes since the original [`condax 0.0.5`](https://github.com/mariusvniekerk/condax/) @@ -69,6 +75,6 @@ $ pip install git+https://github.com/yamaton/condax This forked version has changed the locations of the environments and the config file. If you have already installed packages with the original `condax`, please run the following, just once, to sort out. -```bash +```shell condax repair --migrate ``` From a8c6ca5accb54c503a767298725561c375b9dbe7 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Mon, 8 Aug 2022 21:52:50 -0400 Subject: [PATCH 11/34] Add filtering when scanning over conda envs --- condax/core.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/condax/core.py b/condax/core.py index cbf2640..ed88d41 100644 --- a/condax/core.py +++ b/condax/core.py @@ -402,7 +402,10 @@ def _get_all_envs() -> List[str]: Get all conda envs """ utils.mkdir(C.prefix_dir()) - return sorted([pkg_dir.name for pkg_dir in C.prefix_dir().iterdir()]) + return sorted([ + pkg_dir.name for pkg_dir in C.prefix_dir().iterdir() + if (pkg_dir / "conda-meta" / "history").exists() + ]) def _get_injected_packages(env_name: str) -> List[str]: From ce6e95008b5a407a39f22f0ce4d70f16bc4f80c2 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Mon, 8 Aug 2022 21:41:34 -0400 Subject: [PATCH 12/34] Improve an error message --- condax/conda.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/condax/conda.py b/condax/conda.py index caffaed..42ff6cd 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -225,7 +225,7 @@ def is_good(p: Union[str, Path]) -> bool: ] break else: - raise ValueError("Could not determine package files") + raise ValueError(f"Could not determine package files: {package} - {injected_package}") executables = set() for fn in potential_executables: From 2e7a0b69fb114f173bd7a1247770908f422214a5 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Mon, 8 Aug 2022 21:33:01 -0400 Subject: [PATCH 13/34] Fix `condax update` to properly update links and metadata file --- condax/core.py | 54 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/condax/core.py b/condax/core.py index ed88d41..0d6bbf3 100644 --- a/condax/core.py +++ b/condax/core.py @@ -323,30 +323,52 @@ def _print_condax_dirs() -> None: print() -def update_package(package: str, is_forcing: bool = False): +def update_package(env: str, is_forcing: bool = False): - exit_if_not_installed(package) + exit_if_not_installed(env) try: - executables_already_linked = set(conda.determine_executables_from_env(package)) - conda.update_conda_env(package) - executables_linked_in_updated = set( - conda.determine_executables_from_env(package) - ) - - to_create = executables_linked_in_updated - executables_already_linked - to_delete = executables_already_linked - executables_linked_in_updated + 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(env) + 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) + } + + 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] - create_links(package, to_create, is_forcing) - remove_links(package, to_delete_apps) - print(f"{package} update successfully") + # 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) + + print(f"{env} update successfully") except subprocess.CalledProcessError: - print(f"Failed to update `{package}`", file=sys.stderr) + print(f"Failed to update `{env}`", file=sys.stderr) print(f"Recreating the environment...", file=sys.stderr) - remove_package(package) - install_package(package, is_forcing=is_forcing) + remove_package(env) + install_package(env, is_forcing=is_forcing) + + # Update metadata file + _create_metadata(env) + for pkg in _get_injected_packages(env): + _inject_to_metadata(env, pkg) def _create_metadata(package: str): From 262c4066d43ea704f65c53ffb07ad7549b41d4e6 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Mon, 8 Aug 2022 22:13:02 -0400 Subject: [PATCH 14/34] Say no update when that's the case --- condax/core.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/condax/core.py b/condax/core.py index 0d6bbf3..d2169a8 100644 --- a/condax/core.py +++ b/condax/core.py @@ -323,7 +323,7 @@ def _print_condax_dirs() -> None: print() -def update_package(env: str, is_forcing: bool = False): +def update_package(env: str, is_forcing: bool = False) -> None: exit_if_not_installed(env) try: @@ -339,6 +339,12 @@ def update_package(env: str, is_forcing: bool = False): 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 + ): + print(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] From 6bfd39433b061b0c560d597df9d970339cb32b66 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Mon, 8 Aug 2022 22:18:59 -0400 Subject: [PATCH 15/34] Lint with black --- condax/cli.py | 16 +++------ condax/conda.py | 4 ++- condax/config.py | 15 +++++--- condax/core.py | 69 ++++++++++++++++++++----------------- condax/metadata.py | 1 + condax/migrate.py | 24 ++++++------- condax/wrapper.py | 3 +- tests/_test_env_vars.py | 6 ++-- tests/test_condax.py | 36 +++++++++++++++---- tests/test_condax_more.py | 7 ++-- tests/test_condax_repair.py | 9 ++--- tests/test_metadata.py | 6 ++-- 12 files changed, 116 insertions(+), 80 deletions(-) diff --git a/condax/cli.py b/condax/cli.py index 353935d..c56024a 100644 --- a/condax/cli.py +++ b/condax/cli.py @@ -86,7 +86,7 @@ def install( packages: List[str], config_file: Optional[Path], channels: List[str], - is_forcing: bool + is_forcing: bool, ): if config_file: config.set_via_file(config_file) @@ -140,10 +140,7 @@ def uninstall(packages: List[str]): help="Show packages injected into the main app's environment.", ) def list(short: bool, include_injected: bool): - core.list_all_packages( - short=short, - include_injected=include_injected - ) + core.list_all_packages(short=short, include_injected=include_injected) @cli.command( @@ -166,16 +163,13 @@ def inject( envname: str, channels: List[str], is_forcing: bool, - include_apps: bool + include_apps: bool, ): if channels: config.set_via_value(channels=channels) core.inject_package_to( - envname, - packages, - is_forcing=is_forcing, - include_apps=include_apps + envname, packages, is_forcing=is_forcing, include_apps=include_apps ) @@ -242,7 +236,7 @@ def export(dir: str): "import", help=""" [experimental] Import condax environments. - """ + """, ) @option_is_forcing @click.argument( diff --git a/condax/conda.py b/condax/conda.py index 42ff6cd..b67cb9b 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -225,7 +225,9 @@ def is_good(p: Union[str, Path]) -> bool: ] break else: - raise ValueError(f"Could not determine package files: {package} - {injected_package}") + raise ValueError( + f"Could not determine package files: {package} - {injected_package}" + ) executables = set() for fn in potential_executables: diff --git a/condax/config.py b/condax/config.py index 056eb25..c239e4e 100644 --- a/condax/config.py +++ b/condax/config.py @@ -11,24 +11,31 @@ _xdg_config_home = os.environ.get("XDG_CONFIG_HOME", "~/.config") _default_config_unix = os.path.join(_xdg_config_home, "condax", _config_filename) -_default_config_windows = os.path.join(_localappdata_dir, "condax", "condax", _config_filename) +_default_config_windows = os.path.join( + _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)) _xdg_data_home = os.environ.get("XDG_DATA_HOME", "~/.local/share") _default_prefix_dir_unix = os.path.join(_xdg_data_home, "condax", "envs") _default_prefix_dir_win = os.path.join(_localappdata_dir, "condax", "condax", "envs") -_default_prefix_dir = _default_prefix_dir_win if os.name == "nt" else _default_prefix_dir_unix +_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_BIN_DIR = to_path(os.environ.get("CONDAX_BIN_DIR", "~/.local/bin")) -DEFAULT_CHANNELS = os.environ.get("CONDAX_CHANNELS", "conda-forge defaults").strip().split() +DEFAULT_CHANNELS = ( + os.environ.get("CONDAX_CHANNELS", "conda-forge defaults").strip().split() +) CONDA_ENVIRONMENT_FILE = to_path("~/.conda/environments.txt") conda_path = shutil.which("conda") MAMBA_ROOT_PREFIX = ( - to_path(conda_path).parent.parent if conda_path is not None + to_path(conda_path).parent.parent + if conda_path is not None else to_path(os.environ.get("MAMBA_ROOT_PREFIX", "~/micromamba")) ) diff --git a/condax/core.py b/condax/core.py index d2169a8..35615b0 100644 --- a/condax/core.py +++ b/condax/core.py @@ -132,7 +132,6 @@ def inject_package_to( # package match specifications # https://docs.conda.io/projects/conda/en/latest/user-guide/concepts/pkg-specs.html#package-match-specifications - conda.inject_to_conda_env( injected_specs, env_name, @@ -181,14 +180,14 @@ def uninject_package_from(env_name: str, packages_to_uninject: List[str]): packages_to_uninject = sorted(found) conda.uninject_from_conda_env(packages_to_uninject, env_name) - injected_app_names = [app for pkg in packages_to_uninject for app in _get_injected_apps(env_name, pkg)] + injected_app_names = [ + app for pkg in packages_to_uninject for app in _get_injected_apps(env_name, pkg) + ] remove_links(env_name, injected_app_names) _uninject_from_metadata(env_name, packages_to_uninject) pkgs_str = " and ".join(packages_to_uninject) - print( - f"`{pkgs_str}` has been uninjected from `{env_name}`", file=sys.stderr - ) + print(f"`{pkgs_str}` has been uninjected from `{env_name}`", file=sys.stderr) def exit_if_not_installed(package: str): @@ -354,12 +353,16 @@ def update_package(env: str, is_forcing: bool = False) -> None: 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] + 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] + to_create = ( + injected_apps_after_update[pkg] - injected_apps_before_update[pkg] + ) create_links(env, to_create, is_forcing) print(f"{env} update successfully") @@ -373,7 +376,7 @@ def update_package(env: str, is_forcing: bool = False) -> None: # Update metadata file _create_metadata(env) - for pkg in _get_injected_packages(env): + for pkg in _get_injected_packages(env): _inject_to_metadata(env, pkg) @@ -399,18 +402,17 @@ def _load_metadata(env: str) -> metadata.CondaxMetaData: return meta -def _inject_to_metadata(env: str, packages_to_inject: Iterable[str], include_apps: bool = False): +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) - ] + 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.uninject(pkg) # overwrites if necessary meta.inject(pkg_to_inject) meta.save() @@ -430,10 +432,13 @@ def _get_all_envs() -> List[str]: Get all conda envs """ utils.mkdir(C.prefix_dir()) - return sorted([ - pkg_dir.name for pkg_dir in C.prefix_dir().iterdir() - if (pkg_dir / "conda-meta" / "history").exists() - ]) + return sorted( + [ + pkg_dir.name + for pkg_dir in C.prefix_dir().iterdir() + if (pkg_dir / "conda-meta" / "history").exists() + ] + ) def _get_injected_packages(env_name: str) -> List[str]: @@ -451,7 +456,12 @@ def _get_injected_apps(env_name: str, injected_name: str) -> List[str]: [NOTE] Get a non-empty list only if "include_apps" is True in the metadata. """ meta = _load_metadata(env_name) - result = [app for p in meta.injected_packages if p.name == injected_name and p.include_apps for app in p.apps] + result = [ + app + for p in meta.injected_packages + if p.name == injected_name and p.include_apps + for app in p.apps + ] return result @@ -476,7 +486,9 @@ def _get_apps(env_name: str) -> List[str]: Return a list of all apps """ meta = _load_metadata(env_name) - return meta.main_package.apps + [app for p in meta.injected_packages if p.include_apps for app in p.apps] + return meta.main_package.apps + [ + app for p in meta.injected_packages if p.include_apps for app in p.apps + ] def _get_wrapper_path(cmd_name: str) -> Path: @@ -487,8 +499,7 @@ def _get_wrapper_path(cmd_name: str) -> Path: def export_all_environments(out_dir: str) -> None: - """Export all environments to a directory. - """ + """Export all environments to a directory.""" p = Path(out_dir) p.mkdir(parents=True, exist_ok=True) print("Started exporting all environments to", p) @@ -502,16 +513,14 @@ def export_all_environments(out_dir: str) -> None: def _copy_metadata(env: str, p: Path): - """Copy the condax_metadata.json file to the exported directory. - """ + """Copy the condax_metadata.json file to the exported directory.""" _from = metadata.CondaxMetaData.get_path(env) _to = p / f"{env}.json" shutil.copyfile(_from, _to, follow_symlinks=True) def _overwrite_metadata(envfile: Path): - """Copy the condax_metadata.json file to the exported directory. - """ + """Copy the condax_metadata.json file to the exported directory.""" env = envfile.stem _from = envfile _to = metadata.CondaxMetaData.get_path(env) @@ -521,8 +530,7 @@ def _overwrite_metadata(envfile: Path): def import_environments(in_dir: str, is_forcing: bool) -> None: - """Import all environments from a directory. - """ + """Import all environments from a directory.""" p = Path(in_dir) print("Started importing environments in", p) for envfile in p.glob("*.yml"): @@ -602,8 +610,7 @@ def _prune_links(): def _add_to_conda_env_list() -> None: - """Add condax environment prefixes to ~/.conda/environments.txt if not already there. - """ + """Add condax environment prefixes to ~/.conda/environments.txt if not already there.""" envs = _get_all_envs() prefixe_str_set = {str(conda.conda_env_prefix(env)) for env in envs} lines = set() diff --git a/condax/metadata.py b/condax/metadata.py index e413248..b10d778 100644 --- a/condax/metadata.py +++ b/condax/metadata.py @@ -4,6 +4,7 @@ from condax.config import C + class _PackageBase(object): def __init__(self, name: str, apps: List[str], include_apps: bool): self.name = name diff --git a/condax/migrate.py b/condax/migrate.py index 9835ff8..692ba7b 100644 --- a/condax/migrate.py +++ b/condax/migrate.py @@ -9,33 +9,32 @@ import condax.config as config import condax.utils as utils + def from_old_version() -> None: - """Migrate condax settigns from the old version to the current forked version. - """ + """Migrate condax settigns from the old version to the current forked version.""" move_condax_config() move_condax_envs() repair_conda_environment_file() def move_condax_config() -> None: - """Move the condax config file from ~/.condaxrc to ~/.config/condax/config.yaml - """ + """Move the condax config file from ~/.condaxrc to ~/.config/condax/config.yaml""" from_ = pathlib.Path.home() / ".condaxrc" if from_.exists(): to_ = config.DEFAULT_CONFIG if to_.exists(): - logging.info(f"A file already exists at {to_}; skipping to handle the old config file at {from_}") + logging.info( + f"A file already exists at {to_}; skipping to handle the old config file at {from_}" + ) return to_.parent.mkdir(exist_ok=True, parents=True) shutil.move(from_, to_) print(f" [migrate] Moved {from_} to {to_}") - def move_condax_envs() -> None: - """Move the condax envs directory from ~/.condax/ to ~/.config/condax/envs/ - """ + """Move the condax envs directory from ~/.condax/ to ~/.config/condax/envs/""" from_ = pathlib.Path.home() / ".condax" if from_.exists() and from_.is_dir(): to_ = config.C.prefix_dir() @@ -48,12 +47,13 @@ def move_condax_envs() -> None: shutil.move(subdir, dst) print(f" [migrate] Moved {subdir} to {dst}") else: - print(f" [migrate] Skipping {subdir} as it already exists at {dst}") + print( + f" [migrate] Skipping {subdir} as it already exists at {dst}" + ) def repair_conda_environment_file() -> None: - """Edit conda's environment file at ~/.conda/environments.txt - """ + """Edit conda's environment file at ~/.conda/environments.txt""" src = pathlib.Path.home() / ".conda" / "environments.txt" if src.exists() and src.is_file(): prefix_from_str = str(pathlib.Path.home() / ".condax") @@ -64,7 +64,7 @@ def repair_conda_environment_file() -> None: content.replace(prefix_from_str, prefix_to_str) backup = src.with_suffix(".bak") - utils.unlink(backup) # overwrite backup file if exists + utils.unlink(backup) # overwrite backup file if exists shutil.move(src, backup) print(f" [migrate] Fixed paths at {src}") diff --git a/condax/wrapper.py b/condax/wrapper.py index 4dce22f..6c0f1f5 100644 --- a/condax/wrapper.py +++ b/condax/wrapper.py @@ -33,7 +33,6 @@ def read_env_name(script_path: Union[str, Path]) -> Optional[str]: logging.warning(f"Failed in file opening and parsing: {path}") return None - if namespace is None: logging.warning(f"Failed to parse: `{script_name}`: {path}") return None @@ -64,7 +63,7 @@ def is_wrapper(exec_path: Union[str, Path]) -> bool: return False try: - with open(path, "r", encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: content = f.read() except UnicodeDecodeError: return False diff --git a/tests/_test_env_vars.py b/tests/_test_env_vars.py index b991072..1b83489 100644 --- a/tests/_test_env_vars.py +++ b/tests/_test_env_vars.py @@ -12,12 +12,12 @@ def mock_env_1(monkeypatch: pytest.MonkeyPatch): # Test if C loads environment variables def test_loading_env(mock_env_1): from condax.config import C + assert C.prefix_dir() == Path("/a/s/df/ghgg") assert C.bin_dir() == Path.home() / ".hhh/kkk" assert C.channels() == ["fastchan", "keke", "baba"] - @pytest.fixture def mock_env_2(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("CONDAX_HIDE_EXITCODE", "1") @@ -28,5 +28,7 @@ def test_loading_env_others(mock_env_2): import os from condax.utils import to_bool - print(f"os.environ.get('CONDAX_HIDE_EXITCODE') = {os.environ.get('CONDAX_HIDE_EXITCODE')}") + print( + f"os.environ.get('CONDAX_HIDE_EXITCODE') = {os.environ.get('CONDAX_HIDE_EXITCODE')}" + ) assert to_bool(os.environ.get("CONDAX_HIDE_EXITCODE", False)) diff --git a/tests/test_condax.py b/tests/test_condax.py index 75c5c78..081d386 100644 --- a/tests/test_condax.py +++ b/tests/test_condax.py @@ -113,13 +113,20 @@ def test_inject_then_uninject(): base = "ipython" injected = "numpy" - injected_version = "1.22.4" # older then the latest one + injected_version = "1.22.4" # older then the latest one injected_spec = f"{injected}={injected_version}" python_version = "3.9" exe_path = bin_dir / base env_path = prefix_dir / base - injected_pkg_lib_path = prefix_dir / base / "lib" / f"python{python_version}" / "site-packages" / injected + injected_pkg_lib_path = ( + prefix_dir + / base + / "lib" + / f"python{python_version}" + / "site-packages" + / injected + ) # None of the executable, environment, and injected package should exist assert not exe_path.exists() @@ -133,7 +140,9 @@ def test_inject_then_uninject(): assert not injected_pkg_lib_path.exists() # Make sure ipython throws error when importing numpy - res = subprocess.run(f"{exe_path} -c 'import numpy'", shell=True, capture_output=True) + res = subprocess.run( + f"{exe_path} -c 'import numpy'", shell=True, capture_output=True + ) assert res.returncode == 1 assert "ModuleNotFoundError" in res.stdout.decode() @@ -145,7 +154,11 @@ def test_inject_then_uninject(): assert injected_pkg_lib_path.exists() and injected_pkg_lib_path.is_dir() # ipython should be able to import numpy, and display the correct numpy version - res = subprocess.run(f"{exe_path} -c 'import numpy; print(numpy.__version__)'", shell=True, capture_output=True) + res = subprocess.run( + f"{exe_path} -c 'import numpy; print(numpy.__version__)'", + shell=True, + capture_output=True, + ) assert res.returncode == 0 assert res.stdout and (injected_version in res.stdout.decode()) @@ -156,7 +169,9 @@ def test_inject_then_uninject(): assert not injected_pkg_lib_path.exists() # Make sure ipython throws error when importing numpy ... again - res = subprocess.run(f"{exe_path} -c 'import numpy'", shell=True, capture_output=True) + res = subprocess.run( + f"{exe_path} -c 'import numpy'", shell=True, capture_output=True + ) assert res.returncode == 1 assert "ModuleNotFoundError" in res.stdout.decode() @@ -172,7 +187,12 @@ def test_inject_with_include_apps(): """ Test injecting a library to an existing environment with executable, then uninject it. """ - from condax.core import install_package, inject_package_to, uninject_package_from, remove_package + from condax.core import ( + install_package, + inject_package_to, + uninject_package_from, + remove_package, + ) import condax.config as config from condax.utils import to_path @@ -237,7 +257,9 @@ def test_inject_with_include_apps(): assert exe_xsv.exists() and exe_xsv.is_file() # Check ripgrep is gone from conda-meta - injected_ripgrep_conda_meta = next(conda_meta_dir.glob(f"{injected_rg_name}-{injected_rg_version}-*.json"), None) + injected_ripgrep_conda_meta = next( + conda_meta_dir.glob(f"{injected_rg_name}-{injected_rg_version}-*.json"), None + ) assert injected_ripgrep_conda_meta is None # Check xsv still exists in conda-meta diff --git a/tests/test_condax_more.py b/tests/test_condax_more.py index 1f7e069..1f034c6 100644 --- a/tests/test_condax_more.py +++ b/tests/test_condax_more.py @@ -8,8 +8,11 @@ def test_export_import(): see if environments are recovered by importing files. """ from condax.core import ( - install_package, inject_package_to, remove_package, - export_all_environments, import_environments + install_package, + inject_package_to, + remove_package, + export_all_environments, + import_environments, ) import condax.config as config from condax.utils import to_path diff --git a/tests/test_condax_repair.py b/tests/test_condax_repair.py index 1387be7..e6a058e 100644 --- a/tests/test_condax_repair.py +++ b/tests/test_condax_repair.py @@ -6,10 +6,7 @@ def test_fix_links(): """ Test if fix_links() recovers the links correctly. """ - from condax.core import ( - install_package, inject_package_to, - fix_links - ) + from condax.core import install_package, inject_package_to, fix_links import condax.config as config from condax.utils import to_path @@ -97,7 +94,6 @@ def test_fix_links(): bin_fp.cleanup() - def test_fix_links_without_metadata(): """ When metadata file (condax_metadata.json) is absent, @@ -105,7 +101,8 @@ def test_fix_links_without_metadata(): but not the injected packages. """ from condax.core import ( - install_package, inject_package_to, + install_package, + inject_package_to, fix_links, ) import condax.config as config diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 3a34950..69e9119 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -7,7 +7,8 @@ def test_metadata_to_json(): injected = [InjectedPackage("ripgrep", apps=["rg"], include_apps=False)] metadata = CondaxMetaData(main, injected) - expected = textwrap.dedent(""" + expected = textwrap.dedent( + """ { "injected_packages": [ { @@ -26,6 +27,7 @@ def test_metadata_to_json(): "name": "jq" } } - """).strip() + """ + ).strip() assert expected == metadata.to_json() From 38e71e997c865a14783e93b479b4c3d5abc99f96 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Mon, 8 Aug 2022 22:49:17 -0400 Subject: [PATCH 16/34] Add `condax update --update-specs` --- condax/cli.py | 11 +++++++---- condax/conda.py | 24 ++++++++++++++---------- condax/core.py | 15 ++++++++------- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/condax/cli.py b/condax/cli.py index c56024a..f6c59d2 100644 --- a/condax/cli.py +++ b/condax/cli.py @@ -204,16 +204,19 @@ def ensure_path(config_file: Optional[Path]): """ ) @click.option( - "--all", is_flag=True, help="Set to update all packages installed by condax" + "--all", is_flag=True, help="Set to update all packages installed by condax." +) +@click.option( + "--update-specs", is_flag=True, help="Update based on provided specifications." ) @click.argument("packages", required=False, nargs=-1) @click.pass_context -def update(ctx: click.Context, all: bool, packages: List[str]): +def update(ctx: click.Context, all: bool, packages: List[str], update_specs: bool): if all: - core.update_all_packages() + core.update_all_packages(update_specs) elif packages: for pkg in packages: - core.update_package(pkg) + core.update_package(pkg, update_specs) else: print(ctx.get_help(), file=sys.stderr) diff --git a/condax/conda.py b/condax/conda.py index b67cb9b..fb830e4 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -86,9 +86,9 @@ def _download_extract_micromamba(umamba_dst: Path): # ) -def create_conda_environment(package: str, match_specs=""): +def create_conda_environment(spec: str): conda_exe = ensure_conda() - prefix = conda_env_prefix(package) + prefix = conda_env_prefix(spec) channels = C.channels() channels_args = [x for c in channels for x in ["--channel", c]] @@ -103,7 +103,7 @@ def create_conda_environment(package: str, match_specs=""): *channels_args, "--quiet", "--yes", - shlex.quote(package + match_specs), + shlex.quote(spec), ] ) @@ -155,12 +155,15 @@ def remove_conda_env(package: str): ) -def update_conda_env(package: str): +def update_conda_env(spec: str, update_specs: bool): conda_exe = ensure_conda() - - _subprocess_run( - [conda_exe, "update", "--prefix", conda_env_prefix(package), "--all", "--yes"] - ) + prefix = conda_env_prefix(spec) + if update_specs: + _subprocess_run( + [conda_exe, "update", "--prefix", prefix, "--update_specs", "--yes", spec] + ) + else: + _subprocess_run([conda_exe, "update", "--prefix", prefix, "--all", "--yes"]) def has_conda_env(package: str) -> bool: @@ -169,11 +172,12 @@ def has_conda_env(package: str) -> bool: return p.exists() and p.is_dir() -def conda_env_prefix(package: str) -> Path: +def conda_env_prefix(spec: str) -> Path: + package, _ = utils.split_match_specs(spec) return C.prefix_dir() / package -def get_package_info(package, specific_name=None) -> Tuple[str, str, str]: +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 conda_meta_dir = env_prefix / "conda-meta" diff --git a/condax/core.py b/condax/core.py index 35615b0..c1cc77c 100644 --- a/condax/core.py +++ b/condax/core.py @@ -88,12 +88,12 @@ def remove_links(package: str, app_names_to_unlink: Iterable[str]): def install_package( - package: str, + spec: str, is_forcing: bool = False, ): # package match specifications # https://docs.conda.io/projects/conda/en/latest/user-guide/concepts/pkg-specs.html#package-match-specifications - package, match_specs = utils.split_match_specs(package) + package, match_specs = utils.split_match_specs(spec) if conda.has_conda_env(package): if is_forcing: @@ -105,7 +105,7 @@ def install_package( ) sys.exit(1) - conda.create_conda_environment(package, match_specs=match_specs) + conda.create_conda_environment(spec) executables_to_link = conda.determine_executables_from_env(package) utils.mkdir(C.bin_dir()) create_links(package, executables_to_link, is_forcing=is_forcing) @@ -205,9 +205,9 @@ def remove_package(package: str): print(f"`{package}` has been removed from condax", file=sys.stderr) -def update_all_packages(is_forcing: bool = False): +def update_all_packages(update_specs: bool = False, is_forcing: bool = False): for package in _get_all_envs(): - update_package(package, is_forcing=is_forcing) + update_package(package, update_specs=update_specs, is_forcing=is_forcing) def list_all_packages(short=False, include_injected=False) -> None: @@ -322,8 +322,9 @@ def _print_condax_dirs() -> None: print() -def update_package(env: str, is_forcing: bool = False) -> None: +def update_package(spec: str, update_specs: bool = False, is_forcing: 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)) @@ -331,7 +332,7 @@ def update_package(env: str, is_forcing: bool = False) -> None: injected: set(conda.determine_executables_from_env(env, injected)) for injected in _get_injected_packages(env) } - conda.update_conda_env(env) + conda.update_conda_env(spec, update_specs) main_apps_after_update = set(conda.determine_executables_from_env(env)) injected_apps_after_update = { injected: set(conda.determine_executables_from_env(env, injected)) From 83d59b8cd8f66a5d2c8ce3afd26bc9cd4eac7b9a Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Tue, 9 Aug 2022 09:18:05 -0400 Subject: [PATCH 17/34] Refactor slightly --- condax/core.py | 2 +- condax/utils.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/condax/core.py b/condax/core.py index c1cc77c..636a554 100644 --- a/condax/core.py +++ b/condax/core.py @@ -437,7 +437,7 @@ def _get_all_envs() -> List[str]: [ pkg_dir.name for pkg_dir in C.prefix_dir().iterdir() - if (pkg_dir / "conda-meta" / "history").exists() + if utils.is_env_dir(pkg_dir) ] ) diff --git a/condax/utils.py b/condax/utils.py index 4dbb817..0a35cd5 100644 --- a/condax/utils.py +++ b/condax/utils.py @@ -167,3 +167,9 @@ 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 = to_path(path) + return (p / "conda-meta" / "history").exists() From a461cbb5de786e3733fde59978dc98d3e4755adc Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Tue, 9 Aug 2022 09:59:45 -0400 Subject: [PATCH 18/34] Hotfix condax update --- condax/conda.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/condax/conda.py b/condax/conda.py index fb830e4..4379845 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -158,12 +158,37 @@ def remove_conda_env(package: str): def update_conda_env(spec: str, update_specs: bool): conda_exe = ensure_conda() prefix = conda_env_prefix(spec) + channels_args = [x for c in C.channels() for x in ["--channel", c]] + if update_specs: _subprocess_run( - [conda_exe, "update", "--prefix", prefix, "--update_specs", "--yes", spec] + [ + conda_exe, + "update", + "--prefix", + prefix, + "--override-channels", + *channels_args, + "--update-specs", + "--quiet", + "--yes", + shlex.quote(spec), + ] ) else: - _subprocess_run([conda_exe, "update", "--prefix", prefix, "--all", "--yes"]) + _subprocess_run( + [ + conda_exe, + "update", + "--prefix", + prefix, + "--override-channels", + *channels_args, + "--all", + "--quiet", + "--yes", + ] + ) def has_conda_env(package: str) -> bool: From 1486cb788935a1dc003ffa3e094a2d883aaf0f42 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Tue, 9 Aug 2022 10:00:12 -0400 Subject: [PATCH 19/34] Add test cases of `condax update` --- tests/test_condax_update.py | 87 +++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_condax_update.py diff --git a/tests/test_condax_update.py b/tests/test_condax_update.py new file mode 100644 index 0000000..8819960 --- /dev/null +++ b/tests/test_condax_update.py @@ -0,0 +1,87 @@ +import subprocess +import tempfile + +def test_condax_update_main_apps(): + """Check if condax update main apps works correctly. + """ + from condax.core import ( + install_package, + update_package, + ) + import condax.config as config + from condax.utils import to_path, is_env_dir + import condax.metadata as metadata + + # prep + prefix_fp = tempfile.TemporaryDirectory() + prefix_dir = to_path(prefix_fp.name) + bin_fp = tempfile.TemporaryDirectory() + bin_dir = to_path(bin_fp.name) + channels = ["conda-forge", "bioconda"] + config.set_via_value(prefix_dir=prefix_dir, bin_dir=bin_dir, channels=channels) + + main_pkg = "gff3toddbj" + main_version_before_update = "0.1.1" + main_spec_before_update = f"{main_pkg}={main_version_before_update}" + main_version_after_update = "0.2.1" + main_spec_after_update = f"{main_pkg}={main_version_after_update}" + + main_apps_before = { + "gff3-to-ddbj", + "list-products", + "rename-ids", + "split-fasta", + } + apps_before_update = {bin_dir / app for app in main_apps_before} + + main_apps_after_update = { + "gff3-to-ddbj", + "list-products", + "normalize-entry-names", + "split-fasta", + } + apps_after_update = {bin_dir / app for app in main_apps_after_update} + + env_dir = prefix_dir / main_pkg + exe_main = bin_dir / "gff3-to-ddbj" + + # Before installation there should be nothing + assert not is_env_dir(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 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 + res = subprocess.run(f"{exe_main} --version", shell=True, capture_output=True) + assert res.returncode == 2 + + 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 all(app.exists() and app.is_file() for app in apps_after_update) + to_be_removed = apps_before_update - apps_after_update + + # app named "rename-ids" should be gone after the update + assert to_be_removed == {bin_dir / "rename-ids"} + assert all(not app.exists() for app in to_be_removed) + + # Should get the correct version after the update + res = subprocess.run(f"{exe_main} --version", shell=True, capture_output=True) + assert res.returncode == 0 + assert res.stdout and (main_version_after_update in res.stdout.decode()) + + meta = metadata.load(main_pkg) + assert meta and meta.main_package and set(meta.main_package.apps) == main_apps_after_update + + prefix_fp.cleanup() + bin_fp.cleanup() + + + +## TODO: Add tests for update of injected packages +## From 8a544affd03a334cb63545af149bda799c79542d Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Tue, 9 Aug 2022 11:17:45 -0400 Subject: [PATCH 20/34] Resolve that `conda update` does not take version specs --- condax/conda.py | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/condax/conda.py b/condax/conda.py index 4379845..146c128 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -18,29 +18,29 @@ import condax.utils as utils -def ensure_conda(): +def ensure_conda() -> Path: execs = ["mamba", "conda"] for conda_exec in execs: conda_path = shutil.which(conda_exec) if conda_path is not None: - return conda_path + return to_path(conda_path) logging.info("No existing conda installation found. Installing the standalone") return setup_conda() -def ensure_micromamba(): +def ensure_micromamba() -> Path: execs = ["micromamba"] for conda_exec in execs: conda_path = shutil.which(conda_exec) if conda_path is not None: - return conda_path + return to_path(conda_path) logging.info("No existing conda installation found. Installing the standalone") return setup_micromamba() -def setup_conda(): +def setup_conda() -> Path: url = utils.get_conda_url() resp = requests.get(url, allow_redirects=True) resp.raise_for_status() @@ -156,39 +156,55 @@ def remove_conda_env(package: str): def update_conda_env(spec: str, update_specs: bool): + _, 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 [] - if update_specs: - _subprocess_run( - [ + # NOTE: `conda update` does not support version specification. + # It suggets to use `conda install` instead. + if conda_exe.name == "conda" and match_spec: + command = [ + conda_exe, + "install", + "--prefix", + prefix, + "--override-channels", + *channels_args, + "--quiet", + "--yes", + shlex.quote(spec), + ] + elif match_spec: + command = [ conda_exe, "update", "--prefix", prefix, "--override-channels", *channels_args, - "--update-specs", + *update_specs_args, "--quiet", "--yes", shlex.quote(spec), ] - ) else: - _subprocess_run( - [ + ## FIXME: this update process is inflexible + command = [ conda_exe, "update", "--prefix", prefix, "--override-channels", *channels_args, - "--all", + *update_specs_args, "--quiet", "--yes", + "--all", ] - ) + + _subprocess_run(command) def has_conda_env(package: str) -> bool: From f5a12d407266f0ae358995c26244ddce519520f3 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Tue, 9 Aug 2022 12:31:17 -0400 Subject: [PATCH 21/34] Set default channels via ~/.mambarc and ~/.condarc --- condax/condarc.py | 35 +++++++++++++++++++++++++++++++++++ condax/config.py | 5 ++++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 condax/condarc.py diff --git a/condax/condarc.py b/condax/condarc.py new file mode 100644 index 0000000..45610c6 --- /dev/null +++ b/condax/condarc.py @@ -0,0 +1,35 @@ +from pathlib import Path +from typing import List + +import yaml + + +DEFAULT_CHANNELS = ["conda-forge", "defaults"] + +# Search only ~/.mambarc and ~/.condarc for now +# although conda looks for many locations. +# https://docs.conda.io/projects/conda/en/stable/user-guide/configuration/use-condarc.html#searching-for-condarc +PATHS = [ + Path.home() / ".mambarc", + Path.home() / ".condarc", +] + + +def load_channels() -> List[str]: + """Load 'channels' from PATHS. Earlier paths have precedence.""" + + for p in PATHS: + if p.exists(): + channels = _load_yaml(p) + if channels: + return channels + + return DEFAULT_CHANNELS + + +def _load_yaml(path: Path) -> List[str]: + with open(path, "r") as f: + d = yaml.safe_load(f) + + res = d.get("channels", []) + return res diff --git a/condax/config.py b/condax/config.py index c239e4e..e4b2386 100644 --- a/condax/config.py +++ b/condax/config.py @@ -4,6 +4,7 @@ from typing import List, Optional, Union from condax.utils import to_path +import condax.condarc as condarc import yaml _config_filename = "config.yaml" @@ -26,8 +27,10 @@ DEFAULT_PREFIX_DIR = to_path(os.environ.get("CONDAX_PREFIX_DIR", _default_prefix_dir)) DEFAULT_BIN_DIR = to_path(os.environ.get("CONDAX_BIN_DIR", "~/.local/bin")) + +_channels_in_condarc = condarc.load_channels() DEFAULT_CHANNELS = ( - os.environ.get("CONDAX_CHANNELS", "conda-forge defaults").strip().split() + os.environ.get("CONDAX_CHANNELS", " ".join(_channels_in_condarc)).strip().split() ) CONDA_ENVIRONMENT_FILE = to_path("~/.conda/environments.txt") From 298a635cc8b06e0d638b5b2b8f6ff79d73fbeaa1 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Tue, 9 Aug 2022 12:57:40 -0400 Subject: [PATCH 22/34] Fix conda and micromamba setups for windows 64 bit --- condax/conda.py | 9 ++++++--- condax/utils.py | 10 ++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/condax/conda.py b/condax/conda.py index 146c128..966fe47 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -45,7 +45,8 @@ def setup_conda() -> Path: resp = requests.get(url, allow_redirects=True) resp.raise_for_status() utils.mkdir(C.bin_dir()) - target_filename = C.bin_dir() / "conda.exe" + 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) @@ -55,7 +56,8 @@ def setup_conda() -> Path: def setup_micromamba() -> Path: utils.mkdir(C.bin_dir()) - umamba_exe = C.bin_dir() / "micromamba" + 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 @@ -69,7 +71,8 @@ def _download_extract_micromamba(umamba_dst: Path): 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: - extracted = tar.extractfile("bin/micromamba") + p = "Library/bin/micromamba.exe" if os.name == "nt" else "bin/micromamba" + extracted = tar.extractfile(p) if extracted: shutil.copyfileobj(extracted, f) diff --git a/condax/utils.py b/condax/utils.py index 0a35cd5..c08f2ed 100644 --- a/condax/utils.py +++ b/condax/utils.py @@ -125,9 +125,10 @@ def get_micromamba_url() -> str: subdir = "linux-64/latest" elif platform.system() == "Darwin": subdir = "osx-64/latest" + elif platform.system() == "Windows" and platform.machine() in ("AMD64", "x86_64"): + subdir = "win-64/latest" else: - # TODO: Support windows here - raise ValueError(f"Unsupported platform: {platform.system()}") + raise ValueError(f"Unsupported platform: {platform.system()} {platform.machine()}") url = urllib.parse.urljoin(base, subdir) return url @@ -142,9 +143,10 @@ def get_conda_url() -> str: subdir = "conda-latest-linux-64.exe" elif platform.system() == "Darwin": subdir = "conda-latest-osx-64.exe" + elif platform.system() == "Windows" and platform.machine() in ("AMD64", "x86_64"): + subdir = "conda-latest-win-64.exe" else: - # TODO: Support windows here - raise ValueError(f"Unsupported platform: {platform.system()}") + raise ValueError(f"Unsupported platform: {platform.system()} {platform.machine()}") url = urllib.parse.urljoin(base, subdir) return url From ac8a32ae9fcfbb65179c59652862df23d1db5c50 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Tue, 9 Aug 2022 14:23:48 -0400 Subject: [PATCH 23/34] Don't use micromamba on windows for now --- condax/conda.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/condax/conda.py b/condax/conda.py index 966fe47..43f1fd0 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -30,6 +30,9 @@ def ensure_conda() -> Path: def ensure_micromamba() -> Path: + if os.name == "nt": + return ensure_conda() + execs = ["micromamba"] for conda_exec in execs: conda_path = shutil.which(conda_exec) From c5e2f2144b22cf4c0a0a858ce3aea447f883831a Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Tue, 9 Aug 2022 14:59:05 -0400 Subject: [PATCH 24/34] Revert "Don't use micromamba on windows for now" This reverts commit c2686baca9ee338171a5bf65af3ec5618bf5cc46. --- condax/conda.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/condax/conda.py b/condax/conda.py index 43f1fd0..966fe47 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -30,9 +30,6 @@ def ensure_conda() -> Path: def ensure_micromamba() -> Path: - if os.name == "nt": - return ensure_conda() - execs = ["micromamba"] for conda_exec in execs: conda_path = shutil.which(conda_exec) From cceacdc67fee1c73e112a89684a706e12ae5b8dd Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Tue, 9 Aug 2022 14:59:45 -0400 Subject: [PATCH 25/34] Reinstall micromamba in `condax repair` --- condax/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/condax/cli.py b/condax/cli.py index f6c59d2..0efd415 100644 --- a/condax/cli.py +++ b/condax/cli.py @@ -268,8 +268,8 @@ def run_import(directory: str, is_forcing: bool): def repair(is_migrating): if is_migrating: migrate.from_old_version() + conda.setup_micromamba() core.fix_links() - conda.ensure_micromamba() if __name__ == "__main__": From 63de74226ca7eaf589adbd968e8592fbca989edd Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Wed, 10 Aug 2022 16:07:32 -0400 Subject: [PATCH 26/34] Bump to 0.1.1 --- condax/__init__.py | 2 +- pyproject.toml | 2 +- setup.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/condax/__init__.py b/condax/__init__.py index 3dc1f76..485f44a 100644 --- a/condax/__init__.py +++ b/condax/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/pyproject.toml b/pyproject.toml index 0eff7f9..f243bd9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [metadata] name = 'condax' -version = '0.1.0' +version = '0.1.1' description = 'Install and run applications packaged with conda in isolated environments' author = 'Marius van Niekerk' author_email = 'marius.v.niekerk@gmail.com' diff --git a/setup.py b/setup.py index 4b79e92..ab27e10 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ version = line.strip().split("=")[1].strip(" '\"") break else: - version = "0.1.0" + version = "0.1.1" with open("README.md", "r", encoding="utf-8") as f: readme = f.read() @@ -22,7 +22,7 @@ setup( name="condax", - version="0.1.0", + version=version, description="Install and run applications packaged with conda in isolated environments", long_description=readme, long_description_content_type="text/markdown", From 0622dfd9f5eb23896500418100f8d9cb975e94a4 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Thu, 11 Aug 2022 00:08:09 -0400 Subject: [PATCH 27/34] Add comments --- condax/conda.py | 28 +++++++++++++++++++++------- condax/core.py | 19 ++++++++++++------- condax/metadata.py | 4 ++++ condax/migrate.py | 2 +- condax/wrapper.py | 4 ++-- 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/condax/conda.py b/condax/conda.py index 966fe47..0545ff4 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -62,7 +62,7 @@ def setup_micromamba() -> Path: return umamba_exe -def _download_extract_micromamba(umamba_dst: Path): +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) @@ -89,7 +89,11 @@ def _download_extract_micromamba(umamba_dst: Path): # ) -def create_conda_environment(spec: str): +def create_conda_environment(spec: str) -> None: + """Create an environment by installing a package. + + NOTE: `spec` may contain version specificaitons. + """ conda_exe = ensure_conda() prefix = conda_env_prefix(spec) @@ -111,8 +115,11 @@ def create_conda_environment(spec: str): ) -def inject_to_conda_env(specs: Iterable[str], env_name: str): +def inject_to_conda_env(specs: Iterable[str], env_name: str) -> None: + """Add packages onto existing `env_name`. + NOTE: a spec may contain version specification. + """ conda_exe = ensure_conda() prefix = conda_env_prefix(env_name) channels_args = [x for c in C.channels() for x in ["--channel", c]] @@ -133,7 +140,9 @@ def inject_to_conda_env(specs: Iterable[str], env_name: str): ) -def uninject_from_conda_env(packages: Iterable[str], env_name: str): +def uninject_from_conda_env(packages: Iterable[str], env_name: str) -> None: + """Remove packages from existing environment `env_name`. + """ conda_exe = ensure_conda() prefix = conda_env_prefix(env_name) @@ -150,7 +159,8 @@ def uninject_from_conda_env(packages: Iterable[str], env_name: str): ) -def remove_conda_env(package: str): +def remove_conda_env(package: str) -> None: + """Remove a conda environment.""" conda_exe = ensure_conda() _subprocess_run( @@ -158,7 +168,11 @@ def remove_conda_env(package: str): ) -def update_conda_env(spec: str, update_specs: bool): +def update_conda_env(spec: str, update_specs: 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) @@ -345,7 +359,7 @@ def _subprocess_run( return res -def export_env(env_name: str, out_dir: Path): +def export_env(env_name: str, out_dir: Path) -> None: """Export an environment to a conda environment file.""" conda_exe = ensure_conda() prefix = conda_env_prefix(env_name) diff --git a/condax/core.py b/condax/core.py index 636a554..47bf71f 100644 --- a/condax/core.py +++ b/condax/core.py @@ -500,7 +500,12 @@ def _get_wrapper_path(cmd_name: str) -> Path: def export_all_environments(out_dir: str) -> None: - """Export all environments to a directory.""" + """Export all environments to a directory. + + NOTE: Each environment exports two files: + - One is YAML from `conda env export`. + - Another is a copy of `condax_metadata.json`. + """ p = Path(out_dir) p.mkdir(parents=True, exist_ok=True) print("Started exporting all environments to", p) @@ -514,14 +519,15 @@ def export_all_environments(out_dir: str) -> None: def _copy_metadata(env: str, p: Path): - """Copy the condax_metadata.json file to the exported directory.""" + """Export `condax_metadata.json` in the prefix directory `env` + to the specified directory.""" _from = metadata.CondaxMetaData.get_path(env) _to = p / f"{env}.json" shutil.copyfile(_from, _to, follow_symlinks=True) def _overwrite_metadata(envfile: Path): - """Copy the condax_metadata.json file to the exported directory.""" + """Import `condax_metadata.json file` to the prefix directory.""" env = envfile.stem _from = envfile _to = metadata.CondaxMetaData.get_path(env) @@ -553,8 +559,7 @@ def import_environments(in_dir: str, is_forcing: bool) -> None: def _get_executables_to_link(env: str) -> List[Path]: - """ - Return a list of executables to link. + """Return a list of executables to link. """ meta = _load_metadata(env) @@ -587,6 +592,7 @@ def _recreate_all_links(): def _prune_links(): + """Remove condax bash scripts if broken.""" to_apps = {env: _get_apps(env) for env in _get_all_envs()} utils.mkdir(C.bin_dir()) @@ -630,8 +636,7 @@ def _add_to_conda_env_list() -> None: def fix_links(): - """ - Run the repair lin. + """Repair condax bash scripts in bin_dir. """ utils.mkdir(C.bin_dir()) diff --git a/condax/metadata.py b/condax/metadata.py index b10d778..002b0a8 100644 --- a/condax/metadata.py +++ b/condax/metadata.py @@ -24,6 +24,10 @@ class InjectedPackage(_PackageBase): class CondaxMetaData(object): + """ + Handle metadata information written in `condax_metadata.json` + placed in each environment. + """ metadata_file = "condax_metadata.json" diff --git a/condax/migrate.py b/condax/migrate.py index 692ba7b..e7b38a9 100644 --- a/condax/migrate.py +++ b/condax/migrate.py @@ -1,5 +1,5 @@ """ -Utilities for migrating condax to a new version from the original one (version 0.0.5). +Utilities for migrating from the original condax (version 0.0.5). """ import logging diff --git a/condax/wrapper.py b/condax/wrapper.py index 6c0f1f5..7677990 100644 --- a/condax/wrapper.py +++ b/condax/wrapper.py @@ -11,7 +11,7 @@ def read_env_name(script_path: Union[str, Path]) -> Optional[str]: """ - Read a condax wrapper script. + Read a condax bash script. Returns the environment name within which conda run is executed. """ @@ -79,7 +79,7 @@ def is_wrapper(exec_path: Union[str, Path]) -> bool: class Parser(object): """ - Parser.parse(lines) parses lines to get 'conda run' information. + Parser.parse(lines) parses text to get 'conda run' information. """ p = argparse.ArgumentParser() From 1c2791d6ba5a3f41253e00065836429b605e6bf6 Mon Sep 17 00:00:00 2001 From: Yamato Matsuoka Date: Thu, 11 Aug 2022 21:42:29 -0400 Subject: [PATCH 28/34] Remove an unused github action from the original --- .github/workflows/pre-commit.yml | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 .github/workflows/pre-commit.yml diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index c154a12..0000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: pre-commit - -on: - pull_request: - push: - branches: [master] - -jobs: - pre-commit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - uses: actions/setup-python@v1 - - name: set PY - run: echo "::set-env name=PY::$(python --version --version | sha256sum | cut -d' ' -f1)" - - uses: actions/cache@v1 - with: - path: ~/.cache/pre-commit - key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} - - uses: pre-commit/action@v1.0.0 From 342c398cf5ac2ff6e3d8f4dbd24f3391e19c31aa Mon Sep 17 00:00:00 2001 From: Abraham Murciano Date: Fri, 12 Aug 2022 03:55:33 +0300 Subject: [PATCH 29/34] Tidy up logging and errors --- condax/cli.py | 276 -------------------------------- condax/cli/__init__.py | 120 ++++++++++++++ condax/cli/__main__.py | 57 +++++++ condax/cli/ensure_path.py | 19 +++ condax/cli/export.py | 41 +++++ condax/cli/inject.py | 54 +++++++ condax/cli/install.py | 42 +++++ condax/cli/list.py | 32 ++++ condax/cli/remove.py | 34 ++++ condax/cli/repair.py | 31 ++++ condax/cli/update.py | 43 +++++ condax/conda.py | 184 +++++++++++---------- condax/config.py | 66 +++++--- condax/core.py | 308 ++++++++++++++++++------------------ condax/exceptions.py | 7 + condax/paths.py | 19 +-- condax/utils.py | 15 +- environment.yml | 5 +- setup.py | 4 +- tests/test_condax_update.py | 11 +- tests/test_config.py | 2 +- 21 files changed, 798 insertions(+), 572 deletions(-) delete mode 100644 condax/cli.py create mode 100644 condax/cli/__init__.py create mode 100644 condax/cli/__main__.py create mode 100644 condax/cli/ensure_path.py create mode 100644 condax/cli/export.py create mode 100644 condax/cli/inject.py create mode 100644 condax/cli/install.py create mode 100644 condax/cli/list.py create mode 100644 condax/cli/remove.py create mode 100644 condax/cli/repair.py create mode 100644 condax/cli/update.py create mode 100644 condax/exceptions.py diff --git a/condax/cli.py b/condax/cli.py deleted file mode 100644 index 0efd415..0000000 --- a/condax/cli.py +++ /dev/null @@ -1,276 +0,0 @@ -from pathlib import Path -import sys -from typing import List, Optional - -import click - -import condax.config as config -import condax.conda as conda -import condax.core as core -import condax.paths as paths -import condax.migrate as migrate -from condax import __version__ - - -option_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}", -) - -option_channels = click.option( - "--channel", - "-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.""", -) - -option_envname = click.option( - "--name", - "-n", - "envname", - required=True, - prompt="Specify the environment (Run `condax list --short` to see available ones)", - type=str, - help=f"""Specify existing environment to inject into.""", - callback=lambda ctx, param, value: value.strip(), -) - -option_is_forcing = click.option( - "-f", - "--force", - "is_forcing", - help="""Modify existing environment and files in CONDAX_BIN_DIR.""", - is_flag=True, - default=False, -) - - -@click.group( - help=f"""Install and execute applications packaged by conda. - - Default varibles: - - Conda environment location is {config.DEFAULT_PREFIX_DIR}\n - Links to apps are placed in {config.DEFAULT_BIN_DIR} - """ -) -@click.version_option( - __version__, - message="%(prog)s %(version)s", -) -@option_config -def cli(config_file: Optional[Path]): - if config_file: - config.set_via_file(config_file) - else: - config.set_via_file(config.DEFAULT_CONFIG) - - -@cli.command( - help=f""" - 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}`. - """ -) -@option_channels -@option_config -@option_is_forcing -@click.argument("packages", nargs=-1) -def install( - packages: List[str], - config_file: Optional[Path], - channels: List[str], - is_forcing: bool, -): - if config_file: - config.set_via_file(config_file) - if channels: - config.set_via_value(channels=channels) - for pkg in packages: - core.install_package(pkg, is_forcing=is_forcing) - - -@cli.command( - help=""" - Remove a package. - - This will remove a package installed with condax and destroy the underlying - conda environment. - """ -) -@click.argument("packages", nargs=-1) -def remove(packages: List[str]): - for pkg in packages: - core.remove_package(pkg) - - -@cli.command( - help=""" - Alias for condax remove. - """ -) -@click.argument("packages", nargs=-1) -def uninstall(packages: List[str]): - remove(packages) - - -@cli.command( - help=""" - List packages managed by condax. - - This will show all packages installed by condax. - """ -) -@click.option( - "--short", - is_flag=True, - default=False, - help="List packages only.", -) -@click.option( - "--include-injected", - is_flag=True, - default=False, - help="Show packages injected into the main app's environment.", -) -def list(short: bool, include_injected: bool): - core.list_all_packages(short=short, include_injected=include_injected) - - -@cli.command( - help=""" - Inject a package to existing environment created by condax. - """ -) -@option_channels -@option_envname -@option_is_forcing -@click.option( - "--include-apps", - help="""Make apps from the injected package available.""", - is_flag=True, - default=False, -) -@click.argument("packages", nargs=-1, required=True) -def inject( - packages: List[str], - envname: str, - channels: List[str], - is_forcing: bool, - include_apps: bool, -): - if channels: - config.set_via_value(channels=channels) - - core.inject_package_to( - envname, packages, is_forcing=is_forcing, include_apps=include_apps - ) - - -@cli.command( - help=""" - Uninject a package from an existing environment. - """ -) -@option_envname -@click.argument("packages", nargs=-1, required=True) -def uninject(packages: List[str], envname: str): - core.uninject_package_from(envname, packages) - - -@cli.command( - help=""" - Ensure the condax links directory is on $PATH. - """ -) -@option_config -def ensure_path(config_file: Optional[Path]): - if config_file: - config.set_via_file(config_file) - paths.add_path_to_environment(config.C.bin_dir()) - - -@cli.command( - help=""" - Update package(s) installed by condax. - - This will update the underlying conda environments(s) to the latest release of a package. - """ -) -@click.option( - "--all", is_flag=True, help="Set to update all packages installed by condax." -) -@click.option( - "--update-specs", is_flag=True, help="Update based on provided specifications." -) -@click.argument("packages", required=False, nargs=-1) -@click.pass_context -def update(ctx: click.Context, all: bool, packages: List[str], update_specs: bool): - if all: - core.update_all_packages(update_specs) - elif packages: - for pkg in packages: - core.update_package(pkg, update_specs) - else: - print(ctx.get_help(), file=sys.stderr) - - -@cli.command( - help=""" - [experimental] Export all environments installed by condax. - """ -) -@click.option( - "--dir", - default="condax_exported", - help="Set directory to export to.", -) -def export(dir: str): - core.export_all_environments(dir) - - -@cli.command( - "import", - help=""" - [experimental] Import condax environments. - """, -) -@option_is_forcing -@click.argument( - "directory", - required=True, - type=click.Path(exists=True, dir_okay=True, file_okay=False), -) -def run_import(directory: str, is_forcing: bool): - core.import_environments(directory, is_forcing) - - -@cli.command( - help=f""" - [experimental] Repair condax links in BIN_DIR. - - By default BIN_DIR is {config.DEFAULT_BIN_DIR}. - """ -) -@click.option( - "--migrate", - "is_migrating", - help="""Migrate from the original condax version.""", - is_flag=True, - default=False, -) -def repair(is_migrating): - if is_migrating: - migrate.from_old_version() - conda.setup_micromamba() - core.fix_links() - - -if __name__ == "__main__": - cli() diff --git a/condax/cli/__init__.py b/condax/cli/__init__.py new file mode 100644 index 0000000..3ac07a1 --- /dev/null +++ b/condax/cli/__init__.py @@ -0,0 +1,120 @@ +import logging +from statistics import median +import rainbowlog +from pathlib import Path +from typing import Callable, Optional + +import click + +import condax.config as config +from condax import __version__ + + +option_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, +) + +option_channels = click.option( + "--channel", + "-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, +) + +option_envname = click.option( + "--name", + "-n", + "envname", + required=True, + prompt="Specify the environment (Run `condax list --short` to see available ones)", + type=str, + help=f"""Specify existing environment to inject into.""", + callback=lambda _, __, n: n.strip(), +) + +option_is_forcing = click.option( + "-f", + "--force", + "is_forcing", + help="""Modify existing environment and files in CONDAX_BIN_DIR.""", + is_flag=True, + default=False, +) + + +def options_logging(f: Callable) -> Callable: + option_verbose = click.option( + "-v", + "--verbose", + count=True, + help="Raise verbosity level.", + callback=lambda _, __, v: _LoggerSetup.set_verbose(v), + ) + option_quiet = click.option( + "-q", + "--quiet", + count=True, + help="Decrease verbosity level.", + callback=lambda _, __, q: _LoggerSetup.set_quiet(q), + ) + return option_verbose(option_quiet(f)) + + +@click.group( + help=f"""Install and execute applications packaged by conda. + + Default variables: + + Conda environment location is {config.DEFAULT_PREFIX_DIR}\n + Links to apps are placed in {config.DEFAULT_BIN_DIR} + """ +) +@click.version_option( + __version__, + message="%(prog)s %(version)s", +) +@option_config +@options_logging +def cli(**_): + """Main entry point for condax.""" + pass + + +class _LoggerSetup: + handler = logging.StreamHandler() + formatter = rainbowlog.Formatter(logging.Formatter()) + logger = logging.getLogger((__package__ or __name__).split(".", 1)[0]) + verbose = 0 + quiet = 0 + + @classmethod + def setup(cls) -> int: + """Setup the logger. + + Returns: + int: The log level. + """ + cls.handler.setFormatter(cls.formatter) + cls.logger.addHandler(cls.handler) + level = logging.INFO - 10 * (int(median((-1, 3, cls.verbose - cls.quiet)))) + cls.logger.setLevel(level) + return level + + @classmethod + def set_verbose(cls, v: int) -> int: + """Set the verbose level and return the new log level.""" + cls.verbose += v + return cls.setup() + + @classmethod + def set_quiet(cls, q: int): + """Set the quiet level and return the new log level.""" + cls.quiet += q + return cls.setup() diff --git a/condax/cli/__main__.py b/condax/cli/__main__.py new file mode 100644 index 0000000..764d769 --- /dev/null +++ b/condax/cli/__main__.py @@ -0,0 +1,57 @@ +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 +from .update import update +from .list import run_list +from .ensure_path import ensure_path +from .inject import inject, uninject +from .export import export, run_import +from .repair import repair +from . import cli + + +def main(): + + for subcommand in ( + install, + remove, + uninstall, + update, + run_list, + ensure_path, + inject, + uninject, + export, + run_import, + repair, + ): + cli.add_command(subcommand) + + 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: + logger.error(f"Error: {e}") + else: + logger.info(e) + sys.exit(e.exit_code) + except HTTPError as e: + logger.error(f"HTTP Error: {e}") + sys.exit(e.code) + except Exception as e: + logger.exception(e) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/condax/cli/ensure_path.py b/condax/cli/ensure_path.py new file mode 100644 index 0000000..75d5763 --- /dev/null +++ b/condax/cli/ensure_path.py @@ -0,0 +1,19 @@ +from pathlib import Path +from typing import Optional + +import condax.config as config +import condax.paths as paths +from condax import __version__ + +from . import cli, option_config, options_logging + + +@cli.command( + help=""" + Ensure the condax links directory is on $PATH. + """ +) +@option_config +@options_logging +def ensure_path(**_): + paths.add_path_to_environment(config.C.bin_dir()) diff --git a/condax/cli/export.py b/condax/cli/export.py new file mode 100644 index 0000000..977846b --- /dev/null +++ b/condax/cli/export.py @@ -0,0 +1,41 @@ +import logging +import click + +import condax.core as core +from condax import __version__ + +from . import cli, option_is_forcing, options_logging + + +@cli.command( + help=""" + [experimental] Export all environments installed by condax. + """ +) +@click.option( + "--dir", + default="condax_exported", + help="Set directory to export to.", +) +@options_logging +def export(dir: str, verbose: int, **_): + core.export_all_environments(dir, conda_stdout=verbose <= logging.INFO) + + +@cli.command( + "import", + help=""" + [experimental] Import condax environments. + """, +) +@option_is_forcing +@options_logging +@click.argument( + "directory", + required=True, + type=click.Path(exists=True, dir_okay=True, file_okay=False), +) +def run_import(directory: str, is_forcing: bool, verbose: int, **_): + core.import_environments( + directory, is_forcing, conda_stdout=verbose <= logging.INFO + ) diff --git a/condax/cli/inject.py b/condax/cli/inject.py new file mode 100644 index 0000000..b2aaf52 --- /dev/null +++ b/condax/cli/inject.py @@ -0,0 +1,54 @@ +import logging +from typing import List +import click + +import condax.config as config +import condax.core as core +from condax import __version__ + +from . import cli, option_channels, option_envname, option_is_forcing, options_logging + + +@cli.command( + help=""" + Inject a package to existing environment created by condax. + """ +) +@option_channels +@option_envname +@option_is_forcing +@click.option( + "--include-apps", + help="""Make apps from the injected package available.""", + is_flag=True, + default=False, +) +@options_logging +@click.argument("packages", nargs=-1, required=True) +def inject( + packages: List[str], + envname: str, + is_forcing: bool, + include_apps: bool, + verbose: int, + **_, +): + core.inject_package_to( + envname, + packages, + is_forcing=is_forcing, + include_apps=include_apps, + conda_stdout=verbose <= logging.INFO, + ) + + +@cli.command( + help=""" + Uninject a package from an existing environment. + """ +) +@option_envname +@options_logging +@click.argument("packages", nargs=-1, required=True) +def uninject(packages: List[str], envname: str, verbose: int, **_): + core.uninject_package_from(envname, packages, verbose <= logging.INFO) diff --git a/condax/cli/install.py b/condax/cli/install.py new file mode 100644 index 0000000..7159b4e --- /dev/null +++ b/condax/cli/install.py @@ -0,0 +1,42 @@ +import logging +from typing import List + +import click + +import condax.config as config +import condax.core as core +from condax import __version__ + +from . import ( + cli, + option_config, + option_channels, + option_is_forcing, + option_channels, + options_logging, +) + + +@cli.command( + help=f""" + 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}`. + """ +) +@option_channels +@option_config +@option_is_forcing +@options_logging +@click.argument("packages", nargs=-1) +def install( + packages: List[str], + is_forcing: bool, + verbose: int, + **_, +): + for pkg in packages: + core.install_package( + pkg, is_forcing=is_forcing, conda_stdout=verbose <= logging.INFO + ) diff --git a/condax/cli/list.py b/condax/cli/list.py new file mode 100644 index 0000000..ca64f8e --- /dev/null +++ b/condax/cli/list.py @@ -0,0 +1,32 @@ +import click + +import condax.core as core +from condax import __version__ + +from . import cli, options_logging + + +@cli.command( + "list", + help=""" + List packages managed by condax. + + This will show all packages installed by condax. + """, +) +@click.option( + "-s", + "--short", + is_flag=True, + default=False, + help="List packages only.", +) +@click.option( + "--include-injected", + is_flag=True, + default=False, + help="Show packages injected into the main app's environment.", +) +@options_logging +def run_list(short: bool, include_injected: bool, **_): + core.list_all_packages(short=short, include_injected=include_injected) diff --git a/condax/cli/remove.py b/condax/cli/remove.py new file mode 100644 index 0000000..44a72a8 --- /dev/null +++ b/condax/cli/remove.py @@ -0,0 +1,34 @@ +import logging +from typing import List +import click + +import condax.core as core +from condax import __version__ + +from . import cli, options_logging + + +@cli.command( + help=""" + Remove a package. + + This will remove a package installed with condax and destroy the underlying + conda environment. + """ +) +@options_logging +@click.argument("packages", nargs=-1) +def remove(packages: List[str], verbose: int, **_): + for pkg in packages: + core.remove_package(pkg, conda_stdout=verbose <= logging.INFO) + + +@cli.command( + help=""" + Alias for condax remove. + """ +) +@options_logging +@click.argument("packages", nargs=-1) +def uninstall(packages: List[str], **_): + remove(packages) diff --git a/condax/cli/repair.py b/condax/cli/repair.py new file mode 100644 index 0000000..8e0e36e --- /dev/null +++ b/condax/cli/repair.py @@ -0,0 +1,31 @@ +import click + +import condax.config as config +import condax.conda as conda +import condax.core as core +import condax.migrate as migrate +from condax import __version__ + +from . import cli, options_logging + + +@cli.command( + help=f""" + [experimental] Repair condax links in BIN_DIR. + + By default BIN_DIR is {config.DEFAULT_BIN_DIR}. + """ +) +@click.option( + "--migrate", + "is_migrating", + help="""Migrate from the original condax version.""", + is_flag=True, + default=False, +) +@options_logging +def repair(is_migrating, **_): + if is_migrating: + migrate.from_old_version() + conda.setup_micromamba() + core.fix_links() diff --git a/condax/cli/update.py b/condax/cli/update.py new file mode 100644 index 0000000..425b168 --- /dev/null +++ b/condax/cli/update.py @@ -0,0 +1,43 @@ +import logging +import sys +from typing import List + +import click + +import condax.core as core +from condax import __version__ + +from . import cli, options_logging + + +@cli.command( + help=""" + Update package(s) installed by condax. + + This will update the underlying conda environments(s) to the latest release of a package. + """ +) +@click.option( + "--all", is_flag=True, help="Set to update all packages installed by condax." +) +@click.option( + "--update-specs", is_flag=True, help="Update based on provided specifications." +) +@options_logging +@click.argument("packages", required=False, nargs=-1) +@click.pass_context +def update( + ctx: click.Context, + all: bool, + packages: List[str], + update_specs: bool, + verbose: int, + **_ +): + if all: + core.update_all_packages(update_specs) + elif packages: + for pkg in packages: + core.update_package(pkg, update_specs, conda_stdout=verbose <= logging.INFO) + else: + ctx.fail("No packages specified.") diff --git a/condax/conda.py b/condax/conda.py index 0545ff4..162baf3 100644 --- a/condax/conda.py +++ b/condax/conda.py @@ -9,35 +9,35 @@ from pathlib import Path import sys import tarfile -from typing import Iterable, List, Optional, Tuple, Union +from typing import Callable, Iterable, List, Optional, Set, Tuple, Union import requests from condax.config import C +from condax.exceptions import CondaxError from condax.utils import to_path import condax.utils as utils -def ensure_conda() -> Path: - execs = ["mamba", "conda"] - for conda_exec in execs: - conda_path = shutil.which(conda_exec) - if conda_path is not None: - return to_path(conda_path) +logger = logging.getLogger(__name__) - logging.info("No existing conda installation found. Installing the standalone") - return setup_conda() +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) -def ensure_micromamba() -> Path: - execs = ["micromamba"] - for conda_exec in execs: - conda_path = shutil.which(conda_exec) - if conda_path is not None: - return to_path(conda_path) + logger.info("No existing conda installation found. Installing the standalone") + return installer() + + +def ensure_conda() -> Path: + return _ensure(("conda", "mamba"), setup_conda) - logging.info("No existing conda installation found. Installing the standalone") - return setup_micromamba() + +def ensure_micromamba() -> Path: + return _ensure(("micromamba",), setup_micromamba) def setup_conda() -> Path: @@ -89,7 +89,7 @@ def _download_extract_micromamba(umamba_dst: Path) -> None: # ) -def create_conda_environment(spec: str) -> None: +def create_conda_environment(spec: str, stdout: bool) -> None: """Create an environment by installing a package. NOTE: `spec` may contain version specificaitons. @@ -111,11 +111,12 @@ def create_conda_environment(spec: str) -> None: "--quiet", "--yes", shlex.quote(spec), - ] + ], + suppress_stdout=not stdout, ) -def inject_to_conda_env(specs: Iterable[str], env_name: str) -> None: +def inject_to_conda_env(specs: Iterable[str], env_name: str, stdout: bool) -> None: """Add packages onto existing `env_name`. NOTE: a spec may contain version specification. @@ -125,7 +126,7 @@ def inject_to_conda_env(specs: Iterable[str], env_name: str) -> None: channels_args = [x for c in C.channels() for x in ["--channel", c]] specs_args = [shlex.quote(spec) for spec in specs] - res = _subprocess_run( + _subprocess_run( [ conda_exe, "install", @@ -136,13 +137,15 @@ def inject_to_conda_env(specs: Iterable[str], env_name: str) -> None: "--quiet", "--yes", *specs_args, - ] + ], + suppress_stdout=not stdout, ) -def uninject_from_conda_env(packages: Iterable[str], env_name: str) -> None: - """Remove packages from existing environment `env_name`. - """ +def uninject_from_conda_env( + packages: Iterable[str], env_name: str, stdout: bool +) -> None: + """Remove packages from existing environment `env_name`.""" conda_exe = ensure_conda() prefix = conda_env_prefix(env_name) @@ -155,20 +158,22 @@ def uninject_from_conda_env(packages: Iterable[str], env_name: str) -> None: "--quiet", "--yes", *packages, - ] + ], + suppress_stdout=not stdout, ) -def remove_conda_env(package: str) -> None: +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"] + [conda_exe, "remove", "--prefix", conda_env_prefix(package), "--all", "--yes"], + suppress_stdout=not stdout, ) -def update_conda_env(spec: str, update_specs: bool) -> None: +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. @@ -178,50 +183,33 @@ def update_conda_env(spec: str, update_specs: bool) -> None: 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: - command = [ - conda_exe, - "install", - "--prefix", - prefix, - "--override-channels", - *channels_args, - "--quiet", - "--yes", - shlex.quote(spec), - ] + subcmd = "install" + args = (shlex.quote(spec),) elif match_spec: - command = [ - conda_exe, - "update", - "--prefix", - prefix, - "--override-channels", - *channels_args, - *update_specs_args, - "--quiet", - "--yes", - shlex.quote(spec), - ] + subcmd = "update" + args = (*update_specs_args, shlex.quote(spec)) else: ## FIXME: this update process is inflexible - command = [ - conda_exe, - "update", - "--prefix", - prefix, - "--override-channels", - *channels_args, - *update_specs_args, - "--quiet", - "--yes", - "--all", - ] - - _subprocess_run(command) + 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 has_conda_env(package: str) -> bool: @@ -249,18 +237,19 @@ def get_package_info(package: str, specific_name=None) -> Tuple[str, str, str]: build: str = package_info["build"] return (name, version, build) except ValueError: - logging.info( - "".join( - [ - f"Could not retrieve package info: {package}", - f" - {specific_name}" if specific_name else "", - ] - ) + logger.warning( + f"Could not retrieve package info: {package}" + + (f" - {specific_name}" if specific_name else "") ) 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]: @@ -273,10 +262,10 @@ def is_good(p: Union[str, Path]) -> bool: conda_meta_dir = env_prefix / "conda-meta" for file_name in conda_meta_dir.glob(f"{target_name}*.json"): - with open(file_name, "r") as fo: + with file_name.open() as fo: package_info = json.load(fo) if package_info["name"] == target_name: - potential_executables: List[str] = [ + potential_executables: Set[str] = { fn for fn in package_info["files"] if (fn.startswith("bin/") and is_good(fn)) @@ -284,19 +273,16 @@ def is_good(p: Union[str, Path]) -> bool: # 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 ValueError( - f"Could not determine package files: {package} - {injected_package}" - ) + raise DeterminePkgFilesError(target_name) - executables = set() - for fn in potential_executables: - exec_path = env_prefix / fn - if utils.is_executable(exec_path): - executables.add(exec_path) - return sorted(executables) + return sorted( + env_prefix / fn + for fn in potential_executables + if utils.is_executable(env_prefix / fn) + ) def _get_conda_package_dirs() -> List[Path]: @@ -345,21 +331,31 @@ def get_dependencies(package: str) -> List[str]: return result +class SubprocessError(CondaxError): + def __init__(self, code: int, exe: Union[Path, str]): + super().__init__(code, f"{exe} exited with code {code}.") + + def _subprocess_run( - args: Union[str, List[Union[str, Path]]], **kwargs + args: Union[str, List[Union[str, Path]]], suppress_stdout: bool = True, **kwargs ) -> subprocess.CompletedProcess: """ Run a subprocess and return the CompletedProcess object. """ env = os.environ.copy() env.update({"MAMBA_NO_BANNER": "1"}) - res = subprocess.run(args, **kwargs, env=env) + res = subprocess.run( + args, + **kwargs, + stdout=subprocess.DEVNULL if suppress_stdout else None, + env=env, + ) if res.returncode != 0: - sys.exit(res.returncode) + raise SubprocessError(res.returncode, args[0]) return res -def export_env(env_name: str, out_dir: Path) -> None: +def export_env(env_name: str, out_dir: Path, stdout: bool = False) -> None: """Export an environment to a conda environment file.""" conda_exe = ensure_conda() prefix = conda_env_prefix(env_name) @@ -374,11 +370,12 @@ def export_env(env_name: str, out_dir: Path) -> None: prefix, "--file", filepath, - ] + ], + suppress_stdout=not stdout, ) -def import_env(env_file: Path, is_forcing: bool = False): +def import_env(env_file: Path, is_forcing: bool = False, stdout: bool = False) -> None: """Import an environment from a conda environment file.""" conda_exe = ensure_conda() force_args = ["--force"] if is_forcing else [] @@ -394,5 +391,6 @@ def import_env(env_file: Path, is_forcing: bool = False): prefix, "--file", env_file, - ] + ], + suppress_stdout=not stdout, ) diff --git a/condax/config.py b/condax/config.py index e4b2386..51cd125 100644 --- a/condax/config.py +++ b/condax/config.py @@ -1,7 +1,8 @@ import os from pathlib import Path import shutil -from typing import List, Optional, Union +from typing import Any, Dict, List, Optional, Union +from condax.exceptions import CondaxError from condax.utils import to_path import condax.condarc as condarc @@ -45,7 +46,7 @@ # https://stackoverflow.com/questions/6198372/most-pythonic-way-to-provide-global-configuration-variables-in-config-py class C: - __conf = { + __conf: Dict[str, Any] = { "mamba_root_prefix": MAMBA_ROOT_PREFIX, "prefix_dir": DEFAULT_PREFIX_DIR, "bin_dir": DEFAULT_BIN_DIR, @@ -76,40 +77,55 @@ def _set(name: str, value) -> None: raise NameError("Name not accepted in set() method") +class BadConfigFileError(CondaxError): + def __init__(self, message: str): + super().__init__(10, message) + + +class MissingConfigFileError(CondaxError): + def __init__(self, message: str): + super().__init__(11, message) + + def set_via_file(config_file: Union[str, Path]): """ Set the object C from using a config file in YAML format. + + Raises: + BadConfigFileError: If the config file is not valid. """ config_file = to_path(config_file) - if config_file.exists(): - with open(config_file, "r") as f: + try: + with config_file.open() as f: config = yaml.safe_load(f) + except FileNotFoundError: + raise MissingConfigFileError(f"Config file {config_file} not found") - if not config: - msg = f"""Config file does not contain config information: Remove {config_file} and try again""" - raise ValueError(msg) + if not config: + msg = f"""Config file does not contain config information: Remove or fix {config_file} and try again""" + raise BadConfigFileError(msg) - # For compatibility with condax 0.0.5 - if "prefix_path" in config: - prefix_dir = to_path(config["prefix_path"]) - C._set("prefix_dir", prefix_dir) + # For compatibility with condax 0.0.5 + if "prefix_path" in config: + prefix_dir = to_path(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"]) - C._set("bin_dir", bin_dir) + # For compatibility with condax 0.0.5 + if "target_destination" in config: + bin_dir = to_path(config["target_destination"]) + C._set("bin_dir", bin_dir) - if "prefix_dir" in config: - prefix_dir = to_path(config["prefix_dir"]) - C._set("prefix_dir", prefix_dir) + if "prefix_dir" in config: + prefix_dir = to_path(config["prefix_dir"]) + C._set("prefix_dir", prefix_dir) - if "bin_dir" in config: - bin_dir = to_path(config["bin_dir"]) - C._set("bin_dir", bin_dir) + if "bin_dir" in config: + bin_dir = to_path(config["bin_dir"]) + C._set("bin_dir", bin_dir) - if "channels" in config: - channels = config["channels"] - C._set("channels", channels) + if "channels" in config: + channels = config["channels"] + C._set("channels", channels + C.channels()) def set_via_value( @@ -127,4 +143,4 @@ def set_via_value( C._set("bin_dir", to_path(bin_dir)) if channels: - C._set("channels", channels) + C._set("channels", channels + C.channels()) diff --git a/condax/core.py b/condax/core.py index 47bf71f..a110e04 100644 --- a/condax/core.py +++ b/condax/core.py @@ -6,9 +6,10 @@ import shutil import sys from pathlib import Path -from typing import Dict, Iterable, List +from typing import Counter, Dict, Iterable, List import condax.conda as conda +from condax.exceptions import CondaxError import condax.metadata as metadata import condax.wrapper as wrapper import condax.utils as utils @@ -16,7 +17,10 @@ from condax.config import C -def create_link(package: str, exe: Path, is_forcing: bool = False): +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 @@ -41,31 +45,32 @@ def create_link(package: str, exe: Path, is_forcing: bool = False): 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"): - print(f"Skip installing app: {executable_name}...") - return + logger.warning(f"Skipped creating entrypoint: {executable_name}") + return False - utils.unlink(script_path) + 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: - print("Created the following entrypoint links:", file=sys.stderr) - - for exe in sorted(executables_to_link): - executable_name = exe.name - print(f" {executable_name}", file=sys.stderr) - create_link(package, exe, is_forcing) + logger.info("\n - ".join(("Created the following entrypoint links:", *linked))) def remove_links(package: str, app_names_to_unlink: Iterable[str]): - if app_names_to_unlink: - print("Removed the following entrypoint links:", file=sys.stderr) - + unlinked: List[str] = [] if os.name == "nt": # FIXME: this is hand-waving for now for executable_name in app_names_to_unlink: @@ -76,41 +81,50 @@ def remove_links(package: str, app_names_to_unlink: Iterable[str]): link_path = _get_wrapper_path(executable_name) wrapper_env = wrapper.read_env_name(link_path) if wrapper_env is None: - print(f" {executable_name} \t (failed to get env)") utils.unlink(link_path) + unlinked.append(f"{executable_name} \t (failed to get env)") elif wrapper_env == package: - print(f" {executable_name}", file=sys.stderr) link_path.unlink() + unlinked.append(executable_name) else: - logging.info( - f"Keep {executable_name} as it runs in {wrapper_env}, not {package}." + 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)) + ) + + +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, is_forcing: bool = False, + conda_stdout: bool = False, ): - # package match specifications - # https://docs.conda.io/projects/conda/en/latest/user-guide/concepts/pkg-specs.html#package-match-specifications - package, match_specs = utils.split_match_specs(spec) + package, _ = utils.split_match_specs(spec) if conda.has_conda_env(package): if is_forcing: - conda.remove_conda_env(package) + logger.warning(f"Overwriting environment for {package}") + conda.remove_conda_env(package, conda_stdout) else: - print( - f"`{package}` is already installed. Run `condax install --force {package}` to force install.", - file=sys.stderr, - ) - sys.exit(1) + raise PackageInstalledError(package) - conda.create_conda_environment(spec) + 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) - print(f"`{package}` has been installed by condax", file=sys.stderr) + logger.info(f"`{package}` has been installed by condax") def inject_package_to( @@ -118,23 +132,18 @@ def inject_package_to( injected_specs: List[str], is_forcing: bool = False, include_apps: bool = False, + conda_stdout: bool = False, ): pairs = [utils.split_match_specs(spec) for spec in injected_specs] injected_packages, _ = zip(*pairs) pkgs_str = " and ".join(injected_packages) if not conda.has_conda_env(env_name): - print( - f"`{env_name}` does not exist; Abort injecting {pkgs_str} ...", - file=sys.stderr, - ) - sys.exit(1) - - # package match specifications - # https://docs.conda.io/projects/conda/en/latest/user-guide/concepts/pkg-specs.html#package-match-specifications + raise PackageNotInstalled(env_name) conda.inject_to_conda_env( injected_specs, env_name, + conda_stdout, ) # update the metadata @@ -148,37 +157,28 @@ def inject_package_to( injected_pkg, ) create_links(env_name, executables_to_link, is_forcing=is_forcing) - print(f"`Done injecting {pkgs_str} to `{env_name}`", file=sys.stderr) + logger.info(f"`Done injecting {pkgs_str} to `{env_name}`") -def uninject_package_from(env_name: str, packages_to_uninject: List[str]): +def uninject_package_from( + env_name: str, packages_to_uninject: List[str], conda_stdout: bool = False +): if not conda.has_conda_env(env_name): - pkgs_str = " and ".join(packages_to_uninject) - print( - f"`The environment {env_name}` does not exist. Abort uninjecting `{pkgs_str}`...", - file=sys.stderr, - ) - sys.exit(1) + raise PackageNotInstalled(env_name) already_injected = set(_get_injected_packages(env_name)) to_uninject = set(packages_to_uninject) not_found = to_uninject - already_injected for pkg in not_found: - print( - f"`{pkg}` is absent in the `{env_name}` environment.", - file=sys.stderr, - ) + logger.info(f"`{pkg}` is absent in the `{env_name}` environment.") found = to_uninject & already_injected if not found: - print( - f"`No package is uninjected from {env_name}`", - file=sys.stderr, - ) - sys.exit(1) + logger.warning(f"`No package is uninjected from {env_name}`") + return packages_to_uninject = sorted(found) - conda.uninject_from_conda_env(packages_to_uninject, env_name) + conda.uninject_from_conda_env(packages_to_uninject, env_name, conda_stdout) injected_app_names = [ app for pkg in packages_to_uninject for app in _get_injected_apps(env_name, pkg) @@ -187,22 +187,29 @@ def uninject_package_from(env_name: str, packages_to_uninject: List[str]): _uninject_from_metadata(env_name, packages_to_uninject) pkgs_str = " and ".join(packages_to_uninject) - print(f"`{pkgs_str}` has been uninjected from `{env_name}`", file=sys.stderr) + 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): + +def exit_if_not_installed(package: str, error: bool = True): prefix = conda.conda_env_prefix(package) if not prefix.exists(): - print(f"`{package}` is not installed with condax", file=sys.stderr) - sys.exit(0) + raise PackageNotInstalled(package, error) -def remove_package(package: str): - exit_if_not_installed(package) +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) - print(f"`{package}` has been removed from condax", file=sys.stderr) + 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): @@ -213,10 +220,8 @@ def update_all_packages(update_specs: bool = False, is_forcing: bool = False): def list_all_packages(short=False, include_injected=False) -> None: if short: _list_all_packages_short(include_injected) - elif include_injected: - _list_all_packages_include_injected() else: - _list_all_packages_default() + _list_all_packages(include_injected) def _list_all_packages_short(include_injected: bool) -> None: @@ -225,8 +230,7 @@ def _list_all_packages_short(include_injected: bool) -> None: """ for package in _get_all_envs(): package_name, package_version, _ = conda.get_package_info(package) - package_header = f"{package_name} {package_version}" - print(package_header) + print(f"{package_name} {package_version}") if include_injected: injected_packages = _get_injected_packages(package_name) for injected_pkg in injected_packages: @@ -234,95 +238,82 @@ def _list_all_packages_short(include_injected: bool) -> None: print(f" {name} {version}") -def _list_all_packages_default() -> None: +def _list_all_packages(include_injected: bool) -> None: """ List packages without any flags """ # messages follow pipx's text format _print_condax_dirs() - executable_counts = collections.Counter() - for package in _get_all_envs(): - _, python_version, _ = conda.get_package_info(package, "python") - package_name, package_version, package_build = conda.get_package_info(package) - - package_header = "".join( - [ - f"{shlex.quote(package_name)}", - f" {package_version} {package_build}", - f", using Python {python_version}" if python_version else "", - ] - ) - print(package_header) - - apps = _get_apps(package) - executable_counts.update(apps) - if not apps: - print(f" (No apps found for {package})") - else: - for app in apps: - app = utils.strip_exe_ext(app) # for windows - print(f" - {app}") - print() + executable_counts: Counter[str] = collections.Counter() + for env in _get_all_envs(): + _list_env(env, executable_counts, include_injected) # warn if duplicate of executables are found duplicates = [name for (name, cnt) in executable_counts.items() if cnt > 1] - if duplicates: - print(f"\n[warning] The following executables are duplicated:") - for name in duplicates: - # TODO: include the package environment linked from the executable - print(f" * {name}") - print() + if duplicates and not include_injected: + logger.warning(f"\n[warning] The following executables conflict:") + logger.warning("\n".join(f" * {name}" for name in duplicates) + "\n") -def _list_all_packages_include_injected(): - """ - List packages with --include-injected flag - """ - # messages follow pipx's text format - _print_condax_dirs() +def _list_env( + env: str, executable_counts: Counter[str], include_injected: bool +) -> None: + _, python_version, _ = conda.get_package_info(env, "python") + package_name, package_version, package_build = conda.get_package_info(env) - for env in _get_all_envs(): - _, python_version, _ = conda.get_package_info(env, "python") - package_name, package_version, package_build = conda.get_package_info(env) - - package_header = "".join( - [ - f"{package_name} {package_version} {package_build}", - f", using Python {python_version}" if python_version else "", - ] - ) - print(package_header) + package_header = "".join( + [ + f"{shlex.quote(package_name)}", + f" {package_version} {package_build}", + f", using Python {python_version}" if python_version else "", + ] + ) + print(package_header) - apps = _get_main_apps(package_name) + apps = _get_apps(env) + executable_counts.update(apps) + if not apps and not include_injected: + print(f" (No apps found for {env})") + else: for app in apps: app = utils.strip_exe_ext(app) # for windows print(f" - {app}") - names_injected_apps = _get_injected_apps_dict(package_name) - for name, injected_apps in names_injected_apps.items(): - for app in injected_apps: - app = utils.strip_exe_ext(app) # for windows - print(f" - {app} (from {name})") + if include_injected: + _list_injected(package_name) + print() - injected_packages = _get_injected_packages(package_name) - if injected_packages: - print(" Included packages:") - for injected_pkg in injected_packages: - name, version, build = conda.get_package_info(package_name, injected_pkg) - print(f" {name} {version} {build}") +def _list_injected(package_name: str): + names_injected_apps = _get_injected_apps_dict(package_name) + for name, injected_apps in names_injected_apps.items(): + for app in injected_apps: + app = utils.strip_exe_ext(app) # for windows + print(f" - {app} (from {name})") + + injected_packages = _get_injected_packages(package_name) + if injected_packages: + print(" Included packages:") - print() + for injected_pkg in injected_packages: + name, version, build = conda.get_package_info(package_name, injected_pkg) + print(f" {name} {version} {build}") def _print_condax_dirs() -> None: - print(f"conda envs are in {C.prefix_dir()}") - print(f"apps are exposed on your $PATH at {C.bin_dir()}") - print() + logger.info( + f"conda envs are in {C.prefix_dir()}\n" + f"apps are exposed on your $PATH at {C.bin_dir()}\n" + ) -def update_package(spec: str, update_specs: bool = False, is_forcing: bool = False) -> 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) @@ -332,7 +323,7 @@ def update_package(spec: str, update_specs: bool = False, is_forcing: bool = Fal injected: set(conda.determine_executables_from_env(env, injected)) for injected in _get_injected_packages(env) } - conda.update_conda_env(spec, update_specs) + 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)) @@ -343,7 +334,7 @@ def update_package(spec: str, update_specs: bool = False, is_forcing: bool = Fal main_apps_before_update == main_apps_after_update and injected_apps_before_update == injected_apps_after_update ): - print(f"No updates found: {env}") + 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 @@ -366,14 +357,14 @@ def update_package(spec: str, update_specs: bool = False, is_forcing: bool = Fal ) create_links(env, to_create, is_forcing) - print(f"{env} update successfully") + logger.info(f"{env} update successfully") except subprocess.CalledProcessError: - print(f"Failed to update `{env}`", file=sys.stderr) - print(f"Recreating the environment...", file=sys.stderr) + logger.error(f"Failed to update `{env}`") + logger.warning(f"Recreating the environment...") - remove_package(env) - install_package(env, is_forcing=is_forcing) + remove_package(env, conda_stdout) + install_package(env, is_forcing=is_forcing, conda_stdout=conda_stdout) # Update metadata file _create_metadata(env) @@ -391,15 +382,20 @@ def _create_metadata(package: str): meta.save() +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: - logging.info(f"Recreating condax_metadata.json in {env}...") + logger.info(f"Recreating condax_metadata.json in {env}...") _create_metadata(env) meta = metadata.load(env) if meta is None: - raise ValueError(f"Failed to recreate condax_metadata.json in {env}") + raise NoMetadataError(env) return meta @@ -434,11 +430,9 @@ 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 utils.is_env_dir(pkg_dir) ) @@ -499,7 +493,7 @@ def _get_wrapper_path(cmd_name: str) -> Path: return p -def export_all_environments(out_dir: str) -> None: +def export_all_environments(out_dir: str, conda_stdout: bool = False) -> None: """Export all environments to a directory. NOTE: Each environment exports two files: @@ -508,14 +502,14 @@ def export_all_environments(out_dir: str) -> None: """ p = Path(out_dir) p.mkdir(parents=True, exist_ok=True) - print("Started exporting all environments to", p) + logger.info(f"Started exporting all environments to {p}") envs = _get_all_envs() for env in envs: - conda.export_env(env, p) + conda.export_env(env, p, conda_stdout) _copy_metadata(env, p) - print("Done.") + logger.info("Done.") def _copy_metadata(env: str, p: Path): @@ -536,7 +530,9 @@ def _overwrite_metadata(envfile: Path): shutil.copyfile(_from, _to, follow_symlinks=True) -def import_environments(in_dir: str, is_forcing: bool) -> None: +def import_environments( + in_dir: str, is_forcing: bool, conda_stdout: bool = False +) -> None: """Import all environments from a directory.""" p = Path(in_dir) print("Started importing environments in", p) @@ -544,12 +540,12 @@ def import_environments(in_dir: str, is_forcing: bool) -> None: env = envfile.stem if conda.has_conda_env(env): if is_forcing: - remove_package(env) + remove_package(env, conda_stdout) else: print(f"Environment {env} already exists. Skipping...") continue - conda.import_env(envfile) + conda.import_env(envfile, is_forcing, conda_stdout) metafile = p / (env + ".json") _overwrite_metadata(metafile) @@ -559,8 +555,7 @@ def import_environments(in_dir: str, is_forcing: bool) -> None: def _get_executables_to_link(env: str) -> List[Path]: - """Return a list of executables to link. - """ + """Return a list of executables to link.""" meta = _load_metadata(env) env = meta.main_package.name @@ -636,8 +631,7 @@ def _add_to_conda_env_list() -> None: def fix_links(): - """Repair condax bash scripts in bin_dir. - """ + """Repair condax bash scripts in bin_dir.""" utils.mkdir(C.bin_dir()) print(f"Repairing links in the BIN_DIR: {C.bin_dir()}...") diff --git a/condax/exceptions.py b/condax/exceptions.py new file mode 100644 index 0000000..2a5609c --- /dev/null +++ b/condax/exceptions.py @@ -0,0 +1,7 @@ +class CondaxError(Exception): + """Base class for known condax errors which are to be graciously presented to the user.""" + + def __init__(self, exit_code, message: str): + super().__init__(message) + self.message = message + self.exit_code = exit_code diff --git a/condax/paths.py b/condax/paths.py index d667736..47f15a1 100644 --- a/condax/paths.py +++ b/condax/paths.py @@ -1,3 +1,4 @@ +import logging import sys from pathlib import Path from typing import Union @@ -5,6 +6,9 @@ import userpath +logger = logging.getLogger(__name__) + + def add_path_to_environment(path: Union[Path, str]) -> None: path = str(path) @@ -13,20 +17,13 @@ def add_path_to_environment(path: Union[Path, str]) -> None: " to take effect." ) if userpath.in_current_path(path): - print( - f"{path} has already been added to PATH.", - file=sys.stderr, - ) + logger.info(f"{path} has already been added to PATH.") return if userpath.need_shell_restart(path): - print( - f"{path} has already been added to PATH. " f"{post_install_message}", - file=sys.stderr, - ) + logger.warning(f"{path} has already been added to PATH. {post_install_message}") return userpath.append(path) - print(f"Success! Added {path} to the PATH environment variable.", file=sys.stderr) - print(file=sys.stderr) - print(post_install_message, file=sys.stderr) + logger.info(f"Success! Added {path} to the PATH environment variable.\n") + logger.info(post_install_message) diff --git a/condax/utils.py b/condax/utils.py index c08f2ed..36ce58e 100644 --- a/condax/utils.py +++ b/condax/utils.py @@ -5,6 +5,8 @@ import re import urllib.parse +from condax.exceptions import CondaxError + pat = re.compile(r"<=|>=|==|!=|<|>|=") @@ -128,12 +130,21 @@ def get_micromamba_url() -> str: elif platform.system() == "Windows" and platform.machine() in ("AMD64", "x86_64"): subdir = "win-64/latest" else: - raise ValueError(f"Unsupported platform: {platform.system()} {platform.machine()}") + raise ValueError( + f"Unsupported platform: {platform.system()} {platform.machine()}" + ) url = urllib.parse.urljoin(base, subdir) return url +class UnsuportedPlatformError(CondaxError): + def __init__(self): + super().__init__( + 30, f"Unsupported platform: {platform.system()} {platform.machine()}" + ) + + def get_conda_url() -> str: """ Get the URL of the latest micromamba release. @@ -146,7 +157,7 @@ def get_conda_url() -> str: elif platform.system() == "Windows" and platform.machine() in ("AMD64", "x86_64"): subdir = "conda-latest-win-64.exe" else: - raise ValueError(f"Unsupported platform: {platform.system()} {platform.machine()}") + raise UnsuportedPlatformError() url = urllib.parse.urljoin(base, subdir) return url diff --git a/environment.yml b/environment.yml index 74c7c9e..bb536ad 100644 --- a/environment.yml +++ b/environment.yml @@ -11,4 +11,7 @@ dependencies: # Test dependencies - pytest - coverage - # Doc depe \ No newline at end of file + # Doc depe + - rainbowlog >=2.0.0 + - types-PyYAML + - types-requests \ No newline at end of file diff --git a/setup.py b/setup.py index ab27e10..99ad0bb 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ with open("README.md", "r", encoding="utf-8") as f: readme = f.read() -REQUIRES = ["click", "requests", "userpath", "PyYAML"] +REQUIRES = ["click", "requests", "userpath", "PyYAML", "rainbowlog>=2.0.0"] setup( name="condax", @@ -43,6 +43,6 @@ install_requires=REQUIRES, tests_require=["coverage", "pytest"], packages=find_packages(exclude=("tests", "tests.*")), - entry_points={"console_scripts": ["condax = condax.cli:cli"]}, + entry_points={"console_scripts": ["condax = condax.cli.__main__:main"]}, zip_safe=True, ) diff --git a/tests/test_condax_update.py b/tests/test_condax_update.py index 8819960..a31f9f9 100644 --- a/tests/test_condax_update.py +++ b/tests/test_condax_update.py @@ -1,9 +1,9 @@ import subprocess import tempfile + def test_condax_update_main_apps(): - """Check if condax update main apps works correctly. - """ + """Check if condax update main apps works correctly.""" from condax.core import ( install_package, update_package, @@ -76,12 +76,15 @@ def test_condax_update_main_apps(): assert res.stdout and (main_version_after_update in res.stdout.decode()) meta = metadata.load(main_pkg) - assert meta and meta.main_package and set(meta.main_package.apps) == main_apps_after_update + assert ( + meta + and meta.main_package + and set(meta.main_package.apps) == main_apps_after_update + ) prefix_fp.cleanup() bin_fp.cleanup() - ## TODO: Add tests for update of injected packages ## diff --git a/tests/test_config.py b/tests/test_config.py index 6558bc3..e32fdbf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -45,4 +45,4 @@ def test_set_via_file_1(): set_via_file(temp.name) assert C.prefix_dir() == Path("/path/to/") assert C.bin_dir() == Path.home() / ".yet/another path/foo" - assert C.channels() == ["fastchan"] + assert "fastchan" in C.channels() From 303ddefac60b22c15e1101768589d65ab00a0287 Mon Sep 17 00:00:00 2001 From: Abraham Murciano Date: Fri, 12 Aug 2022 18:10:46 +0300 Subject: [PATCH 30/34] poetry --- .flake8 | 7 - .github/workflows/pythonpackage.yml | 21 +- .pre-commit-config.yaml | 18 - MANIFEST.in | 2 - condax/__init__.py | 10 +- docs/contributing.md | 18 +- environment.yml | 17 - poetry.lock | 962 ++++++++++++++++++++++++++++ pyproject.toml | 67 +- requirements.txt | 1 - rever.xsh | 3 +- setup.py | 48 -- tox.ini | 5 +- 13 files changed, 1044 insertions(+), 135 deletions(-) delete mode 100644 .flake8 delete mode 100644 .pre-commit-config.yaml delete mode 100644 MANIFEST.in delete mode 100644 environment.yml create mode 100644 poetry.lock delete mode 100644 requirements.txt delete mode 100644 setup.py diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 09ddb36..0000000 --- a/.flake8 +++ /dev/null @@ -1,7 +0,0 @@ -[flake8] -# Recommend matching the black line length (default 88), -# rather than using the flake8 default of 79: -max-line-length = 88 -extend-ignore = - # See https://github.com/PyCQA/pycodestyle/issues/373 - E203, \ No newline at end of file diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 428dd88..465b9a8 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -23,30 +23,21 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - - # - name: Lint with flake8 - # run: | - # pip install flake8 - # # stop the build if there are Python syntax errors or undefined names - # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + pip install poetry + poetry install - name: Test with pytest run: | - pip install pytest - pytest + poetry run pytest - name: Install condax run: | - pip install . - condax ensure-path + poetry run condax ensure-path mkdir -p "${HOME}/.local/bin" - name: Install black via condax run: | - condax install black + poetry run condax install black - name: Run black --help run: | @@ -56,4 +47,4 @@ jobs: - name: Remove black from condax environments run: | - condax remove black + poetry run condax remove black diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 85bd3d9..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -repos: -- repo: https://github.com/pre-commit/mirrors-isort - rev: v5.9.3 - hooks: - - id: isort - args: ["--profile", "black", "--filter-files"] - -- repo: https://github.com/psf/black - rev: 21.8b0 - hooks: - - id: black - language_version: python3 - -- repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.910 - hooks: - - id: mypy - additional_dependencies: [types-requests, types-PyYAML] \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index b3281e9..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.rst -include LICENSE-MIT diff --git a/condax/__init__.py b/condax/__init__.py index 485f44a..a96c2d4 100644 --- a/condax/__init__.py +++ b/condax/__init__.py @@ -1 +1,9 @@ -__version__ = "0.1.1" +import sys + + +if sys.version_info >= (3, 8): + import importlib.metadata as metadata +else: + import importlib_metadata as metadata + +__version__ = metadata.version(__package__) diff --git a/docs/contributing.md b/docs/contributing.md index 1c001bc..6850f76 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,25 +1,27 @@ Thanks for your interest in contributing to condax! -## Dependencies -Dependencies for condax can be installed by using any package manager you like. For convenience a `environment.yaml` file -is provided to get you set up quickly. +## Development environment +To set up a development environment and then activate it, you can use [poetry](https://python-poetry.org/). +```bash +poetry install +poetry shell ``` -conda create env -``` + +From now on it is assumed you are in the development environment. ## Testing condax locally In your environmnent run the tests as follows -``` -python -m pytest -vv . +```bash +pytest tests ``` ## Testing condax on Github Actions When you make a pull request, tests will automatically be run against your code as defined in `.github/workflows/pythonpackage.yml`. These tests are run using github actions ## Creating a pull request -When making a new pull request please create a news file in the `./news` directory. This will automatically be merged into the documentation when new releases are made. +When making a new pull request please create a news file in the `./news` directory. You can make a copy of the provided template. This will automatically be merged into the documentation when new releases are made. ## Documentation `condax` autogenerates API documentation, published on github pages. diff --git a/environment.yml b/environment.yml deleted file mode 100644 index bb536ad..0000000 --- a/environment.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: condax -channels: - - conda-forge - - defaults -dependencies: - - python >=3.7 - - click - - requests - - userpath - - pyyaml - # Test dependencies - - pytest - - coverage - # Doc depe - - rainbowlog >=2.0.0 - - types-PyYAML - - types-requests \ No newline at end of file diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..a4f4ea8 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,962 @@ +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "22.1.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "black" +version = "22.6.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2022.6.15" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "2.1.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[[package]] +name = "colorama" +version = "0.4.5" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "constyle" +version = "1.1.1" +description = "A Python library to add style to your console." +category = "main" +optional = false +python-versions = ">=3.7,<4.0" + +[package.dependencies] +importlib-metadata = ">=4.11.0,<5.0.0" + +[[package]] +name = "coverage" +version = "6.4.3" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "cryptography" +version = "37.0.4" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +sdist = ["setuptools_rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] + +[[package]] +name = "github3.py" +version = "3.2.0" +description = "Python wrapper for the GitHub API(http://developer.github.com/v3)" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +PyJWT = {version = ">=2.3.0", extras = ["crypto"]} +python-dateutil = ">=2.6.0" +requests = ">=2.18" +uritemplate = ">=3.0.0" + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "importlib-metadata" +version = "4.12.0" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "lazyasd" +version = "0.1.4" +description = "Lazy & self-destructive tools for speeding up module imports" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "mypy" +version = "0.971" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +mypy-extensions = ">=0.4.3" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} +typing-extensions = ">=3.10" + +[package.extras] +reports = ["lxml"] +python2 = ["typed-ast (>=1.4.0,<2)"] +dmypy = ["psutil (>=4.0)"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "platformdirs" +version = "2.5.2" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +testing = ["pytest-benchmark", "pytest"] +dev = ["tox", "pre-commit"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyjwt" +version = "2.4.0" +description = "JSON Web Token implementation in Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cryptography = {version = ">=3.3.1", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +docs = ["zope.interface", "sphinx-rtd-theme", "sphinx"] +dev = ["pre-commit", "mypy", "coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)", "cryptography (>=3.3.1)", "zope.interface", "sphinx-rtd-theme", "sphinx"] +crypto = ["cryptography (>=3.3.1)"] + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "dev" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytest" +version = "7.1.2" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +tomli = ">=1.0.0" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "rainbowlog" +version = "2.0.1" +description = "Format your python logs with colours based on the log levels." +category = "main" +optional = false +python-versions = ">=3.7,<4.0" + +[package.dependencies] +constyle = ">=1.0.2,<2.0.0" +importlib-metadata = ">=4.11,<5.0" + +[[package]] +name = "re-ver" +version = "0.5.0" +description = "Release Versions of Software" +category = "dev" +optional = false +python-versions = ">3.4" + +[package.dependencies] +"github3.py" = ">=2" +lazyasd = "*" +"ruamel.yaml" = "*" +xonsh = "*" + +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] + +[[package]] +name = "ruamel.yaml" +version = "0.17.21" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +category = "dev" +optional = false +python-versions = ">=3" + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.6", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""} + +[package.extras] +docs = ["ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel.yaml.clib" +version = "0.2.6" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "typed-ast" +version = "1.5.4" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "types-pyyaml" +version = "6.0.11" +description = "Typing stubs for PyYAML" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "types-requests" +version = "2.28.8" +description = "Typing stubs for requests" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +types-urllib3 = "<1.27" + +[[package]] +name = "types-urllib3" +version = "1.26.22" +description = "Typing stubs for urllib3" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typing-extensions" +version = "4.3.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "urllib3" +version = "1.26.11" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +secure = ["ipaddress", "certifi", "idna (>=2.0.0)", "cryptography (>=1.3.4)", "pyOpenSSL (>=0.14)"] +brotli = ["brotlipy (>=0.6.0)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] + +[[package]] +name = "userpath" +version = "1.8.0" +description = "Cross-platform tool for adding locations to the user PATH" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = "*" + +[[package]] +name = "xonsh" +version = "0.12.2" +description = "Python-powered, cross-platform, Unix-gazing shell" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +zipapp = ["importlib-resources"] +pygments = ["pygments (>=2.2)"] +ptk = ["pyperclip", "prompt-toolkit (>=3.0.27)"] +proctitle = ["setproctitle"] +mac = ["gnureadline"] +linux = ["distro"] +full = ["setproctitle", "distro", "gnureadline", "pygments (>=2.2)", "pyperclip", "prompt-toolkit (>=3.0.27)"] + +[[package]] +name = "zipp" +version = "3.8.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.7" +content-hash = "7c447397b203af9883341e58d8d23c76ffd15dcdc122db06edb885cdd264d2dd" + +[metadata.files] +atomicwrites = [ + {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, +] +attrs = [ + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, +] +black = [ + {file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"}, + {file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"}, + {file = "black-22.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6797f58943fceb1c461fb572edbe828d811e719c24e03375fd25170ada53825e"}, + {file = "black-22.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c85928b9d5f83b23cee7d0efcb310172412fbf7cb9d9ce963bd67fd141781def"}, + {file = "black-22.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6fe02afde060bbeef044af7996f335fbe90b039ccf3f5eb8f16df8b20f77666"}, + {file = "black-22.6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cfaf3895a9634e882bf9d2363fed5af8888802d670f58b279b0bece00e9a872d"}, + {file = "black-22.6.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94783f636bca89f11eb5d50437e8e17fbc6a929a628d82304c80fa9cd945f256"}, + {file = "black-22.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2ea29072e954a4d55a2ff58971b83365eba5d3d357352a07a7a4df0d95f51c78"}, + {file = "black-22.6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e439798f819d49ba1c0bd9664427a05aab79bfba777a6db94fd4e56fae0cb849"}, + {file = "black-22.6.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:187d96c5e713f441a5829e77120c269b6514418f4513a390b0499b0987f2ff1c"}, + {file = "black-22.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:074458dc2f6e0d3dab7928d4417bb6957bb834434516f21514138437accdbe90"}, + {file = "black-22.6.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a218d7e5856f91d20f04e931b6f16d15356db1c846ee55f01bac297a705ca24f"}, + {file = "black-22.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:568ac3c465b1c8b34b61cd7a4e349e93f91abf0f9371eda1cf87194663ab684e"}, + {file = "black-22.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6c1734ab264b8f7929cef8ae5f900b85d579e6cbfde09d7387da8f04771b51c6"}, + {file = "black-22.6.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a3ac16efe9ec7d7381ddebcc022119794872abce99475345c5a61aa18c45ad"}, + {file = "black-22.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:b9fd45787ba8aa3f5e0a0a98920c1012c884622c6c920dbe98dbd05bc7c70fbf"}, + {file = "black-22.6.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ba9be198ecca5031cd78745780d65a3f75a34b2ff9be5837045dce55db83d1c"}, + {file = "black-22.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3db5b6409b96d9bd543323b23ef32a1a2b06416d525d27e0f67e74f1446c8f2"}, + {file = "black-22.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:560558527e52ce8afba936fcce93a7411ab40c7d5fe8c2463e279e843c0328ee"}, + {file = "black-22.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b154e6bbde1e79ea3260c4b40c0b7b3109ffcdf7bc4ebf8859169a6af72cd70b"}, + {file = "black-22.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:4af5bc0e1f96be5ae9bd7aaec219c901a94d6caa2484c21983d043371c733fc4"}, + {file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"}, + {file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"}, +] +certifi = [ + {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"}, + {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"}, +] +cffi = [ + {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"}, + {file = "charset_normalizer-2.1.0-py3-none-any.whl", hash = "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5"}, +] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] +colorama = [ + {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, + {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, +] +constyle = [ + {file = "constyle-1.1.1-py3-none-any.whl", hash = "sha256:c74da2dcae11653ae6df14072c48cf7a090c75263b0911cdc2426c921f716639"}, + {file = "constyle-1.1.1.tar.gz", hash = "sha256:1b2e84fa3616880e3811e44846abb8174b0aafd2cf1757298f046cb5f290893d"}, +] +coverage = [ + {file = "coverage-6.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f50d3a822947572496ea922ee7825becd8e3ae6fbd2400cd8236b7d64b17f285"}, + {file = "coverage-6.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d5191d53afbe5b6059895fa7f58223d3751c42b8101fb3ce767e1a0b1a1d8f87"}, + {file = "coverage-6.4.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04010af3c06ce2bfeb3b1e4e05d136f88d88c25f76cd4faff5d1fd84d11581ea"}, + {file = "coverage-6.4.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6630d8d943644ea62132789940ca97d05fac83f73186eaf0930ffa715fbdab6b"}, + {file = "coverage-6.4.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05de0762c1caed4a162b3e305f36cf20a548ff4da0be6766ad5c870704be3660"}, + {file = "coverage-6.4.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0e3a41aad5919613483aad9ebd53336905cab1bd6788afd3995c2a972d89d795"}, + {file = "coverage-6.4.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a2738ba1ee544d6f294278cfb6de2dc1f9a737a780469b5366e662a218f806c3"}, + {file = "coverage-6.4.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a0d2df4227f645a879010461df2cea6b7e3fb5a97d7eafa210f7fb60345af9e8"}, + {file = "coverage-6.4.3-cp310-cp310-win32.whl", hash = "sha256:73a10939dc345460ca0655356a470dd3de9759919186a82383c87b6eb315faf2"}, + {file = "coverage-6.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:53c8edd3b83a4ddba3d8c506f1359401e7770b30f2188f15c17a338adf5a14db"}, + {file = "coverage-6.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1eda5cae434282712e40b42aaf590b773382afc3642786ac3ed39053973f61f"}, + {file = "coverage-6.4.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59fc88bc13e30f25167e807b8cad3c41b7218ef4473a20c86fd98a7968733083"}, + {file = "coverage-6.4.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75314b00825d70e1e34b07396e23f47ed1d4feedc0122748f9f6bd31a544840"}, + {file = "coverage-6.4.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52f8b9fcf3c5e427d51bbab1fb92b575a9a9235d516f175b24712bcd4b5be917"}, + {file = "coverage-6.4.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5a559aab40c716de80c7212295d0dc96bc1b6c719371c20dd18c5187c3155518"}, + {file = "coverage-6.4.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:306788fd019bb90e9cbb83d3f3c6becad1c048dd432af24f8320cf38ac085684"}, + {file = "coverage-6.4.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:920a734fe3d311ca01883b4a19aa386c97b82b69fbc023458899cff0a0d621b9"}, + {file = "coverage-6.4.3-cp37-cp37m-win32.whl", hash = "sha256:ab9ef0187d6c62b09dec83a84a3b94f71f9690784c84fd762fb3cf2d2b44c914"}, + {file = "coverage-6.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:39ebd8e120cb77a06ee3d5fc26f9732670d1c397d7cd3acf02f6f62693b89b80"}, + {file = "coverage-6.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc698580216050b5f4a34d2cdd2838b429c53314f1c4835fab7338200a8396f2"}, + {file = "coverage-6.4.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:877ee5478fd78e100362aed56db47ccc5f23f6e7bb035a8896855f4c3e49bc9b"}, + {file = "coverage-6.4.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:555a498999c44f5287cc95500486cd0d4f021af9162982cbe504d4cb388f73b5"}, + {file = "coverage-6.4.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff095a5aac7011fdb51a2c82a8fae9ec5211577f4b764e1e59cfa27ceeb1b59"}, + {file = "coverage-6.4.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5de1e9335e2569974e20df0ce31493d315a830d7987e71a24a2a335a8d8459d3"}, + {file = "coverage-6.4.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7856ea39059d75f822ff0df3a51ea6d76307c897048bdec3aad1377e4e9dca20"}, + {file = "coverage-6.4.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:411fdd9f4203afd93b056c0868c8f9e5e16813e765de962f27e4e5798356a052"}, + {file = "coverage-6.4.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cdf7b83f04a313a21afb1f8730fe4dd09577fefc53bbdfececf78b2006f4268e"}, + {file = "coverage-6.4.3-cp38-cp38-win32.whl", hash = "sha256:ab2b1a89d2bc7647622e9eaf06128a5b5451dccf7c242deaa31420b055716481"}, + {file = "coverage-6.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:0e34247274bde982bbc613894d33f9e36358179db2ed231dd101c48dd298e7b0"}, + {file = "coverage-6.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b104b6b1827d6a22483c469e3983a204bcf9c6bf7544bf90362c4654ebc2edf3"}, + {file = "coverage-6.4.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:adf1a0d272633b21d645dd6e02e3293429c1141c7d65a58e4cbcd592d53b8e01"}, + {file = "coverage-6.4.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff9832434a9193fbd716fbe05f9276484e18d26cc4cf850853594bb322807ac3"}, + {file = "coverage-6.4.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:923f9084d7e1d31b5f74c92396b05b18921ed01ee5350402b561a79dce3ea48d"}, + {file = "coverage-6.4.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d64304acf79766e650f7acb81d263a3ea6e2d0d04c5172b7189180ff2c023c"}, + {file = "coverage-6.4.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:fc294de50941d3da66a09dca06e206297709332050973eca17040278cb0918ff"}, + {file = "coverage-6.4.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a42eaaae772f14a5194f181740a67bfd48e8806394b8c67aa4399e09d0d6b5db"}, + {file = "coverage-6.4.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4822327b35cb032ff16af3bec27f73985448f08e874146b5b101e0e558b613dd"}, + {file = "coverage-6.4.3-cp39-cp39-win32.whl", hash = "sha256:f217850ac0e046ede611312703423767ca032a7b952b5257efac963942c055de"}, + {file = "coverage-6.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:0a84376e4fd13cebce2c0ef8c2f037929c8307fb94af1e5dbe50272a1c651b5d"}, + {file = "coverage-6.4.3-pp36.pp37.pp38-none-any.whl", hash = "sha256:068d6f2a893af838291b8809c876973d885543411ea460f3e6886ac0ee941732"}, + {file = "coverage-6.4.3.tar.gz", hash = "sha256:ec2ae1f398e5aca655b7084392d23e80efb31f7a660d2eecf569fb9f79b3fb94"}, +] +cryptography = [ + {file = "cryptography-37.0.4-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:549153378611c0cca1042f20fd9c5030d37a72f634c9326e225c9f666d472884"}, + {file = "cryptography-37.0.4-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:a958c52505c8adf0d3822703078580d2c0456dd1d27fabfb6f76fe63d2971cd6"}, + {file = "cryptography-37.0.4-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f721d1885ecae9078c3f6bbe8a88bc0786b6e749bf32ccec1ef2b18929a05046"}, + {file = "cryptography-37.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3d41b965b3380f10e4611dbae366f6dc3cefc7c9ac4e8842a806b9672ae9add5"}, + {file = "cryptography-37.0.4-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80f49023dd13ba35f7c34072fa17f604d2f19bf0989f292cedf7ab5770b87a0b"}, + {file = "cryptography-37.0.4-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2dcb0b3b63afb6df7fd94ec6fbddac81b5492513f7b0436210d390c14d46ee8"}, + {file = "cryptography-37.0.4-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:b7f8dd0d4c1f21759695c05a5ec8536c12f31611541f8904083f3dc582604280"}, + {file = "cryptography-37.0.4-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:30788e070800fec9bbcf9faa71ea6d8068f5136f60029759fd8c3efec3c9dcb3"}, + {file = "cryptography-37.0.4-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:190f82f3e87033821828f60787cfa42bff98404483577b591429ed99bed39d59"}, + {file = "cryptography-37.0.4-cp36-abi3-win32.whl", hash = "sha256:b62439d7cd1222f3da897e9a9fe53bbf5c104fff4d60893ad1355d4c14a24157"}, + {file = "cryptography-37.0.4-cp36-abi3-win_amd64.whl", hash = "sha256:f7a6de3e98771e183645181b3627e2563dcde3ce94a9e42a3f427d2255190327"}, + {file = "cryptography-37.0.4-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bc95ed67b6741b2607298f9ea4932ff157e570ef456ef7ff0ef4884a134cc4b"}, + {file = "cryptography-37.0.4-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:f8c0a6e9e1dd3eb0414ba320f85da6b0dcbd543126e30fcc546e7372a7fbf3b9"}, + {file = "cryptography-37.0.4-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:e007f052ed10cc316df59bc90fbb7ff7950d7e2919c9757fd42a2b8ecf8a5f67"}, + {file = "cryptography-37.0.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bc997818309f56c0038a33b8da5c0bfbb3f1f067f315f9abd6fc07ad359398d"}, + {file = "cryptography-37.0.4-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d204833f3c8a33bbe11eda63a54b1aad7aa7456ed769a982f21ec599ba5fa282"}, + {file = "cryptography-37.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:75976c217f10d48a8b5a8de3d70c454c249e4b91851f6838a4e48b8f41eb71aa"}, + {file = "cryptography-37.0.4-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:7099a8d55cd49b737ffc99c17de504f2257e3787e02abe6d1a6d136574873441"}, + {file = "cryptography-37.0.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2be53f9f5505673eeda5f2736bea736c40f051a739bfae2f92d18aed1eb54596"}, + {file = "cryptography-37.0.4-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:91ce48d35f4e3d3f1d83e29ef4a9267246e6a3be51864a5b7d2247d5086fa99a"}, + {file = "cryptography-37.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4c590ec31550a724ef893c50f9a97a0c14e9c851c85621c5650d699a7b88f7ab"}, + {file = "cryptography-37.0.4.tar.gz", hash = "sha256:63f9c17c0e2474ccbebc9302ce2f07b55b3b3fcb211ded18a42d5764f5c10a82"}, +] +"github3.py" = [ + {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"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +importlib-metadata = [ + {file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"}, + {file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +lazyasd = [ + {file = "lazyasd-0.1.4.tar.gz", hash = "sha256:a3196f05cff27f952ad05767e5735fd564b4ea4e89b23f5ea1887229c3db145b"}, +] +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"}, + {file = "mypy-0.971-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:19830b7dba7d5356d3e26e2427a2ec91c994cd92d983142cbd025ebe81d69cf3"}, + {file = "mypy-0.971-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:02ef476f6dcb86e6f502ae39a16b93285fef97e7f1ff22932b657d1ef1f28655"}, + {file = "mypy-0.971-cp310-cp310-win_amd64.whl", hash = "sha256:25c5750ba5609a0c7550b73a33deb314ecfb559c350bb050b655505e8aed4103"}, + {file = "mypy-0.971-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d3348e7eb2eea2472db611486846742d5d52d1290576de99d59edeb7cd4a42ca"}, + {file = "mypy-0.971-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3fa7a477b9900be9b7dd4bab30a12759e5abe9586574ceb944bc29cddf8f0417"}, + {file = "mypy-0.971-cp36-cp36m-win_amd64.whl", hash = "sha256:2ad53cf9c3adc43cf3bea0a7d01a2f2e86db9fe7596dfecb4496a5dda63cbb09"}, + {file = "mypy-0.971-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:855048b6feb6dfe09d3353466004490b1872887150c5bb5caad7838b57328cc8"}, + {file = "mypy-0.971-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:23488a14a83bca6e54402c2e6435467a4138785df93ec85aeff64c6170077fb0"}, + {file = "mypy-0.971-cp37-cp37m-win_amd64.whl", hash = "sha256:4b21e5b1a70dfb972490035128f305c39bc4bc253f34e96a4adf9127cf943eb2"}, + {file = "mypy-0.971-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9796a2ba7b4b538649caa5cecd398d873f4022ed2333ffde58eaf604c4d2cb27"}, + {file = "mypy-0.971-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5a361d92635ad4ada1b1b2d3630fc2f53f2127d51cf2def9db83cba32e47c856"}, + {file = "mypy-0.971-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b793b899f7cf563b1e7044a5c97361196b938e92f0a4343a5d27966a53d2ec71"}, + {file = "mypy-0.971-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d1ea5d12c8e2d266b5fb8c7a5d2e9c0219fedfeb493b7ed60cd350322384ac27"}, + {file = "mypy-0.971-cp38-cp38-win_amd64.whl", hash = "sha256:23c7ff43fff4b0df93a186581885c8512bc50fc4d4910e0f838e35d6bb6b5e58"}, + {file = "mypy-0.971-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1f7656b69974a6933e987ee8ffb951d836272d6c0f81d727f1d0e2696074d9e6"}, + {file = "mypy-0.971-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d2022bfadb7a5c2ef410d6a7c9763188afdb7f3533f22a0a32be10d571ee4bbe"}, + {file = "mypy-0.971-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef943c72a786b0f8d90fd76e9b39ce81fb7171172daf84bf43eaf937e9f220a9"}, + {file = "mypy-0.971-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d744f72eb39f69312bc6c2abf8ff6656973120e2eb3f3ec4f758ed47e414a4bf"}, + {file = "mypy-0.971-cp39-cp39-win_amd64.whl", hash = "sha256:77a514ea15d3007d33a9e2157b0ba9c267496acf12a7f2b9b9f8446337aac5b0"}, + {file = "mypy-0.971-py3-none-any.whl", hash = "sha256:0d054ef16b071149917085f51f89555a576e2618d5d9dd70bd6eea6410af3ac9"}, + {file = "mypy-0.971.tar.gz", hash = "sha256:40b0f21484238269ae6a57200c807d80debc6459d444c0489a102d7c6a75fa56"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pathspec = [ + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, +] +platformdirs = [ + {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, + {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pycparser = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] +pyjwt = [ + {file = "PyJWT-2.4.0-py3-none-any.whl", hash = "sha256:72d1d253f32dbd4f5c88eaf1fdc62f3a19f676ccbadb9dbc5d07e951b2b26daf"}, + {file = "PyJWT-2.4.0.tar.gz", hash = "sha256:d42908208c699b3b973cbeb01a969ba6a96c821eefb1c5bfe4c390c01d67abba"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] +pytest = [ + {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, + {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] +pyyaml = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] +rainbowlog = [ + {file = "rainbowlog-2.0.1-py3-none-any.whl", hash = "sha256:4bdce44f60741cddfe8542122a7bef35b4de6f53c69f260ae97de22711ecdc8d"}, + {file = "rainbowlog-2.0.1.tar.gz", hash = "sha256:20eeda543dd6724622cc7a16f286a478b605e948ab263ca220da437cf6d73c5a"}, +] +re-ver = [ + {file = "re-ver-0.5.0.tar.gz", hash = "sha256:d41e6da1f368735f6da1a1131543744b09068e317bdccc7a4a4070e0408bf0fc"}, +] +requests = [ + {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, + {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, +] +"ruamel.yaml" = [ + {file = "ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7"}, + {file = "ruamel.yaml-0.17.21.tar.gz", hash = "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af"}, +] +"ruamel.yaml.clib" = [ + {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6e7be2c5bcb297f5b82fee9c665eb2eb7001d1050deaba8471842979293a80b0"}, + {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:221eca6f35076c6ae472a531afa1c223b9c29377e62936f61bc8e6e8bdc5f9e7"}, + {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-win32.whl", hash = "sha256:1070ba9dd7f9370d0513d649420c3b362ac2d687fe78c6e888f5b12bf8bc7bee"}, + {file = "ruamel.yaml.clib-0.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:77df077d32921ad46f34816a9a16e6356d8100374579bc35e15bab5d4e9377de"}, + {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:cfdb9389d888c5b74af297e51ce357b800dd844898af9d4a547ffc143fa56751"}, + {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7b2927e92feb51d830f531de4ccb11b320255ee95e791022555971c466af4527"}, + {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win32.whl", hash = "sha256:ada3f400d9923a190ea8b59c8f60680c4ef8a4b0dfae134d2f2ff68429adfab5"}, + {file = "ruamel.yaml.clib-0.2.6-cp35-cp35m-win_amd64.whl", hash = "sha256:de9c6b8a1ba52919ae919f3ae96abb72b994dd0350226e28f3686cb4f142165c"}, + {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d67f273097c368265a7b81e152e07fb90ed395df6e552b9fa858c6d2c9f42502"}, + {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:72a2b8b2ff0a627496aad76f37a652bcef400fd861721744201ef1b45199ab78"}, + {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-win32.whl", hash = "sha256:9efef4aab5353387b07f6b22ace0867032b900d8e91674b5d8ea9150db5cae94"}, + {file = "ruamel.yaml.clib-0.2.6-cp36-cp36m-win_amd64.whl", hash = "sha256:846fc8336443106fe23f9b6d6b8c14a53d38cef9a375149d61f99d78782ea468"}, + {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0847201b767447fc33b9c235780d3aa90357d20dd6108b92be544427bea197dd"}, + {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:78988ed190206672da0f5d50c61afef8f67daa718d614377dcd5e3ed85ab4a99"}, + {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-win32.whl", hash = "sha256:a49e0161897901d1ac9c4a79984b8410f450565bbad64dbfcbf76152743a0cdb"}, + {file = "ruamel.yaml.clib-0.2.6-cp37-cp37m-win_amd64.whl", hash = "sha256:bf75d28fa071645c529b5474a550a44686821decebdd00e21127ef1fd566eabe"}, + {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a32f8d81ea0c6173ab1b3da956869114cae53ba1e9f72374032e33ba3118c233"}, + {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7f7ecb53ae6848f959db6ae93bdff1740e651809780822270eab111500842a84"}, + {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-win32.whl", hash = "sha256:89221ec6d6026f8ae859c09b9718799fea22c0e8da8b766b0b2c9a9ba2db326b"}, + {file = "ruamel.yaml.clib-0.2.6-cp38-cp38-win_amd64.whl", hash = "sha256:31ea73e564a7b5fbbe8188ab8b334393e06d997914a4e184975348f204790277"}, + {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc6a613d6c74eef5a14a214d433d06291526145431c3b964f5e16529b1842bed"}, + {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1866cf2c284a03b9524a5cc00daca56d80057c5ce3cdc86a52020f4c720856f0"}, + {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-win32.whl", hash = "sha256:3fb9575a5acd13031c57a62cc7823e5d2ff8bc3835ba4d94b921b4e6ee664104"}, + {file = "ruamel.yaml.clib-0.2.6-cp39-cp39-win_amd64.whl", hash = "sha256:825d5fccef6da42f3c8eccd4281af399f21c02b32d98e113dbc631ea6a6ecbc7"}, + {file = "ruamel.yaml.clib-0.2.6.tar.gz", hash = "sha256:4ff604ce439abb20794f05613c374759ce10e3595d1867764dd1ae675b85acbd"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +typed-ast = [ + {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"}, + {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"}, + {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"}, + {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"}, + {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"}, + {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"}, + {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"}, + {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"}, + {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"}, + {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"}, + {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"}, + {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"}, + {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"}, + {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"}, + {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"}, + {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"}, + {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"}, + {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"}, + {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"}, + {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"}, + {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"}, + {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"}, + {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, + {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, +] +types-pyyaml = [ + {file = "types-PyYAML-6.0.11.tar.gz", hash = "sha256:7f7da2fd11e9bc1e5e9eb3ea1be84f4849747017a59fc2eee0ea34ed1147c2e0"}, + {file = "types_PyYAML-6.0.11-py3-none-any.whl", hash = "sha256:8f890028123607379c63550179ddaec4517dc751f4c527a52bb61934bf495989"}, +] +types-requests = [ + {file = "types-requests-2.28.8.tar.gz", hash = "sha256:7a9f7b152d594a1c18dd4932cdd2596b8efbeedfd73caa4e4abb3755805b4685"}, + {file = "types_requests-2.28.8-py3-none-any.whl", hash = "sha256:b0421f9f2d0dd0f8df2c75f974686517ca67473f05b466232d4c6384d765ad7a"}, +] +types-urllib3 = [ + {file = "types-urllib3-1.26.22.tar.gz", hash = "sha256:b05af90e73889e688094008a97ca95788db8bf3736e2776fd43fb6b171485d94"}, + {file = "types_urllib3-1.26.22-py3-none-any.whl", hash = "sha256:09a8783e1002472e8d1e1f3792d4c5cca1fffebb9b48ee1512aae6d16fe186bc"}, +] +typing-extensions = [ + {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, + {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, +] +uritemplate = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] +urllib3 = [ + {file = "urllib3-1.26.11-py2.py3-none-any.whl", hash = "sha256:c33ccba33c819596124764c23a97d25f32b28433ba0dedeb77d873a38722c9bc"}, + {file = "urllib3-1.26.11.tar.gz", hash = "sha256:ea6e8fb210b19d950fab93b60c9009226c63a28808bc8386e05301e25883ac0a"}, +] +userpath = [ + {file = "userpath-1.8.0-py3-none-any.whl", hash = "sha256:f133b534a8c0b73511fc6fa40be68f070d9474de1b5aada9cded58cdf23fb557"}, + {file = "userpath-1.8.0.tar.gz", hash = "sha256:04233d2fcfe5cff911c1e4fb7189755640e1524ff87a4b82ab9d6b875fee5787"}, +] +xonsh = [ + {file = "xonsh-0.12.2-py3-none-any.whl", hash = "sha256:beaf89134812d3bff79712cb68f7c0fa0f67ed73e02a4762c43d2c7a87346148"}, + {file = "xonsh-0.12.2.tar.gz", hash = "sha256:0876164a54f95f0bcf574236850c0880397238274cc4de31d89086f086dbeb09"}, +] +zipp = [ + {file = "zipp-3.8.1-py3-none-any.whl", hash = "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"}, + {file = "zipp-3.8.1.tar.gz", hash = "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2"}, +] diff --git a/pyproject.toml b/pyproject.toml index f243bd9..aad14da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,56 @@ -[metadata] -name = 'condax' -version = '0.1.1' -description = 'Install and run applications packaged with conda in isolated environments' -author = 'Marius van Niekerk' -author_email = 'marius.v.niekerk@gmail.com' -license = 'MIT' -url = 'https://github.com/_/condax' +[tool.poetry] +name = "condax" +version = "1.0.0" +description = "Install and run applications packaged with conda in isolated environments" +authors = [ + "Marius van Niekerk ", + "Abraham Murciano ", +] +license = "MIT" +readme = "README.md" +homepage = "https://github.com/mariusvniekerk/condax" +repository = "https://github.com/mariusvniekerk/condax" +documentation = "https://mariusvniekerk.github.io/condax/" +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Software Development :: Build Tools", + "Topic :: System :: Installation/Setup", + "Topic :: System :: Systems Administration", +] -[requires] -python_version = ['pypy', 'pypy3'] +[tool.poetry.dependencies] +python = "^3.7" +click = "^8.1.3" +requests = "^2.28.1" +userpath = "^1.8.0" +PyYAML = "^6.0" +importlib-metadata = "^4.12.0" +rainbowlog = "^2.0.1" -[build-system] -requires = ['setuptools', 'wheel'] +[tool.poetry.dev-dependencies] +pytest = "^7.1.2" +coverage = "^6.4.3" +black = "^22.6.0" +mypy = "^0.971" +re-ver = "^0.5.0" +types-PyYAML = "^6.0.11" +types-requests = "^2.28.8" + +[tool.poetry.scripts] +condax = "condax.cli.__main__:main" -[tool.hatch.commands] -prerelease = 'hatch build' +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d6e1198..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ --e . diff --git a/rever.xsh b/rever.xsh index 76e90d2..0dee36a 100644 --- a/rever.xsh +++ b/rever.xsh @@ -14,8 +14,7 @@ $GITHUB_REPO = 'condax' $VERSION_BUMP_PATTERNS = [ # These note where/how to find the version numbers - ('condax/__init__.py', '__version__\s*=.*', '__version__ = "$VERSION"'), - ('setup.py', 'version\s*=.*,', 'version="$VERSION",'), + ('pyproject.toml', 'version\s*=.*', 'version = "$VERSION"'), ] $CHANGELOG_FILENAME = 'docs/changelog.md' diff --git a/setup.py b/setup.py deleted file mode 100644 index 99ad0bb..0000000 --- a/setup.py +++ /dev/null @@ -1,48 +0,0 @@ -#################### Maintained by Hatch #################### -# This file is auto-generated by hatch. If you'd like to customize this file -# please add your changes near the bottom marked for 'USER OVERRIDES'. -# EVERYTHING ELSE WILL BE OVERWRITTEN by hatch. -############################################################# -from io import open - -from setuptools import find_packages, setup - -with open("condax/__init__.py", "r") as f: - for line in f: - if line.startswith("__version__"): - version = line.strip().split("=")[1].strip(" '\"") - break - else: - version = "0.1.1" - -with open("README.md", "r", encoding="utf-8") as f: - readme = f.read() - -REQUIRES = ["click", "requests", "userpath", "PyYAML", "rainbowlog>=2.0.0"] - -setup( - name="condax", - version=version, - description="Install and run applications packaged with conda in isolated environments", - long_description=readme, - long_description_content_type="text/markdown", - author="Marius van Niekerk", - author_email="marius.v.niekerk@gmail.com", - maintainer="Marius van Niekerk", - maintainer_email="marius.v.niekerk@gmail.com", - url="https://github.com/mariusvniekerk/condax", - license="MIT", - classifiers=[ - "Development Status :: 4 - Beta", - "License :: OSI Approved :: MIT License", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - ], - python_requires=">=3.7", - install_requires=REQUIRES, - tests_require=["coverage", "pytest"], - packages=find_packages(exclude=("tests", "tests.*")), - entry_points={"console_scripts": ["condax = condax.cli.__main__:main"]}, - zip_safe=True, -) diff --git a/tox.ini b/tox.ini index 373e0df..b5fe0e1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,9 @@ [tox] envlist = - py36, py37, py38, + py39, + py310, pypy3, [testenv] @@ -11,7 +12,7 @@ deps = coverage pytest commands = - python setup.py --quiet clean develop + pip install -e . --quiet coverage run --parallel-mode -m pytest coverage combine --append coverage report -m From 1d834f2446488be713164b216f3104e7a118a2a0 Mon Sep 17 00:00:00 2001 From: Abraham Murciano Date: Mon, 15 Aug 2022 17:05:44 +0300 Subject: [PATCH 31/34] Tidied up cli (#9) --- condax/cli/__init__.py | 102 +----------------------------------- condax/cli/ensure_path.py | 8 +-- condax/cli/export.py | 16 +++--- condax/cli/inject.py | 27 +++++----- condax/cli/install.py | 22 +++----- condax/cli/list.py | 4 +- condax/cli/options.py | 106 ++++++++++++++++++++++++++++++++++++++ condax/cli/remove.py | 14 ++--- condax/cli/repair.py | 4 +- condax/cli/update.py | 10 ++-- condax/utils.py | 2 +- 11 files changed, 156 insertions(+), 159 deletions(-) create mode 100644 condax/cli/options.py diff --git a/condax/cli/__init__.py b/condax/cli/__init__.py index 3ac07a1..e1c83de 100644 --- a/condax/cli/__init__.py +++ b/condax/cli/__init__.py @@ -1,72 +1,9 @@ -import logging -from statistics import median -import rainbowlog -from pathlib import Path -from typing import Callable, Optional - import click import condax.config as config from condax import __version__ -option_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, -) - -option_channels = click.option( - "--channel", - "-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, -) - -option_envname = click.option( - "--name", - "-n", - "envname", - required=True, - prompt="Specify the environment (Run `condax list --short` to see available ones)", - type=str, - help=f"""Specify existing environment to inject into.""", - callback=lambda _, __, n: n.strip(), -) - -option_is_forcing = click.option( - "-f", - "--force", - "is_forcing", - help="""Modify existing environment and files in CONDAX_BIN_DIR.""", - is_flag=True, - default=False, -) - - -def options_logging(f: Callable) -> Callable: - option_verbose = click.option( - "-v", - "--verbose", - count=True, - help="Raise verbosity level.", - callback=lambda _, __, v: _LoggerSetup.set_verbose(v), - ) - option_quiet = click.option( - "-q", - "--quiet", - count=True, - help="Decrease verbosity level.", - callback=lambda _, __, q: _LoggerSetup.set_quiet(q), - ) - return option_verbose(option_quiet(f)) - - @click.group( help=f"""Install and execute applications packaged by conda. @@ -80,41 +17,6 @@ def options_logging(f: Callable) -> Callable: __version__, message="%(prog)s %(version)s", ) -@option_config -@options_logging +@click.help_option("-h", "--help") def cli(**_): - """Main entry point for condax.""" - pass - - -class _LoggerSetup: - handler = logging.StreamHandler() - formatter = rainbowlog.Formatter(logging.Formatter()) - logger = logging.getLogger((__package__ or __name__).split(".", 1)[0]) - verbose = 0 - quiet = 0 - - @classmethod - def setup(cls) -> int: - """Setup the logger. - - Returns: - int: The log level. - """ - cls.handler.setFormatter(cls.formatter) - cls.logger.addHandler(cls.handler) - level = logging.INFO - 10 * (int(median((-1, 3, cls.verbose - cls.quiet)))) - cls.logger.setLevel(level) - return level - - @classmethod - def set_verbose(cls, v: int) -> int: - """Set the verbose level and return the new log level.""" - cls.verbose += v - return cls.setup() - - @classmethod - def set_quiet(cls, q: int): - """Set the quiet level and return the new log level.""" - cls.quiet += q - return cls.setup() + return diff --git a/condax/cli/ensure_path.py b/condax/cli/ensure_path.py index 75d5763..07b0a72 100644 --- a/condax/cli/ensure_path.py +++ b/condax/cli/ensure_path.py @@ -1,11 +1,8 @@ -from pathlib import Path -from typing import Optional - import condax.config as config import condax.paths as paths from condax import __version__ -from . import cli, option_config, options_logging +from . import cli, options @cli.command( @@ -13,7 +10,6 @@ Ensure the condax links directory is on $PATH. """ ) -@option_config -@options_logging +@options.common def ensure_path(**_): paths.add_path_to_environment(config.C.bin_dir()) diff --git a/condax/cli/export.py b/condax/cli/export.py index 977846b..68ab101 100644 --- a/condax/cli/export.py +++ b/condax/cli/export.py @@ -4,7 +4,7 @@ import condax.core as core from condax import __version__ -from . import cli, option_is_forcing, options_logging +from . import cli, options @cli.command( @@ -17,9 +17,9 @@ default="condax_exported", help="Set directory to export to.", ) -@options_logging -def export(dir: str, verbose: int, **_): - core.export_all_environments(dir, conda_stdout=verbose <= logging.INFO) +@options.common +def export(dir: str, log_level: int, **_): + core.export_all_environments(dir, conda_stdout=log_level <= logging.INFO) @cli.command( @@ -28,14 +28,14 @@ def export(dir: str, verbose: int, **_): [experimental] Import condax environments. """, ) -@option_is_forcing -@options_logging +@options.is_forcing +@options.common @click.argument( "directory", required=True, type=click.Path(exists=True, dir_okay=True, file_okay=False), ) -def run_import(directory: str, is_forcing: bool, verbose: int, **_): +def run_import(directory: str, is_forcing: bool, log_level: int, **_): core.import_environments( - directory, is_forcing, conda_stdout=verbose <= logging.INFO + directory, is_forcing, conda_stdout=log_level <= logging.INFO ) diff --git a/condax/cli/inject.py b/condax/cli/inject.py index b2aaf52..89e4046 100644 --- a/condax/cli/inject.py +++ b/condax/cli/inject.py @@ -2,11 +2,10 @@ from typing import List import click -import condax.config as config import condax.core as core from condax import __version__ -from . import cli, option_channels, option_envname, option_is_forcing, options_logging +from . import cli, options @cli.command( @@ -14,23 +13,23 @@ Inject a package to existing environment created by condax. """ ) -@option_channels -@option_envname -@option_is_forcing +@options.channels +@options.envname +@options.is_forcing @click.option( "--include-apps", help="""Make apps from the injected package available.""", is_flag=True, default=False, ) -@options_logging -@click.argument("packages", nargs=-1, required=True) +@options.common +@options.packages def inject( packages: List[str], envname: str, is_forcing: bool, include_apps: bool, - verbose: int, + log_level: int, **_, ): core.inject_package_to( @@ -38,7 +37,7 @@ def inject( packages, is_forcing=is_forcing, include_apps=include_apps, - conda_stdout=verbose <= logging.INFO, + conda_stdout=log_level <= logging.INFO, ) @@ -47,8 +46,8 @@ def inject( Uninject a package from an existing environment. """ ) -@option_envname -@options_logging -@click.argument("packages", nargs=-1, required=True) -def uninject(packages: List[str], envname: str, verbose: int, **_): - core.uninject_package_from(envname, packages, verbose <= logging.INFO) +@options.envname +@options.common +@options.packages +def uninject(packages: List[str], envname: str, log_level: int, **_): + core.uninject_package_from(envname, packages, log_level <= logging.INFO) diff --git a/condax/cli/install.py b/condax/cli/install.py index 7159b4e..2fa4a54 100644 --- a/condax/cli/install.py +++ b/condax/cli/install.py @@ -7,14 +7,7 @@ import condax.core as core from condax import __version__ -from . import ( - cli, - option_config, - option_channels, - option_is_forcing, - option_channels, - options_logging, -) +from . import cli, options @cli.command( @@ -25,18 +18,17 @@ provided by it to `{config.DEFAULT_BIN_DIR}`. """ ) -@option_channels -@option_config -@option_is_forcing -@options_logging -@click.argument("packages", nargs=-1) +@options.channels +@options.is_forcing +@options.common +@options.packages def install( packages: List[str], is_forcing: bool, - verbose: int, + log_level: int, **_, ): for pkg in packages: core.install_package( - pkg, is_forcing=is_forcing, conda_stdout=verbose <= logging.INFO + pkg, is_forcing=is_forcing, conda_stdout=log_level <= logging.INFO ) diff --git a/condax/cli/list.py b/condax/cli/list.py index ca64f8e..1f8d943 100644 --- a/condax/cli/list.py +++ b/condax/cli/list.py @@ -3,7 +3,7 @@ import condax.core as core from condax import __version__ -from . import cli, options_logging +from . import cli, options @cli.command( @@ -27,6 +27,6 @@ default=False, help="Show packages injected into the main app's environment.", ) -@options_logging +@options.common def run_list(short: bool, include_injected: bool, **_): core.list_all_packages(short=short, include_injected=include_injected) diff --git a/condax/cli/options.py b/condax/cli/options.py new file mode 100644 index 0000000..4220b00 --- /dev/null +++ b/condax/cli/options.py @@ -0,0 +1,106 @@ +import logging +import rainbowlog +from statistics import median +from typing import Callable, Sequence +from pathlib import Path +from functools import wraps + +from condax import config + +import click + + +def common(f: Callable) -> Callable: + """ + This decorator adds common options to the CLI. + """ + options: Sequence[Callable] = ( + config_file, + log_level, + click.help_option("-h", "--help"), + ) + + for op in options: + f = op(f) + + return f + + +packages = click.argument("packages", nargs=-1, required=True) + +config_file = 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, +) + +channels = click.option( + "--channel", + "-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, +) + +envname = click.option( + "--name", + "-n", + "envname", + required=True, + prompt="Specify the environment (Run `condax list --short` to see available ones)", + type=str, + help=f"""Specify existing environment to inject into.""", + callback=lambda _, __, n: n.strip(), +) + +is_forcing = click.option( + "-f", + "--force", + "is_forcing", + help="""Modify existing environment and files in CONDAX_BIN_DIR.""", + is_flag=True, + default=False, +) + +verbose = click.option( + "-v", + "--verbose", + count=True, + help="Raise verbosity level.", +) + +quiet = click.option( + "-q", + "--quiet", + count=True, + help="Decrease verbosity level.", +) + + +def log_level(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 + @quiet + @wraps(f) + def setup_logging_hook(verbose: int, quiet: int, **kwargs): + handler = logging.StreamHandler() + logger = logging.getLogger((__package__ or __name__).split(".", 1)[0]) + handler.setFormatter(rainbowlog.Formatter(logging.Formatter())) + logger.addHandler(handler) + level = int( + median( + (logging.DEBUG, logging.INFO - 10 * (verbose - quiet), logging.CRITICAL) + ) + ) + logger.setLevel(level) + return f(log_level=level, **kwargs) + + return setup_logging_hook diff --git a/condax/cli/remove.py b/condax/cli/remove.py index 44a72a8..3e51d29 100644 --- a/condax/cli/remove.py +++ b/condax/cli/remove.py @@ -5,7 +5,7 @@ import condax.core as core from condax import __version__ -from . import cli, options_logging +from . import cli, options @cli.command( @@ -16,11 +16,11 @@ conda environment. """ ) -@options_logging -@click.argument("packages", nargs=-1) -def remove(packages: List[str], verbose: int, **_): +@options.common +@options.packages +def remove(packages: List[str], log_level: int, **_): for pkg in packages: - core.remove_package(pkg, conda_stdout=verbose <= logging.INFO) + core.remove_package(pkg, conda_stdout=log_level <= logging.INFO) @cli.command( @@ -28,7 +28,7 @@ def remove(packages: List[str], verbose: int, **_): Alias for condax remove. """ ) -@options_logging -@click.argument("packages", nargs=-1) +@options.common +@options.packages def uninstall(packages: List[str], **_): remove(packages) diff --git a/condax/cli/repair.py b/condax/cli/repair.py index 8e0e36e..a9f8cbd 100644 --- a/condax/cli/repair.py +++ b/condax/cli/repair.py @@ -6,7 +6,7 @@ import condax.migrate as migrate from condax import __version__ -from . import cli, options_logging +from . import cli, options @cli.command( @@ -23,7 +23,7 @@ is_flag=True, default=False, ) -@options_logging +@options.common def repair(is_migrating, **_): if is_migrating: migrate.from_old_version() diff --git a/condax/cli/update.py b/condax/cli/update.py index 425b168..026f8cb 100644 --- a/condax/cli/update.py +++ b/condax/cli/update.py @@ -7,7 +7,7 @@ import condax.core as core from condax import __version__ -from . import cli, options_logging +from . import cli, options @cli.command( @@ -23,7 +23,7 @@ @click.option( "--update-specs", is_flag=True, help="Update based on provided specifications." ) -@options_logging +@options.common @click.argument("packages", required=False, nargs=-1) @click.pass_context def update( @@ -31,13 +31,15 @@ def update( all: bool, packages: List[str], update_specs: bool, - verbose: int, + log_level: int, **_ ): if all: core.update_all_packages(update_specs) elif packages: for pkg in packages: - core.update_package(pkg, update_specs, conda_stdout=verbose <= logging.INFO) + core.update_package( + pkg, update_specs, conda_stdout=log_level <= logging.INFO + ) else: ctx.fail("No packages specified.") diff --git a/condax/utils.py b/condax/utils.py index 36ce58e..de285de 100644 --- a/condax/utils.py +++ b/condax/utils.py @@ -71,7 +71,7 @@ def is_executable(path: Path) -> bool: for ext in os.environ.get("PATHEXT", "").split(os.pathsep) ] ext = path.suffix.lower() - return ext and (ext in pathexts) + return bool(ext) and (ext in pathexts) return os.access(path, os.X_OK) From a2a466ef58e194ad5cb6eccab9e2d3a4254de123 Mon Sep 17 00:00:00 2001 From: Abraham Murciano Date: Tue, 16 Aug 2022 00:41:02 +0300 Subject: [PATCH 32/34] WIP: Refactoring 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 zcmeHuhgVbE_H_V7MS**-L@pp;N4g?KIu<~>^cq0v9qB!YqS6#;k&Z|W0i-2BXo~dS zODNJMlu(2Kf#loqz26`4jq%2ygtPZKXP33tnrqHQ#8XXWMh12U2n52Y^5n4&1VRfw z(n8MCgI~Y>hL6CnbM8-!Js}XLX6iRhn_Gc3xX9+EXym2qX6xnq%)+37z;OgXQ z^~~Ky$j!qpbyJ2N0=WWFdHg`nFKuJmH;vObm_Q-&2KMexSL`{*8L#M^~XPgwP& z7?nCt@R{27cBc{64*d?E1rpN;L6z7^|GsV_>5zVZy{7vg2;`1x{|1E zP{0Qg@3w?!6MSxFJOEc%>OLCo@roFs`h&(dbYxzEOSkRSOfQKZjB1L_j4k`{W;~>J z6X1{2UFqRO9zHWuy!bb!Ynug}m@LaZi{OW9xMe71@)kyI`!e;~l^$6e)n){-a9KGf zavxC*d%r>*%4r_ckJ>AYXL3Vx^Q6VGu=hOFBX&BUbn-Sal1J}^+nY~Pd(%fU<1zJh z3EK!J#~m%D8!8&U9((XxU}6g2o}dK^iPOqARiKSgg~!YzLWYYlYQuxC50j+a@K4dA z{3?#M=|z-^tKb<7-+yvoMd1U{Hwu3n)gVK3*E|$wlt9D5%=cbnM%YO34kfi=P{_C> zap635;YycOEAVwSj|}_{qq#Y6Yp>fe3K=d1|7qGg?)m%XnVYUe%+ zo;90t^hGd39C+9;usx2ezVr61Wyk0ayuOd6g}L1hTz-brasH?lJKXY zCY+Skq0t?vUURUEkp7|;bzE+X*Cf3|7PC;e;35=imFjiZk^!{J`q|B}DN$xf%C!f#fYw+vf{W8KA)e6_HpUG__63cQ9 z(o@X}I|$TCx}ARl1X=M3QalRW%c^PZKEA{mT$P>I~74w>z%PbgGTFqpT@{ zi+-azq_Crz7vN%%-|f7ew?#W<+H+Sxi%bWDDZX|t%cEstD5PQ4r-UpK1}<;qyrg(O zJTGmhMXDLJT0#b7iT6RFm#CxlToy_m-Lm+dvAwHd;fg>VkLbRmj!CP-z<1*mOxWBNH>N<;3*xY8M`D#F2F1zSPzbEm+|4J^b zMBTcEJPFa|;^#A?w(#6YMGTV~zSsq89B=k8YdelX8>HGP{96ji+dBxlZw2JjA0MotTIvI6K#&(ZY2eqUSoT9V@@H z!wX#9B}|Xgao?L8oZ21R9xWlprAGwOA*UHzgZ9A)4(8jYDX_i3?-hzjib2#X{kbx* z(W5tEsZx8$`m2Rrk90a(Uk}Ed%f;)IM;c&eaDu?YR#m!~KQi<#Izo6y0)MguFYv*l z!tQL1Y6`Arz=i_ZH=XqD?=C)7^pl|l=G?Alolv612SezU+o%-Q<_voC-ZV|3USiX_ zZCHN9FI`#@mXwzvkyp49Q+Oo5R=Ij{g@4jw-p9B>4Bk=s#$v1@k9g|hRk4uzkB6KwSN$Pa>J8g3r7CEdFTEVn z)~Cnz?eodF9tJJN%SeWNV1x`$@}y3F?d~VW2UJJ}uwl9c zZdYif>a=Y%j_mtdpvs$}u80Nyi*PAa1S?bNVQ`gv^u2RKr@<8HM4)WB9RH}w*%p&J zf7I%o?7&wd&#xq7GjrDBq;`avSL@b@riI!nbQo3k8VK!^v-X0*Cy%J$@NN2T zXT7(|gh$C#y?|-TNZAKjlneKGG2Ws-d;eS*2tw;m^PX?Wx0P887v)rRzszW)3yyf& zx6Te(Sf(CwfA}J4=bU_19(j37zUX%TMa<^dX+wF%C1%4K5Jv?zL2yW|1$TA^-%TW} zto;I>Gx|pd=UQUaqSv|=yuVjsQ@gTn-JGT-4y8F2WI4^4nd0h(sp3L0(m+nPMJ^^t zjdj$E*Wim)Ubc1V<$JF$!291z$Z5<;dctG&$$NKiiVK&l)#obn>m6Fg^gy2pcAs>F z3%~GqYba19{OdrOd-U{}S+>GtU5`^ZOVr-JcRZzu%P_chX^E5=#V z^SQjgN2!X6)A}9y`!<`ry7-3ucjzgP&$!%0eW@fJqW9ae6S6%#0!gqF3&)*>w4djX zleWnD5>>LD$UL{(l*XXuisS%L}hLv(Pl< z->;#Y5%uuTRQ;e=uorxYNKG^EU`I2Qdbzd|{ktA?6n=6G}9AC;U=i zbL>e(KdrGma4*;Bu7t8)K+eP5wytR*n`Uqnyn_$=-|%dY!i$XO3g2nye`7ioD$}KB zt+`uwa@TM(=A7{}&CSrvCDn4N{cU;sv9-*SEw7KbCFdu7-ft5f)t&95sn9|;)fsu# z=PG3U*ESEajlhI(ey-))#8Q~|TJv~=CY|zLjivp_vxhXUc1G;5?K%fn{{ER4ifB~8q4KZ`UxcFid#MA)dBTOqx$i?1lA=q|V2w#KP zLwSg;uRU@>Z`3pZ2FS(7HF$E5d^~M&Y;JF=bVAh}(R_V{k#AL?;xpfP@lU#@8qLkZ z*4KjaixBr)vIKcl+(jEcX}wh+-Xf8MJfR*!Mwt!j0475sBEv2~48~1dpAUVypX;FL zmGyXlrh-V=uXT1vf->bX7Kx8Vh~`Ns)Wn&s zPp(OmiKMDEq-;j!IeGYYhsP&v_PLfIt`-;9qm~jd^QqNbF6+l~OWp8Y&(+s&O4s#|UEO9b5y+{{ zs%XIp5rfqM27NNRHL2`p^mivCMo7_LoC4a%MX6o`MZzQdL^IU<%wXMR3J^u{X>O|JT+7Y}g*+4{{@SxGBD5v{alo@JZFw!A|<&j(ydTo#5Kj>1? z@6WF;3s7PL-~Mg@uJzSD5azD%#xf!IhE`VVm^TfsEI2P@{K!+>t0TVq(q>fkUu0bA zmTf(ho|>B`pUR#58UTn7+Pm>e>BP^|?=zcPDb4`Fjr$?$W7q>UN^LX6ZE$`c2MQeF ze_>o??!TpWzeqBEm9l&1RXsJga7Nn=P6r5>-^qW8@tKse1(RfX;_0|e*PsS*XBW(z zMs}V_PjAT(?ECej_>9>EM1ZxUaQDrt@<@-BkZrzjyC^-%#w9TP3NUEla2y^dQw`{v=LO0yQ+74@tSo9l}igx)xFUhKVF!mLnoqX_teiSlR^ZInDXa-5lU zl?KVkN)dgBzA95DfL_#3LsJ;}D}VMi(gVK?(uKZYm%dw;f~6hIJ;Lzm`u*J_Kg9*@ zs$dUnt?T_?zh2N=%zlZS9WC&%=pI-=ee!R8tzzJ;oKri#`&V^ zeD#*;Tt0ZUs(xePo-=C)FmvF??RQRJPnnhFpBOIdf!2g(H@EO@^6I}ZOjecd(km0r z`;~>6^d1XtREZa6AMKt|Sz+2~S3iasZtDBBPDnE)8>s>So#5PSzBlXm;@I`vI&{2Z zMTDC|?|+RyZfMxERSclDp3UUSlebe{Q)eL&`=>SPEFbG4VjLa19-4pH$cZZv896bH z$2Sb2wonowg*}_;dSh>yct<|DV_B3HNov%XgrpQ{>Ng_wudIFlnc3))*W%v#OGvof4M{EN}n88Fh@n{_b& zXdWhC6Kyt-Khr3OKX@TdIbjkzF1mMdRmozcG=n%X3(wlb_Dp(og`+>5w>y-B7+qBZ zxL%KflS+)pof2x(KRgSeMi|n}mo8t!3;(1r7agjZ4j)vZ>nuKM%G;T>$sc`n;ix>& zzQX!+?_|&MaLORjtELhY)IT`acWmydRp!=n?7$q)fd5{!U+~+nrg%XJ1m~Zeja3vO zC84Zo!*y>0Da?C!Pqg|mDNJfJ;OX=jr#N|$Aj)s=@Utf!UrnM}&?FD;coaAIULKn3 zlh3hMBagbQfV`O8boGlCXzH6Cw)rBpRdvQ|p0JNYKkG?Zq@DB_3V+|{(Wl7e0KHu0 z=AQvY4|_L1Q098}BgDL~V>UmC17e#Y`T3Q_6oM!ys{w#FzP<5S^Of7ChfRGsqMMMR z-3DWxBwyH5S0dM0teSk^PuYO$HF4*th+>{ z$YpP-CfD{s?C>GU$BzGofi@Tke(lL4~d5}S0m6pyRFSBv9lJUrQO-iZu=Irx@NpfN}F+sErL^al_6(M@U`Sv;o44U7&sKw_qrzYFXQ^drD0Cuq)J}S zdO`WCSvgpBZwh8&d*R7olc}^Ae4ra?k?Cx%LkLV;Gk!XyiyRlmb8>JW&P74)9Iep3 zFRgDWk6>kX3X8CFLVxu%xQ>v%H^AU@&$pxU{cNqguB6w_TL#}cjTaa%4;GPVI7?y0OQ9wxV zr3kQUD6%w4%Xj^Z6I8>Ke}49CaQ($N?Nn>})!LCpiZ_Pr&xO!|7HlIf{F5z{LwfW!!Q-Gr z{SKR6*9uxHAi51-v;Xd&R8~OV{D(`pKY5u~#x&=MuxK|!m$tGBxkJwWtS`I!y)~xZ zh%ZIO6@S*x?Zsjl!?gu8IZKj|^B{X_VoBT=MC5*glrTblv&>y~H?o?NgmC;>WV0 zpm|eY3F0@*^Z1-$w@(q^9*`7|2Fd=&xv}5&%f=T~M|cdyB;CKTH;iNh%(>9^fPB9( z#3tP~V`0jcf3D_hWMC%?HGJyb$5hZp?41|`TI*{~obu^%8mMn#p*>%Azw`J-HC)9_ zV;jC9RcB)_=fdIN_jvRIZpdR^8(tS-*FOyL4MdyL0Mrk@_&L>hbrTyq>N}Ed#!x0= z@r@(%zg|qr)xatOd3J}s3Vfa7tFF!;-Y&Jda`EYctjl{Yy2F)e$k(yu__&)RLh-iKUu^E_nUWPnK%->6v>u*>wS`6eLxnZ6#Wu&SBf`T}`T<6Sk zQ&4M0W91qD?^=QcuSi;EB2zoJ7JaC*gTZ<1I<8hE_S`Qj!dfTO<5~r7sF`j|yXS$N z!I6UHWXO>ry>>7b$||Qzlp@e!^-rRtyrib9CttlPxQ|(_wo^uI+od?HFzdNIhM@WJqV%bh zY>>oF9(|rhzonw1RXb^kZ+?o@!)doWcDbE5k$Jda{b?@Frs-;i%st!AjX-)MpIGjh zijKr_jA-YE@v_ftY0KHZ^CVhl^&Tyb;UfnUR~PEjw6p{1Q@$?oq`Grw7(54HX)KKP z@?r-hRGRo*{_KbX7pt00_ejLe*N%@nQnCTx#H(T9;EO$VzdN3$$skFdCud2GYclcRyv1LAyqkjh z8z1J;@00tW?5VD}&Gy$yJWyW*?|zpQrYp3WK^`!=^{u57xNukA4>VcT!1c@F9+a-W z?-5nI2LWlrN+MVB)73H$T&r5^MRZIR6^1*mE!^Z$RQiHE(f)$7xxx(4cF<@1*uJcq z)|lk19I-4i?^@*S>W5{8ZLIjvx52-H5Htr551U8OF-xtRh;6JyU_)kzU*40w*}bs^ z1{61VkMQF_HD`{ln42jf_Dq0?HGP0u{)Pt>-oe9xoM2h@bu)79C2#pHSI?}UI!_p9 zvs`LbsPDaMy?`F{*=IM#y`*#8wfD?=19#E(s|5!%RvB=kWXlDU014bHJsB3=z0$g> zR=L=71>5F<2$Sz5aC@vB$X<8#JhN&EV*Vjv*VdH*TP(xBDWqF(m_oNi8#&vN)OhO} zxOhHAcJOk+GC2TBOsa*^n<;?og_VUGh?-CM2?rY+t_TPM47cg#cB!k5UuV5w*QX8c`1WUyO zma08|qCTFzrmk;8q*uVca5s-B*Gb3KFiZvvMS;tjPXvF|f3-u}6q068dn>nOjX9t} z)A=&>g;^_fZxbBVt6{CkZW+&qUrO=5*bDcm!(`$hS-eWAa~|Fh8O z{QDjo6j~8itcE8!QM_^gN59OYaJk?(UVway4z^iRJ(!bCRY)n_vs&t0C*3=B?+5>o z@l`b*SE(%LLV1d-fQ#|dS?v2782#n>9>~{!%Uw|F(Gw`U=CLEaWPGKI)#7h7A%)Qgh+p zhh+}`;y>feQQFT!#9>&gp_QUrNt)n6m~DAZv{(~M%wG`ok==&ts+Y#0yHhuZ*lUrv z4H3)hZuFsCB-+p$d-W&Pw)=xKJ6_kWw=hf|wA;*wH?xNnQ(Oau)<0DQTl^mE^)PLF zC9e(>H~ETNV``{T+hx_l!swmnJ0O2;2+sdQsv zZQdYza4HP`Hu@qxRIX8?~YuV}kP5Z*N*6;F?dF&VQ4Vz!lFyD{3T+3HrwQm&SU$UFhH8Jp8 zJ3L!)eo(?>Ux#sl(PZHz#Mo=f$H-x{gFp=v-aIv4ydxpziU^hA2yppen<=&A!{&e2 zd!m4vbpq-J3u6#(szBTZNvtRKl$GAGTwYd87flS}e8K+P7nmXiEd`zN_0g$du=IFP|NN9y<82bGLcM^m|EJIB>rCf4-L-p$ZiwY zH2x0!LGhW({;%I0Z#hF^bKaC4oTudq{<3mDdb^uk)9?WJM&hvJnfpAPKx{mEf8cI__&1ZWo9PM_Nq)zERa5*|SbVa3@%iX#l8(Do!+zXoZ1kPp-L0MjVEO) zL4Sh{2WrXi(W(n|!R;*~(DlUUrw1Djx83!ROEi!ta$UDIEEPSdLUPS*wOP$J22-gQ z3p6%r6|RUd{jCwV10a7FZ8wMmMEr26;nO6==WW8m=b4G&5c!f(__a?d*%V4z|NP-T zx<^P99R9tAm3KHBOK1=ja>HS$t6FkC%fwE!=2U=!!Fb^3b6tQg0GhphxDTwxFjejaxz^`f zGaeeF>`j&y9vEdN=va&5eI)%^GkA4C`s}pAv0w0df~>R}ICi(kOll59A^p8IlSHnP z*c(+ru0`J~W@>dYAcuVACgK!-Lw?&Em_FdG0bU1mr;z^friOrpCl$lrE{QVVyHGWa zo_=@bFF=VcRhaJOQ1%2`2$PF7IF$P+h@)a2HN*^3seSFqKr3u+;#vz~%lvxjx1w4x zlIT6O%6@8Cjk6H@2jm8N!;w6A32<3P7lkhm9JdU4<#{N$^X42(rw;pB6y;?|=S8r- zw_j$vSk7Q$ct;I+3{&@>bua*B2kK#>!}F&-sjdcRlcqiKQdR$0$fvVy-oj~Uyo)}; zd9S~M_=hd6T4RBbiu>j+ym=0&hSCv-V-V@we2* za)64&%x}0pojJE$D)T~254hznI}CDnnTeH8szNVV2PScO7-(8%T3oNlM#KEzP^LN^mCPABR=nq5x@Pu=iSu(V&+do@)oboTCu5h#`kex&gceYM z%3VWE^Q;J`41gPUr>^%~@)evfoqU9I{;)M;!wjjm2I?kzKnzBU{PE-Sk>l!ZR4MPQ zMeew89p;{m5GaWeJY%1`{x?86u|Ta3D$8{N^&y5;?717dIN6%aHlVKW)h~R)*(iW6 zPle)!wkd9nfC9#-Nfv<&22XYy0&@I@uy!-1?yxTS58+fIyKDM21oXE(|74n5CMW!S zePfIZ$T*!+e&LRd31VkV2E0!me`?Yc^Ml}^TiN*IWu&<4 zX2%eFNIHfGc1Z+i^w$N~f$R{&IkT$u;?g(0r!f{zrelG0t63)Uff+r}3A>-g=^5+B zmb9Sn0#%6|z>!UB{vr4r1)?V5PC5ezN5q+e!xvvVxl78+u91vBFHK*nSPCEc)I){% z(IUI3bDZRrSzg`M4K@ltZB1y(=FCseAT>v<3)T*|b;~qq_~hFo;B2YIMc0iUr~wsx zZM=<=<(v#J5x0_ecv|VU(6`k^H00girRU)DXkE1V!Vce##o7D|MAWRG)Uf$kcte*m z!gDOPHf7>^Y=uB;x$IhdW2QEp?ma z==%B!sDrQg$=N4tm?Z3LK^wJcNuQG2`DSI(m_3gHlmNnqyDQ1sCVbj|IZd#y8r>?Z z`)sz)xCH2)ak_cet^=T^u0Q#1dNHl~$YQ`xD^H6- zk-@;SFQY3+^tvr4txg_avHH?=ie7GFai`2tnKC)OSd|}EwR7n8%}fy#JN@TO>L?>N z08{Xo$5ry@6piy!;GNScC2BI*paBBIK2wuQ5=(}Vj$gnA$MeN($=tO#)iIt#0L64a z8lZQ)D808$lMRqUg{T-qIY>7P1%IneD?p4gQ}B^8oqx#Vk2ycveeaei&~?RTDjI~| z-N$}shH(6)P7eiDTwa!YPVSxqY2}Hl@t=RHf4d4p1c(~x+1|mg_DWW=kwv}AHhUn@jET_w?iGidp;W}ebF@=<49_ANeS7nM!oYTJiIk| zX#{{W$42kBxLg(%+R#3i8~5)t(rhK=3A<_gahj4{f2ew90pfcnV5*8ea3YkC*rK<) zpoCH5{r42)(OqiS?6Ly(hB-|{!*ZY7$d9Oc++}-3V%1wJa31*l9P~VU3WAypAc&`} zcRWoGm>2186KIh+tWhR&+)-J}@6)|A*L!bs%ghLrVHwU!f2i>|9_ggkU|GGT3f>Hh zGBTb6JZB;9X7*Q6{SW({@@h26JJPkyx-y@`mS$}C8dYX#0m;QW)fa>Q5==X|hn zo{`Z?RevI8Nqq-L(%+C!#w<9Uc=p^{@#OKqc@Pp?AoO3fk_sD}vp~L^~WwQs0tItXI|#$x^*Z73Eue{Z2tx?k`LD zsR4GVa_>5prCk?0yN3@RyBlLR0U&Xajz_kcbm`<5%R!ulZYkgZ?h7tUjPrkDK<;It z3%6&)$Lx+vmGZ8AIiVa`G^}<|PqKLM_rMQ&baQ}E?mz`gXp?itwfTe-U;Ay)V3KnLDWpwPcRKp*FLnrZNzjwS@-*Q z-po>Ue%8Isd-$Kv0W=)r59zy=$hE&>fCh33iEtRRl1f*6R!gyxB&KEIN%%__l+PTD zUR;*YlNS;|b-UbOVtU;A@q`yRd{wC~J+gZuUkI!2p75HP(}q)ihdkFnglYXOu+f%B zcQ+1-c+@HxDY5&EJRZOzgqJQmdYyjlbazOKT-Rn%WPUJTy6Pz9P z&v~+gXFoiDHGCOiYNkvd1qA|<*VmjYJfBC7bF{CQZW0v%f1MJDPk2MlHq`KS^}(|A zEieJ!i~^ZG{wFJZEoxaIj-{Qxa-l~{|L`RrSd7Z=QAH4lX(DHVUQZwUl#tcPAI(83 zLGa6&)(L7M-^%dO2b|0-_GcNDiU^+72HA@!B}jq>QWC+GRItqf48H8ve@U5Xp=PSA z9IEedG=C}F?zoC%4$frac;zo2wm*fiC{;fp)Uq(316xxT|CFaTf6UDc0XqpnnKX}? z>QAj?zJC)}={|>|pP2$FWd^m>4`uQPp!IrUqs2G^U?AI+TLn!IsZA=tvh>KaBVZKb z-hQ$)rJok2WQqg2Bc+E*#=vJl z)|a6`eV*EyeELe!vI2j=tiL+Yo57U4$)sKyh|E z&2$%WJa+j>90+>_1XvqL-1GtBX{rrh{!WF>m@Xs4fZ+vuPy!5^#HdYg4$6Q$05mlN zBrm}DC0D>>#BTuRZRBmw%NxAZ>f#vA@-R8mA$vLi6)c;dhd49+epjQGC+4M(!PzW= zx*&*Ulm_Oo6g=mfgs%g6-T~9q{--GRk_~4f@-TvuLZ=c3E%%yFVgjOpDSm}8!JA%@2`PI=dmqDiujqhoEA<`eB;q; zoX!16aQQ0?$PH`x;2!%HY5^w*3ZUf;XmNiGQU&Ce1F&Kp1w47{DhQmehVy5^~I?|a9S-{Pl(C&7uM z;66A#8$;o2#ye+cfsb5Kd>c99?v{$PaefCyd(G$3btc&YXC+R3;zCN}V98b`95`v6 zau=0)f+Qfq(7-Q2F0T-QRkT`&-qu3wCy;=OtI!pZpC^?M;37PA364ste{L#S{4&HL%v& zKYjH_wF^_YNmyWUyi~@93Dm*58kU0^Jcz3WRG2g0?C-#D=fD7#DtTdn#TanZOmYlPubL0&L%R(&*SY7$kKy?}= zktf;aiKOQM=B5G?ln<%}cu&dG;Qv!lkjc9c$g#jkLO;mU5(_o?b@x8OpF7wfb$KYW z!hPfX+>xtcjREBe=c{3zY~j;FV3pgSmN~wiUkQSNidnQsvAW{&V&griL++Z+A7J%_ z-!xhB@DNnKCP%7skx%8P1Oq2%KK3QAv~uNSyoh>050rqc;Vyv8F8hU?tr+c(rJ@wX zlzTCq+lC(S$%%y0$t@$zpVVo6h>u!KD4z7Qf5((yKjxHC(jP`F!PFaqHlg!*qrUlb z12yLh<9FGphy6H|xJgT$^VbL94c4N__Fwo%uTqWZ&q6|90uzD!`wsm0-^c&0!~ZWv 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 8ea8254177d477ac7dcf619c56fb49f69f60e02f Mon Sep 17 00:00:00 2001 From: Abraham Murciano Date: Sun, 21 Aug 2022 00:24:39 +0300 Subject: [PATCH 33/34] 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 b886fde9cd6867022e8ecb9f67ac14b0194b91e3 Mon Sep 17 00:00:00 2001 From: Abraham Murciano Date: Sun, 21 Aug 2022 02:41:15 +0300 Subject: [PATCH 34/34] WIP: Refactoring 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