diff --git a/conda/cli/install.py b/conda/cli/install.py index 166dad66378..3a6bcbf52b3 100644 --- a/conda/cli/install.py +++ b/conda/cli/install.py @@ -17,6 +17,7 @@ from boltons.setutils import IndexedSet +from ..auxlib.ish import dals from ..base.constants import ( REPODATA_FN, ROOT_ENV_NAME, @@ -33,6 +34,7 @@ from ..core.solve import diff_for_unlink_link_precs from ..deprecations import deprecated from ..exceptions import ( + CondaError, CondaEnvException, CondaExitZero, CondaImportError, @@ -42,6 +44,7 @@ CondaValueError, DirectoryNotACondaEnvironmentError, DryRunExit, + InvalidInstaller, NoBaseEnvironmentError, PackageNotInstalledError, PackagesNotFoundError, @@ -425,6 +428,23 @@ def install(args, parser, command="install"): handle_txn(unlink_link_transaction, prefix, args, newenv) + # install all other external packages + for installer_type, pkg_specs in env.external_packages.items(): + try: + installer = context.plugin_manager.get_installer(installer_type) + installer.install(prefix, pkg_specs, env.config) + except InvalidInstaller: + raise CondaError( + dals( + f""" + Unable to install package for {installer_type}. + + Please double check and ensure your dependencies for {installer_type} + are correctly formatted. + """ + ) + ) + def install_clone(args, parser): """Executes an install of a new conda environment by cloning.""" diff --git a/conda/cli/main_env_create.py b/conda/cli/main_env_create.py index d357a37fbed..05375a1b50f 100644 --- a/conda/cli/main_env_create.py +++ b/conda/cli/main_env_create.py @@ -113,6 +113,7 @@ def configure_parser(sub_parsers: _SubParsersAction, **kwargs) -> ArgumentParser def execute(args: Namespace, parser: ArgumentParser) -> int: from ..auxlib.ish import dals from ..base.context import context, determine_target_prefix + from ..common.constants import NULL from ..common.serialize import json from ..core.prefix_data import PrefixData from ..env.env import print_result @@ -166,36 +167,37 @@ def execute(args: Namespace, parser: ArgumentParser) -> int: context.create_default_packages if not args.no_default_packages else [] ) + cli_args = {k: v for k,v in vars(args).items() if v != NULL and v != None} + if args.dry_run: installer_type = "conda" installer = get_installer(installer_type) pkg_specs = [*env.requested_packages, *args_packages] - solved_env = installer.dry_run(pkg_specs, args, env) + solved_env = installer.dry_run(env.prefix, pkg_specs, env.config, **cli_args) if args.json: print(json.dumps(solved_env.to_dict())) else: print(solved_env.to_yaml(), end="") else: + # Install conda packages if args_packages: installer_type = "conda" installer = get_installer(installer_type) - result[installer_type] = installer.install(prefix, args_packages, args, env) - - # install conda packages + result[installer_type] = installer.install(prefix, args_packages, env.config, **cli_args) installer_type = "conda" installer = get_installer(installer_type) result[installer_type] = installer.install( - prefix, env.requested_packages, args, env + prefix, env.requested_packages, env.config, **cli_args ) # install all other external packages for installer_type, pkg_specs in env.external_packages.items(): try: - installer = get_installer(installer_type) - result[installer_type] = installer.install(prefix, pkg_specs, args, env) + installer = context.plugin_manager.get_installer(installer_type) + result[installer_type] = installer.install(prefix, pkg_specs, env.config, **cli_args) except InvalidInstaller: raise CondaError( dals( diff --git a/conda/env/env.py b/conda/env/env.py index 23dee7f988e..17b1e73b8dd 100644 --- a/conda/env/env.py +++ b/conda/env/env.py @@ -271,7 +271,7 @@ def to_environment_model(self) -> EnvironmentModel: platform=context.subdir, name=self.name, config=config, - variables=self.variables, + variables=self.variables or {}, external_packages=external_packages, requested_packages=requested_packages, ) diff --git a/conda/env/installers/conda.py b/conda/env/installers/conda.py index e6fca47d2e7..25b9b563c1e 100644 --- a/conda/env/installers/conda.py +++ b/conda/env/installers/conda.py @@ -16,16 +16,17 @@ from ...env.env import EnvironmentYaml from ...exceptions import CondaValueError, UnsatisfiableError from ...models.channel import Channel, prioritize_channels +from ...models.records import PackageRecord if TYPE_CHECKING: from argparse import Namespace from ...core.solve import Solver - from ...models.environment import Environment + from ...models.environment import EnvironmentConfig def _solve( - prefix: str, specs: list[str], args: Namespace, env: Environment, *_, **kwargs + prefix: str, specs: list[str], env_config: EnvironmentConfig, **kwargs ) -> Solver: """Solve the environment. @@ -37,9 +38,9 @@ def _solve( """ # TODO: support all various ways this happens # Including 'nodefaults' in the channels list disables the defaults - channel_urls = [chan for chan in env.config.channels if chan != "nodefaults"] + channel_urls = [chan for chan in env_config.channels if chan != "nodefaults"] - if "nodefaults" not in env.config.channels: + if "nodefaults" not in env_config.channels: channel_urls.extend(context.channels) _channel_priority_map = prioritize_channels(channel_urls) @@ -54,7 +55,7 @@ def _solve( def dry_run( - specs: list[str], args: Namespace, env: Environment, *_, **kwargs + prefix: str, specs: list[str], env_config: EnvironmentConfig, **kwargs ) -> EnvironmentYaml: """Do a dry run of the environment solve. @@ -64,16 +65,16 @@ def dry_run( :return: Solved environment object :rtype: EnvironmentYaml """ - solver = _solve(tempfile.mkdtemp(), specs, args, env, *_, **kwargs) + solver = _solve(prefix, specs, env_config, **kwargs) pkgs = solver.solve_final_state() return EnvironmentYaml( - name=env.name, dependencies=[str(p) for p in pkgs], channels=env.config.channels + name=prefix, dependencies=[str(p) for p in pkgs], channels=env_config.channels ) def install( - prefix: str, specs: list[str], args: Namespace, env: Environment, *_, **kwargs -) -> dict | None: + prefix: str, specs: list, env_config: EnvironmentConfig, **kwargs +) -> dict: """Install packages into a conda environment. This function handles two main paths: @@ -93,27 +94,24 @@ def install( processed, the conda client SHOULD NOT invoke a solver." """ # Handle explicit environments separately per CEP-23 requirements - if env.explicit_packages: + if not specs: + return {} + + # If the specs are package records, these are explicit packages. + if isinstance(specs[0], PackageRecord): from ...misc import install_explicit_packages - - # For explicit environments, we consider any provided specs as user-requested - # All packages in the explicit file are installed, but only user-provided specs - # are recorded in history as explicitly requested - requested_specs = specs if specs else () - # Install explicit packages - bypassing the solver completely return install_explicit_packages( - package_cache_records=env.explicit_packages, + package_cache_records=specs, prefix=prefix, - requested_specs=requested_specs, ) # For regular environments, proceed with the normal solve-based installation - solver = _solve(prefix, specs, args, env, *_, **kwargs) + solver = _solve(prefix, specs, env_config, **kwargs) try: unlink_link_transaction = solver.solve_for_transaction( - prune=getattr(args, "prune", False), + prune=getattr(kwargs, "prune", False), update_modifier=UpdateModifier.FREEZE_INSTALLED, ) except (UnsatisfiableError, SystemExit) as exc: @@ -122,7 +120,7 @@ def install( if not getattr(exc, "allow_retry", True): raise unlink_link_transaction = solver.solve_for_transaction( - prune=getattr(args, "prune", False), update_modifier=NULL + prune=getattr(kwargs, "prune", False), update_modifier=NULL ) # Execute the transaction and return success if unlink_link_transaction.nothing_to_do: diff --git a/conda/env/installers/pip.py b/conda/env/installers/pip.py index 85ff3c35d81..5ae09da3da0 100644 --- a/conda/env/installers/pip.py +++ b/conda/env/installers/pip.py @@ -10,11 +10,13 @@ from ...env.pip_util import get_pip_installed_packages, pip_subprocess from ...gateways.connection.session import CONDA_SESSION_SCHEMES from ...reporters import get_spinner +from ...models.environment import EnvironmentConfig + log = getLogger(__name__) -def _pip_install_via_requirements(prefix, specs, args, *_, **kwargs): +def _pip_install_via_requirements(prefix, specs): """ Installs the pip dependencies in specs using a temporary pip requirements file. @@ -28,16 +30,7 @@ def _pip_install_via_requirements(prefix, specs, args, *_, **kwargs): See: https://pip.pypa.io/en/stable/user_guide/#requirements-files https://pip.pypa.io/en/stable/reference/pip_install/#requirements-file-format """ - url_scheme = args.file.split("://", 1)[0] - if url_scheme in CONDA_SESSION_SCHEMES: - pip_workdir = None - else: - try: - pip_workdir = op.dirname(op.abspath(args.file)) - if not os.access(pip_workdir, os.W_OK): - pip_workdir = None - except AttributeError: - pip_workdir = None + pip_workdir = None requirements = None try: # Generate the temporary requirements file @@ -67,6 +60,11 @@ def _pip_install_via_requirements(prefix, specs, args, *_, **kwargs): return get_pip_installed_packages(stdout) -def install(*args, **kwargs): +def install(prefix: str, specs: list[str], *args, **kwargs): with get_spinner("Installing pip dependencies"): - return _pip_install_via_requirements(*args, **kwargs) + return _pip_install_via_requirements(prefix, specs) + + +def dry_run(prefix: str, specs: list[str], *args, **kwargs) -> dict: + print("pretending to (dry-run) install stuff with pip") + return {} \ No newline at end of file diff --git a/conda/models/environment.py b/conda/models/environment.py index fe6b5b882f4..ef71188db7a 100644 --- a/conda/models/environment.py +++ b/conda/models/environment.py @@ -500,16 +500,12 @@ def from_cli( # environment spec plugin. The core conda cli commands are not # ready for that yet. So, use this old way of reading specs from # files. - for fpath in args.file: - try: - specs.extend( - [spec for spec in specs_from_url(fpath) if spec != EXPLICIT_MARKER] - ) - except UnicodeError: - raise CondaError( - "Error reading file, file should be a text file containing packages\n" - "See `conda create --help` for details." - ) + file_envs = [] + for path in args.file: + # parse the file + spec_hook = context.plugin_manager.get_environment_specifier(path) + file_env = spec_hook.environment_spec(path).env + file_envs.append(file_env) # Add default packages if required. If the default package is already # present in the list of specs, don't add it (this will override any @@ -538,7 +534,7 @@ def from_cli( "Cannot mix specifications with conda package filenames" ) - return Environment( + cli_env = Environment( name=args.name, prefix=context.target_prefix, platform=context.subdir, @@ -546,3 +542,5 @@ def from_cli( explicit_packages=explicit_packages, config=EnvironmentConfig.from_context(), ) + + return Environment.merge(cli_env, *file_envs) \ No newline at end of file diff --git a/conda/plugins/__init__.py b/conda/plugins/__init__.py index dd46bf272f8..6bf6d95ff36 100644 --- a/conda/plugins/__init__.py +++ b/conda/plugins/__init__.py @@ -29,6 +29,7 @@ CondaAuthHandler, CondaEnvironmentSpecifier, CondaHealthCheck, + CondaInstaller, CondaPostCommand, CondaPostSolve, CondaPostTransactionAction, diff --git a/conda/plugins/hookspec.py b/conda/plugins/hookspec.py index 9207374a429..5dd9ab25e33 100644 --- a/conda/plugins/hookspec.py +++ b/conda/plugins/hookspec.py @@ -22,6 +22,7 @@ CondaEnvironmentExporter, CondaEnvironmentSpecifier, CondaHealthCheck, + CondaInstaller, CondaPostCommand, CondaPostSolve, CondaPostTransactionAction, @@ -730,3 +731,7 @@ def conda_environment_exporters(): ) """ yield from () + + @_hookspec + def conda_installers(self) -> Iterable[CondaInstaller]: + yield from () diff --git a/conda/plugins/installers/__init__.py b/conda/plugins/installers/__init__.py new file mode 100644 index 00000000000..e9ebd3d119a --- /dev/null +++ b/conda/plugins/installers/__init__.py @@ -0,0 +1,8 @@ +# Copyright (C) 2012 Anaconda, Inc +# SPDX-License-Identifier: BSD-3-Clause +"""Register the built-in env_installer hook implementations.""" + +from . import pip, uv + +#: The list of env_installer plugins for easier registration with pluggy +plugins = [pip, uv] \ No newline at end of file diff --git a/conda/plugins/installers/pip.py b/conda/plugins/installers/pip.py new file mode 100644 index 00000000000..535372b2c1d --- /dev/null +++ b/conda/plugins/installers/pip.py @@ -0,0 +1,19 @@ +# Copyright (C) 2012 Anaconda, Inc +# SPDX-License-Identifier: BSD-3-Clause +"""Register the pip installer for conda env files.""" + +from .. import CondaInstaller, hookimpl + + +@hookimpl +def conda_installers(): + from ...env.installers.pip import dry_run as pip_dry_run + from ...env.installers.pip import install as pip_install + + # In this demo, swap the meaning of "pip" and "pypi" + yield CondaInstaller( + name="pypi_pip", + types=("pip","pypi"), + install=pip_install, + dry_run=pip_dry_run, + ) diff --git a/conda/plugins/installers/uv.py b/conda/plugins/installers/uv.py new file mode 100644 index 00000000000..1372e31b175 --- /dev/null +++ b/conda/plugins/installers/uv.py @@ -0,0 +1,29 @@ +# Copyright (C) 2012 Anaconda, Inc +# SPDX-License-Identifier: BSD-3-Clause +"""Register the pip installer for conda env files.""" + +from .. import CondaInstaller, hookimpl +from ...models.environment import EnvironmentConfig + +def uv_install(prefix: str, specs: list[str], *args, **kwargs) -> dict: + print("pretending to install stuff with uv") + for spec in specs: + print(f" - {spec}") + return {} + + +def uv_dry_run(prefix: str, specs: list[str], *args, **kwargs) -> dict: + print("pretending to (dry-run) install stuff with uv") + for spec in specs: + print(f" - {spec}") + return {} + + +@hookimpl +def conda_installers(): + yield CondaInstaller( + name="pypi_uv", + types=("uv", "pip", "pypi"), + install=uv_install, + dry_run=uv_dry_run, + ) diff --git a/conda/plugins/manager.py b/conda/plugins/manager.py index 8f93aeff66b..64edd1dd58c 100644 --- a/conda/plugins/manager.py +++ b/conda/plugins/manager.py @@ -33,6 +33,7 @@ from . import ( environment_exporters, environment_specifiers, + installers, post_solves, prefix_data_loaders, reporter_backends, @@ -59,6 +60,7 @@ CondaEnvironmentExporter, CondaEnvironmentSpecifier, CondaHealthCheck, + CondaInstaller, CondaPostCommand, CondaPostSolve, CondaPostTransactionAction, @@ -190,6 +192,9 @@ def load_entrypoints(self, group: str, name: str | None = None) -> int: count += 1 return count + @overload + def get_hook_results(self, name: Literal["installers"]) -> list[CondaInstaller]: ... + @overload def get_hook_results( self, name: Literal["subcommands"] @@ -826,6 +831,35 @@ def get_post_transaction_actions( ) for hook in self.get_hook_results("post_transaction_actions") ] + + def get_installer(self, installer_type: str) -> CondaInstaller: + """ + Returns the installer registered for the given installer name. + Raises PluginError if more than one installer is found for the same installer name. + Raises CondaValueError if no installer were found for that installer name. + """ + found = {} + for hook in self.get_hook_results("installers"): + if installer_type in hook.types: + found[hook.name] = hook + if len(found) == 1: + return next(iter(found.values())) + if found: + # Choose preferred plugin for the type of installer. Users may set + # their preferred installer for a given type using plugin config. + # This will check for config with the name `_preferred_installer`. + preferred_installer = getattr(context.plugins, f"{installer_type}_preferred_installer", None) + if preferred_installer: + if preferred_installer in found.keys(): + return found[preferred_installer] + else: + raise CondaValueError(f"Could not find preferred installer plugin '{preferred_installer}' for installing package from '{installer_type}'.") + raise PluginError( + f"Too many installers registered for '{installer_type}': {dashlist(found.keys())}" + f"\n\nSet the default installer for package type `{installer_type}` by configuring the setting `{installer_type}_preferred_installer`." + f"\nFor example:\n `export CONDA_PLUGINS_PIP_PREFERRED_INSTALLER=uv`." + ) + raise CondaValueError(f"Could not find env installer for '{installer_type}'.") @functools.cache @@ -846,6 +880,7 @@ def get_plugin_manager() -> CondaPluginManager: *prefix_data_loaders.plugins, *environment_specifiers.plugins, *environment_exporters.plugins, + *installers.plugins, ) plugin_manager.load_entrypoints(spec_name) return plugin_manager diff --git a/conda/plugins/types.py b/conda/plugins/types.py index 3dc60198d1f..1958b344477 100644 --- a/conda/plugins/types.py +++ b/conda/plugins/types.py @@ -12,12 +12,14 @@ from abc import ABC, abstractmethod from contextlib import nullcontext from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Callable, Iterable from requests.auth import AuthBase +from .config import PluginConfig from ..exceptions import PluginError from ..models.records import PackageRecord +from ..common.configuration import PrimitiveParameter if TYPE_CHECKING: from argparse import ArgumentParser, Namespace @@ -28,7 +30,7 @@ from ..common.path import PathType from ..core.path_actions import Action from ..core.solve import Solver - from ..models.environment import Environment + from ..models.environment import Environment, EnvironmentConfig from ..models.match_spec import MatchSpec from ..models.records import PrefixRecord @@ -522,3 +524,33 @@ def __post_init__(self): except AttributeError: # AttributeError: alias is not a string raise PluginError(f"Invalid plugin aliases for {self!r}") + + +@dataclass +class CondaInstaller(CondaPlugin): + """ + **EXPERIMENTAL** + Return type to use when defining a conda installer plugin hook. + For details on how this is used, see + :meth:`~conda.plugins.hookspec.CondaSpecs.conda_installers`. + :param name: name of the installer (e.g., ``pip``) + :param types: the names of the types of packages it can install (e.g. conda, pip). + + """ + + name: str + types: Iterable[str] + install: Callable[[str, list, EnvironmentConfig, dict], dict] + dry_run: Callable[[str, list, EnvironmentConfig, dict], dict] + + def __post_init__(self): + # Handle name normalization + super().__post_init__() + + # Configure plugin settings for preferred_installers + for t in self.types: + PluginConfig.add_plugin_setting( + name=f"{t}_preferred_installer", + parameter=PrimitiveParameter(""), + # aliases=[f"{t}_preferred_installer" for t in self.types] + )