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