# Copyright (C) 2012 Anaconda, Inc # SPDX-License-Identifier: BSD-3-Clause """Environment object describing the conda environment.yaml file.""" import json import os import re from itertools import chain from os.path import abspath, expanduser, expandvars from ..base.context import context from ..cli import common, install from ..common.iterators import groupby_to_dict as groupby from ..common.iterators import unique from ..common.serialize import yaml_safe_dump, yaml_safe_load from ..core.prefix_data import PrefixData from ..exceptions import EnvironmentFileEmpty, EnvironmentFileNotFound from ..gateways.connection.download import download_text from ..gateways.connection.session import CONDA_SESSION_SCHEMES from ..history import History from ..models.enums import PackageType from ..models.match_spec import MatchSpec from ..models.prefix_graph import PrefixGraph VALID_KEYS = ("name", "dependencies", "prefix", "channels", "variables") def validate_keys(data, kwargs): """Check for unknown keys, remove them and print a warning""" invalid_keys = [] new_data = data.copy() if data else {} for key in data.keys(): if key not in VALID_KEYS: invalid_keys.append(key) new_data.pop(key) if invalid_keys: filename = kwargs.get("filename") verb = "are" if len(invalid_keys) != 1 else "is" plural = "s" if len(invalid_keys) != 1 else "" print( f"\nEnvironmentSectionNotValid: The following section{plural} on " f"'{filename}' {verb} invalid and will be ignored:" ) for key in invalid_keys: print(f" - {key}") print() deps = data.get("dependencies", []) depsplit = re.compile(r"[<>~\s=]") is_pip = lambda dep: "pip" in depsplit.split(dep)[0].split("::") lists_pip = any(is_pip(dep) for dep in deps if not isinstance(dep, dict)) for dep in deps: if isinstance(dep, dict) and "pip" in dep and not lists_pip: print( "Warning: you have pip-installed dependencies in your environment file, " "but you do not list pip itself as one of your conda dependencies. Conda " "may not use the correct pip to install your packages, and they may end up " "in the wrong place. Please add an explicit pip dependency. I'm adding one" " for you, but still nagging you." ) new_data["dependencies"].insert(0, "pip") break return new_data def from_environment( name, prefix, no_builds=False, ignore_channels=False, from_history=False ): """ Get ``Environment`` object from prefix Args: name: The name of environment prefix: The path of prefix no_builds: Whether has build requirement ignore_channels: whether ignore_channels from_history: Whether environment file should be based on explicit specs in history Returns: Environment object """ pd = PrefixData(prefix, pip_interop_enabled=True) variables = pd.get_environment_env_vars() if from_history: history = History(prefix).get_requested_specs_map() deps = [str(package) for package in history.values()] return Environment( name=name, dependencies=deps, channels=list(context.channels), prefix=prefix, variables=variables, ) precs = tuple(PrefixGraph(pd.iter_records()).graph) grouped_precs = groupby(lambda x: x.package_type, precs) conda_precs = sorted( ( *grouped_precs.get(None, ()), *grouped_precs.get(PackageType.NOARCH_GENERIC, ()), *grouped_precs.get(PackageType.NOARCH_PYTHON, ()), ), key=lambda x: x.name, ) pip_precs = sorted( ( *grouped_precs.get(PackageType.VIRTUAL_PYTHON_WHEEL, ()), *grouped_precs.get(PackageType.VIRTUAL_PYTHON_EGG_MANAGEABLE, ()), *grouped_precs.get(PackageType.VIRTUAL_PYTHON_EGG_UNMANAGEABLE, ()), ), key=lambda x: x.name, ) if no_builds: dependencies = ["=".join((a.name, a.version)) for a in conda_precs] else: dependencies = ["=".join((a.name, a.version, a.build)) for a in conda_precs] if pip_precs: dependencies.append({"pip": [f"{a.name}=={a.version}" for a in pip_precs]}) channels = list(context.channels) if not ignore_channels: for prec in conda_precs: canonical_name = prec.channel.canonical_name if canonical_name not in channels: channels.insert(0, canonical_name) return Environment( name=name, dependencies=dependencies, channels=channels, prefix=prefix, variables=variables, ) def from_yaml(yamlstr, **kwargs): """Load and return a ``Environment`` from a given ``yaml`` string""" data = yaml_safe_load(yamlstr) filename = kwargs.get("filename") if data is None: raise EnvironmentFileEmpty(filename) data = validate_keys(data, kwargs) if kwargs is not None: for key, value in kwargs.items(): data[key] = value _expand_channels(data) return Environment(**data) def _expand_channels(data): """Expands ``Environment`` variables for the channels found in the ``yaml`` data""" data["channels"] = [ os.path.expandvars(channel) for channel in data.get("channels", []) ] def from_file(filename): """Load and return an ``Environment`` from a given file""" url_scheme = filename.split("://", 1)[0] if url_scheme in CONDA_SESSION_SCHEMES: yamlstr = download_text(filename) elif not os.path.exists(filename): raise EnvironmentFileNotFound(filename) else: with open(filename, "rb") as fp: yamlb = fp.read() try: yamlstr = yamlb.decode("utf-8") except UnicodeDecodeError: yamlstr = yamlb.decode("utf-16") return from_yaml(yamlstr, filename=filename) class Dependencies(dict): """A ``dict`` subclass that parses the raw dependencies into a conda and pip list""" def __init__(self, raw, *args, **kwargs): super().__init__(*args, **kwargs) self.raw = raw self.parse() def parse(self): """Parse the raw dependencies into a conda and pip list""" if not self.raw: return self.update({"conda": []}) for line in self.raw: if isinstance(line, dict): self.update(line) else: self["conda"].append(common.arg2spec(line)) if "pip" in self: if not self["pip"]: del self["pip"] if not any(MatchSpec(s).name == "pip" for s in self["conda"]): self["conda"].append("pip") # TODO only append when it's not already present def add(self, package_name): """Add a package to the ``Environment``""" self.raw.append(package_name) self.parse() class Environment: """A class representing an ``environment.yaml`` file""" def __init__( self, name=None, filename=None, channels=None, dependencies=None, prefix=None, variables=None, ): self.name = name self.filename = filename self.prefix = prefix self.dependencies = Dependencies(dependencies) self.variables = variables if channels is None: channels = [] self.channels = channels def add_channels(self, channels): """Add channels to the ``Environment``""" self.channels = list(unique(chain.from_iterable((channels, self.channels)))) def remove_channels(self): """Remove all channels from the ``Environment``""" self.channels = [] def to_dict(self, stream=None): """Convert information related to the ``Environment`` into a dictionary""" d = {"name": self.name} if self.channels: d["channels"] = self.channels if self.dependencies: d["dependencies"] = self.dependencies.raw if self.variables: d["variables"] = self.variables if self.prefix: d["prefix"] = self.prefix if stream is None: return d stream.write(json.dumps(d)) def to_yaml(self, stream=None): """Convert information related to the ``Environment`` into a ``yaml`` string""" d = self.to_dict() out = yaml_safe_dump(d, stream) if stream is None: return out def save(self): """Save the ``Environment`` data to a ``yaml`` file""" with open(self.filename, "wb") as fp: self.to_yaml(stream=fp) def get_filename(filename): """Expand filename if local path or return the ``url``""" url_scheme = filename.split("://", 1)[0] if url_scheme in CONDA_SESSION_SCHEMES: return filename else: return abspath(expanduser(expandvars(filename))) def print_result(args, prefix, result): """Print the result of an install operation""" if context.json: if result["conda"] is None and result["pip"] is None: common.stdout_json_success( message="All requested packages already installed." ) else: if result["conda"] is not None: actions = result["conda"] else: actions = {} if result["pip"] is not None: actions["PIP"] = result["pip"] common.stdout_json_success(prefix=prefix, actions=actions) else: install.print_activate(args.name or prefix)