Repository: conda/conda
Pull request #14670: Refactor `repodata_fn` iteration in `conda.cli.install` and smaller cleanups
Author: login: soapy1
Labels: name: cla-signed
description: [bot] added once the contributor has signed the CLA
name: locked
description: [bot] locked due to inactivity
Created: 2025-03-13T19:21:57Z | Merged: 2025-04-18T16:15:12Z


### Description
The goal of this PR is to refactor `conda.cli.install` by:
* de-duplicating validation
* moving validation of command line argument combinations to the cli command layer
* moving appropriate validations to the cli command layer
* reduce the responsibilities of the `conda.cli.install.install` function

This refactor will make it easier to add new behaviours to the different installation workflows. In particular, taking steps to have yaml files supported by the `conda create --file` command (ref: https://github.com/conda/conda/pull/14113)

### Checklist - did you ...

<!-- If any of the following items aren't relevant to your contribution,
     please either tick them or use ~strikethrough~ so we know you've gone
     through the checklist. -->

- [x] Add a file to the `news` directory ([using the template](https://github.com/conda/conda/blob/main/news/TEMPLATE)) for the next release's release notes?
     <!-- All "significant" changes should get an entry:
            - user-facing changes or enhancements
            - bug fixes
            - deprecations
            - documentation updates
            - etc -->
- [x] Add / update necessary tests?
- [x] Add / update outdated documentation?


<!-- Just as a reminder, everyone in all conda org spaces (including PRs)
     must follow the Conda Org Code of Conduct (link below).

     Finally, once again, thanks for your time and effort. If you have any
     feedback in regards to your experience contributing here, please
     let us know!

     Helpful links:
       - Conda Org COC: https://github.com/conda/conda/blob/main/CODE_OF_CONDUCT.md
       - Contributing docs: https://github.com/conda/conda/blob/main/CONTRIBUTING.md -->



Diff:
diff --git a/conda/cli/common.py b/conda/cli/common.py
index 2ffe4cdad30..a669da1498c 100644
--- a/conda/cli/common.py
+++ b/conda/cli/common.py
@@ -329,3 +329,22 @@ def validate_subdir_config():
                     """
                 )
                 raise OperationNotAllowed(msg)
+
+
+def print_activate(env_name_or_prefix):  # pragma: no cover
+    if not context.quiet and not context.json:
+        if " " in env_name_or_prefix:
+            env_name_or_prefix = f'"{env_name_or_prefix}"'
+        message = dals(
+            f"""
+        #
+        # To activate this environment, use
+        #
+        #     $ conda activate {env_name_or_prefix}
+        #
+        # To deactivate an active environment, use
+        #
+        #     $ conda deactivate
+        """
+        )
+        print(message)  # TODO: use logger
diff --git a/conda/cli/install.py b/conda/cli/install.py
index 0a42b5c2ba8..6663298f418 100644
--- a/conda/cli/install.py
+++ b/conda/cli/install.py
@@ -18,7 +18,6 @@
 from boltons.setutils import IndexedSet
 
 from .. import CondaError
-from ..auxlib.ish import dals
 from ..base.constants import (
     PREFIX_MAGIC_FILE,
     REPODATA_FN,
@@ -57,8 +56,9 @@
     UnsatisfiableError,
 )
 from ..gateways.disk.delete import delete_trash, path_is_clean
+from ..gateways.disk.test import is_conda_environment
 from ..history import History
-from ..misc import _get_best_prec_match, clone_env, explicit, touch_nonadmin
+from ..misc import _get_best_prec_match, clone_env, explicit
 from ..models.match_spec import MatchSpec
 from ..models.prefix_graph import PrefixGraph
 from ..reporters import confirm_yn, get_spinner
@@ -151,23 +151,11 @@ def clone(src_arg, dst_prefix, json=False, quiet=False, index_args=None):
         )
 
 
+@deprecated("25.9", "26.3", addendum="Use conda.cli.common.print_activate()")
 def print_activate(env_name_or_prefix):  # pragma: no cover
-    if not context.quiet and not context.json:
-        if " " in env_name_or_prefix:
-            env_name_or_prefix = f'"{env_name_or_prefix}"'
-        message = dals(
-            f"""
-        #
-        # To activate this environment, use
-        #
-        #     $ conda activate {env_name_or_prefix}
-        #
-        # To deactivate an active environment, use
-        #
-        #     $ conda deactivate
-        """
-        )
-        print(message)  # TODO: use logger
+    from .common import print_activate as _print_activate
+
+    _print_activate(env_name_or_prefix)
 
 
 def get_revision(arg, json=False):
@@ -193,7 +181,86 @@ def get_index_args(args) -> dict[str, any]:
     }
 
 
-def validate_install_command(prefix: str):
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            isinstance(exc_value, self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif isinstance(exc_value, ResolvePackageNotFound):
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+            *allows_errors,
+        )
+
+    def __iter__(self):
+        for repodata in self.repodata_fns:
+            yield TryRepodata(
+                self.succeed,
+                repodata,
+                self.repodata_fns[-1],
+                self.index_args,
+                self.allowed_errors,
+            )
+            if self.success:
+                break
+
+    def succeed(self):
+        self.success = True
+
+
+def validate_install_command(prefix: str, command: str = "install"):
     """Executes a set of validations that are required before any installation
     command is executed. This includes:
       * ensure the configuration is valid
@@ -201,31 +268,64 @@ def validate_install_command(prefix: str):
       * ensure the user is not forcing 32bit installs in the root prefix
 
     :param prefix: The prefix where the environment will be created
+    :param command: Type of operation being performed
     :raises: error if the configuration for the install is bad
     """
     context.validate_configuration()
     check_non_admin()
+
     if context.force_32bit and paths_equal(prefix, context.root_prefix):
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
 
+    # Ensure the prefix exists if it is meant to
+    if command == "create":
+        # new environments should not have a prefix that exists
+        pass
+    elif isdir(prefix):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exist - if it does not
+        # then this is not a valid conda environment
+        if is_conda_environment(prefix):
+            # if the prefix exists, ensure that it is writable
+            validate_prefix_is_writable(prefix)
+        else:
+            if paths_equal(prefix, context.conda_prefix):
+                raise NoBaseEnvironmentError()
+            else:
+                if not path_is_clean(prefix):
+                    raise DirectoryNotACondaEnvironmentError(prefix)
+    else:
+        # if this is not a new env and the prefix does not exist, then
+        # there is no existing environment - this is an error
+        raise EnvironmentLocationNotFound(prefix)
 
-def install_clone(args, parser):
-    """Executes an install of a new conda environment by cloning. Assumes
-    that the caller has already checked that the prefix does not exist (or
-    is ok to overwrite)
+
+def ensure_update_specs_exist(prefix: str, specs: list[str]):
+    """Checks that each spec that is requested as an update exists in the prefix
+
+    :param prefix: The target install prefix
+    :param specs: List of specs to be updated
+    :raises CondaError: if there is an invalid spec provided
+    :raises PackageNotInstalledError: if the requested specs to install don't exist in the prefix
     """
-    prefix = context.target_prefix
+    prefix_data = PrefixData(prefix)
+    for spec in specs:
+        spec = MatchSpec(spec)
+        if not spec.is_name_only_spec:
+            raise CondaError(
+                f"Invalid spec for 'conda update': {spec}\nUse 'conda install' instead."
+            )
+        if not prefix_data.get(spec.name, None):
+            raise PackageNotInstalledError(prefix, spec.name)
 
-    # common validations for all types of installs
-    validate_install_command(prefix=prefix)
 
+def install_clone(args, parser):
+    """Executes an install of a new conda environment by cloning."""
+    prefix = context.target_prefix
     index_args = get_index_args(args)
 
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
+    # common validations for all types of installs
+    validate_install_command(prefix=prefix, command="create")
 
     clone(
         args.clone,
@@ -234,9 +334,6 @@ def install_clone(args, parser):
         quiet=context.quiet,
         index_args=index_args,
     )
-    touch_nonadmin(prefix)
-    print_activate(args.name or prefix)
-    return
 
 
 def install(args, parser, command="install"):
@@ -244,7 +341,7 @@ def install(args, parser, command="install"):
     prefix = context.target_prefix
 
     # common validations for all types of installs
-    validate_install_command(prefix=prefix)
+    validate_install_command(prefix=prefix, command=command)
 
     if context.use_only_tar_bz2:
         args.repodata_fns = ("repodata.json",)
@@ -268,6 +365,7 @@ def install(args, parser, command="install"):
         else:
             raise EnvironmentLocationNotFound(prefix)
 
+    # collect packages provided from the command line
     args_packages = [s.strip("\"'") for s in args.packages]
     if newenv and not args.no_default_packages:
         # Override defaults if they are specified at the command line
@@ -276,21 +374,21 @@ def install(args, parser, command="install"):
             if MatchSpec(default_package).name not in names:
                 args_packages.append(default_package)
 
+    index_args = get_index_args(args=args)
     context_channels = context.channels
-    index_args = get_index_args(args)
+
     num_cp = sum(is_package_file(s) for s in args_packages)
     if num_cp:
         if num_cp == len(args_packages):
+            # short circuit to installing explicit if all specs are direct files
             explicit(args_packages, prefix, verbose=not context.quiet)
-            if newenv:
-                touch_nonadmin(prefix)
-                print_activate(args.name or prefix)
             return
         else:
             raise CondaValueError(
                 "cannot mix specifications with conda package filenames"
             )
 
+    # collect specs provided by --file arguments
     specs = []
     if args.file:
         for fpath in args.file:
@@ -302,27 +400,15 @@ def install(args, parser, command="install"):
                     " packages \nconda create --help for details"
                 )
         if "@EXPLICIT" in specs:
+            # short circuit to installing explicit if explicit specs are provided
             explicit(specs, prefix, verbose=not context.quiet)
-            if newenv:
-                touch_nonadmin(prefix)
-                print_activate(args.name or prefix)
             return
     specs.extend(common.specs_from_args(args_packages, json=context.json))
 
     # for 'conda update', make sure the requested specs actually exist in the prefix
     # and that they are name-only specs
     if isupdate and context.update_modifier != UpdateModifier.UPDATE_ALL:
-        prefix_data = PrefixData(prefix)
-        for spec in specs:
-            spec = MatchSpec(spec)
-            if not spec.is_name_only_spec:
-                raise CondaError(
-                    f"Invalid spec for 'conda update': {spec}\n"
-                    "Use 'conda install' instead."
-                )
-            if not prefix_data.get(spec.name, None):
-                raise PackageNotInstalledError(prefix, spec.name)
-
+        ensure_update_specs_exist(prefix=prefix, specs=specs)
     if newenv and args.clone:
         deprecated.topic(
             "25.9",
@@ -349,123 +435,102 @@ def install(args, parser, command="install"):
         not in (UpdateModifier.FREEZE_INSTALLED, UpdateModifier.UPDATE_SPECS)
     ) and not newenv
 
-    for repodata_fn in repodata_fns:
-        try:
-            if isinstall and args.revision:
-                with get_spinner(f"Collecting package metadata ({repodata_fn})"):
-                    index = get_index(
-                        channel_urls=index_args["channel_urls"],
-                        prepend=index_args["prepend"],  # --override-channels
-                        platform=None,
-                        use_local=index_args["use_local"],  # --use-local
-                        # use_cache=index_args["use_cache"],  # --use-index-cache
-                        # unknown=index_args["unknown"],  # --unknown
-                        prefix=prefix,
-                        repodata_fn=repodata_fn,
-                    )
-                revision_idx = get_revision(args.revision)
-                with get_spinner(f"Reverting to revision {revision_idx}"):
-                    unlink_link_transaction = revert_actions(
-                        prefix, revision_idx, index
-                    )
-            else:
-                solver_backend = context.plugin_manager.get_cached_solver_backend()
-                solver = solver_backend(
-                    prefix,
-                    context_channels,
-                    context.subdirs,
-                    specs_to_add=specs,
-                    repodata_fn=repodata_fn,
-                    command=args.cmd,
-                )
-                update_modifier = context.update_modifier
-                if (isinstall or isremove) and args.update_modifier == NULL:
-                    update_modifier = UpdateModifier.FREEZE_INSTALLED
-                deps_modifier = context.deps_modifier
-                if isupdate:
-                    deps_modifier = context.deps_modifier or DepsModifier.UPDATE_SPECS
-
+    update_modifier = context.update_modifier
+    if (isinstall or isremove) and args.update_modifier == NULL:
+        update_modifier = UpdateModifier.FREEZE_INSTALLED
+    deps_modifier = context.deps_modifier
+    if isupdate:
+        deps_modifier = context.deps_modifier or DepsModifier.UPDATE_SPECS
+
+    for repodata_fn in Repodatas(
+        repodata_fns,
+        index_args,
+        (UnsatisfiableError, SpecsConfigurationConflictError, SystemExit),
+    ):
+        with repodata_fn as repodata:
+            solver_backend = context.plugin_manager.get_cached_solver_backend()
+            solver = solver_backend(
+                prefix,
+                context_channels,
+                context.subdirs,
+                specs_to_add=specs,
+                repodata_fn=repodata,
+                command=args.cmd,
+            )
+            try:
                 unlink_link_transaction = solver.solve_for_transaction(
                     deps_modifier=deps_modifier,
                     update_modifier=update_modifier,
                     force_reinstall=context.force_reinstall or context.force,
                     should_retry_solve=(
-                        _should_retry_unfrozen or repodata_fn != repodata_fns[-1]
+                        _should_retry_unfrozen or repodata != repodata_fns[-1]
                     ),
                 )
-            # we only need one of these to work.  If we haven't raised an exception,
-            #   we're good.
-            break
-
-        except (ResolvePackageNotFound, PackagesNotFoundError) as e:
-            if not getattr(e, "allow_retry", True):
-                raise e  # see note in next except block
-            # end of the line.  Raise the exception
-            if repodata_fn == repodata_fns[-1]:
-                # PackagesNotFoundError is the only exception type we want to raise.
-                #    Over time, we should try to get rid of ResolvePackageNotFound
-                if isinstance(e, PackagesNotFoundError):
-                    raise e
-                else:
-                    channels_urls = tuple(
-                        calculate_channel_urls(
-                            channel_urls=index_args["channel_urls"],
-                            prepend=index_args["prepend"],
-                            platform=None,
-                            use_local=index_args["use_local"],
-                        )
-                    )
-                    # convert the ResolvePackageNotFound into PackagesNotFoundError
-                    raise PackagesNotFoundError(e._formatted_chains, channels_urls)
-
-        except (UnsatisfiableError, SystemExit, SpecsConfigurationConflictError) as e:
-            if not getattr(e, "allow_retry", True):
-                # TODO: This is a temporary workaround to allow downstream libraries
-                # to inject this attribute set to False and skip the retry logic
-                # Other solvers might implement their own internal retry logic without
-                # depending --freeze-install implicitly like conda classic does. Example
-                # retry loop in conda-libmamba-solver:
-                # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
-                # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
-                # so we don't have go through all the repodatas and freeze-installed logic
-                # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
-                # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
-                raise e
-            # Quick solve with frozen env or trimmed repodata failed.  Try again without that.
-            if not hasattr(args, "update_modifier"):
-                if repodata_fn == repodata_fns[-1]:
+            except (UnsatisfiableError, SpecsConfigurationConflictError) as e:
+                if not getattr(e, "allow_retry", True):
                     raise e
-            elif _should_retry_unfrozen:
-                try:
+                if _should_retry_unfrozen:
                     unlink_link_transaction = solver.solve_for_transaction(
                         deps_modifier=deps_modifier,
                         update_modifier=UpdateModifier.UPDATE_SPECS,
                         force_reinstall=context.force_reinstall or context.force,
-                        should_retry_solve=(repodata_fn != repodata_fns[-1]),
+                        should_retry_solve=(repodata != repodata_fns[-1]),
                     )
-                except (
-                    UnsatisfiableError,
-                    SystemExit,
-                    SpecsConfigurationConflictError,
-                ) as e:
-                    # Unsatisfiable package specifications/no such revision/import error
-                    if e.args and "could not import" in e.args[0]:
-                        raise CondaImportError(str(e))
-                    # we want to fall through without raising if we're not at the end of the list
-                    #    of fns.  That way, we fall to the next fn.
-                    if repodata_fn == repodata_fns[-1]:
-                        raise e
-            elif repodata_fn != repodata_fns[-1]:
-                continue  # if we hit this, we should retry with next repodata source
-            else:
-                # end of the line.  Raise the exception
-                # Unsatisfiable package specifications/no such revision/import error
+                else:
+                    raise e
+            except SystemExit as e:
+                if not getattr(e, "allow_retry", True):
+                    raise e
                 if e.args and "could not import" in e.args[0]:
                     raise CondaImportError(str(e))
                 raise e
+
     handle_txn(unlink_link_transaction, prefix, args, newenv)
 
 
+def install_revision(args, parser):
+    """Install a previous version of a conda environment"""
+    prefix = context.target_prefix
+    index_args = get_index_args(args)
+
+    # common validations for all types of installs
+    validate_install_command(prefix=prefix, command="install")
+
+    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
+    #    because it deduplicates records that exist as both formats.  Forcing this to
+    #    repodata.json ensures that .tar.bz2 files are available
+    if context.use_only_tar_bz2:
+        args.repodata_fns = ("repodata.json",)
+
+    # ensure trash is cleared from existing prefix
+    delete_trash(prefix)
+
+    repodata_fns = args.repodata_fns
+    if not repodata_fns:
+        repodata_fns = list(context.repodata_fns)
+    if REPODATA_FN not in repodata_fns:
+        repodata_fns.append(REPODATA_FN)
+
+    for repodata_fn in Repodatas(repodata_fns, index_args):
+        with repodata_fn as repodata:
+            with get_spinner(f"Collecting package metadata ({repodata})"):
+                index = get_index(
+                    channel_urls=index_args["channel_urls"],
+                    prepend=index_args["prepend"],  # --override-channels
+                    platform=None,
+                    use_local=index_args["use_local"],  # --use-local
+                    # use_cache=index_args["use_cache"],  # --use-index-cache
+                    # unknown=index_args["unknown"],  # --unknown
+                    prefix=prefix,
+                    repodata_fn=repodata,
+                )
+            revision_idx = get_revision(args.revision)
+            with get_spinner(f"Reverting to revision {revision_idx}"):
+                unlink_link_transaction = revert_actions(prefix, revision_idx, index)
+
+    handle_txn(unlink_link_transaction, prefix, args, newenv=False)
+
+
 def revert_actions(prefix, revision=-1, index=None):
     # TODO: If revision raise a revision error, should always go back to a safe revision
     h = History(prefix)
@@ -540,13 +605,11 @@ def handle_txn(unlink_link_transaction, prefix, args, newenv, remove_op=False):
         raise CondaSystemExit("Exiting", e)
 
     if newenv:
-        touch_nonadmin(prefix)
         if context.subdir != context._native_subdir():
             set_keys(
                 ("subdir", context.subdir),
                 path=Path(prefix, ".condarc"),
             )
-        print_activate(args.name or prefix)
 
     if context.json:
         actions = unlink_link_transaction._make_legacy_action_groups()[0]
diff --git a/conda/cli/main_create.py b/conda/cli/main_create.py
index cbf7b1049a1..1e43335ed36 100644
--- a/conda/cli/main_create.py
+++ b/conda/cli/main_create.py
@@ -96,8 +96,9 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
     from ..exceptions import ArgumentError, CondaValueError, TooManyArgumentsError
     from ..gateways.disk.delete import rm_rf
     from ..gateways.disk.test import is_conda_environment
+    from ..misc import touch_nonadmin
     from ..reporters import confirm_yn
-    from .common import validate_subdir_config
+    from .common import print_activate, validate_subdir_config
     from .install import check_prefix, install, install_clone
 
     # Ensure provided combination of command line argments are valid
@@ -124,6 +125,8 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
     # Ensure no protected dirs are getting overwritten
     check_protected_dirs(context.target_prefix)
 
+    # Ensure the user wants to update the provided prefix if there is already
+    # an installed conda environment or other files in the target
     if is_conda_environment(context.target_prefix):
         if paths_equal(context.target_prefix, context.root_prefix):
             raise CondaValueError("The target prefix is the base prefix. Aborting.")
@@ -162,5 +165,8 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
         install_clone(args, parser)
     else:
         install(args, parser, "create")
+    # Run post-install steps applicable to all new environments
+    touch_nonadmin(context.target_prefix)
+    print_activate(args.name or context.target_prefix)
 
     return 0
diff --git a/conda/cli/main_install.py b/conda/cli/main_install.py
index 25fe2e88902..5b3901dc522 100644
--- a/conda/cli/main_install.py
+++ b/conda/cli/main_install.py
@@ -126,7 +126,8 @@ def configure_parser(sub_parsers: _SubParsersAction, **kwargs) -> ArgumentParser
 def execute(args: Namespace, parser: ArgumentParser) -> int:
     from ..base.context import context
     from ..exceptions import CondaValueError
-    from .install import get_revision, install
+    from .common import validate_prefix
+    from .install import get_revision, install, install_revision
 
     if context.force:
         print(
@@ -146,4 +147,12 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
             "too few arguments, must supply command line packages, --file or --revision"
         )
 
-    return install(args, parser, "install")
+    # Ensure the target prefix is a valid conda environment
+    validate_prefix(context.target_prefix)
+
+    if args.revision:
+        install_revision(args, parser)
+    else:
+        install(args, parser, "install")
+
+    return 0
diff --git a/news/14670-refactor-install b/news/14670-refactor-install
new file mode 100644
index 00000000000..fe34ebc0f11
--- /dev/null
+++ b/news/14670-refactor-install
@@ -0,0 +1,19 @@
+### Enhancements
+
+* <news item>
+
+### Bug fixes
+
+* <news item>
+
+### Deprecations
+
+* Mark `conda.cli.install.print_activate` as pending deprecation. Use `conda.cli.common.print_activate` instead. It will be permanently removed in 26.3. (#14670)
+
+### Docs
+
+* <news item>
+
+### Other
+
+* Refactor `--repodata-fn` iteration in `conda.cli.install` and other smaller cleanups. (#14670)


Reviews:
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T16:59:52Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T17:00:44Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T17:01:47Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T17:02:56Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T17:03:16Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T17:03:27Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T17:06:53Z
user: login: soapy1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T19:12:19Z
user: login: soapy1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T20:38:17Z
user: login: soapy1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T21:19:45Z
user: login: soapy1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T04:09:16Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T16:16:12Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T16:18:22Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T16:18:53Z
user: login: soapy1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T18:28:46Z
user: login: soapy1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T18:32:40Z
user: login: soapy1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T18:47:56Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-29T16:51:58Z
user: login: soapy1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T01:49:20Z
user: login: dholth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T20:36:05Z
user: login: soapy1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T21:39:47Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:20:41Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:20:57Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:21:27Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:26:43Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:28:24Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:28:37Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:30:50Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:32:02Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:32:50Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:38:06Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:39:52Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T09:50:28Z
user: login: jaimergp
body: I dropped a few comments as I read the changes. We are on the right track but two main things came to mind.

The first one: There are many prefix validations in different layers. 

If we are refactoring this part, it should be simpler. I know it was way more involved before, but right now I can see:
  - `conda.cli.main_create:execute`:  `check_prefix()`, `check_protected_dirs()`, several directory existence checks.
  - `conda.cli.main_install:execute`:  `validate_prefix()`
  - `conda.cli.main_update:execute`:  no `validate_prefix()`
  - `conda.cli.install:validate_install_command`: More directory checks, `conda-meta/history` existence, and whether is writable via `validate_prefix_is_writable`.
  - `conda.cli.main_remove` is not even in this PR, but there are a few prefix checks there as well. Same with the `conda.cli.main_env_*` family.
  
Seeing all these `check_prefix`, `validate_prefix`, `validate_prefix_is_writable`, `check_protected_dirs` utility functions, I wonder if we would be better off with a class that encompasses all these checks for a target directory. Many of these functions are overlapping and we could make more out of these system calls. Something like a `TargetPrefixValidator`.

---

The second one: the repodata loop refactor. It seems to me that is unrelated to the main goal of this PR, and could be better off on its own PR. If we do keep it here, we have to make sure that we are indeed breaking the repodata / unfreeze loop when successful. I think I saw a `break` clause being removed and I'm uncertain why.

state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-04T09:57:22Z
user: login: soapy1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T18:01:01Z
user: login: soapy1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T18:10:09Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T10:32:00Z
user: login: jaimergp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T10:33:58Z
user: login: jaimergp
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-18T13:50:15Z

Review comments:
diff_hunk: @@ -263,3 +267,51 @@ def validate_prefix(prefix):
         raise EnvironmentLocationNotFound(prefix)
 
     return prefix
+
+
+def validate_subdir_config():
+    """Validates that the configured subdir is ok. A subdir that is different from
+    the native system is only allowed if it comes from the global configration, or
path: conda/cli/common.py
user: login: jaimergp
body: ```suggestion
    the native system is only allowed if it comes from the global configuration, or
```
created_at: 2025-03-14T16:59:52Z
author_association: MEMBER
diff_hunk: @@ -263,3 +267,51 @@ def validate_prefix(prefix):
         raise EnvironmentLocationNotFound(prefix)
 
     return prefix
+
+
+def validate_subdir_config():
+    """Validates that the configured subdir is ok. A subdir that is different from
+    the native system is only allowed if it comes from the global configration, or
+    from an environment variable.
+
+    :raises OperationNotAllowed: Active environment is not allowed to request
+                                 non-native platform packages
+    """
+    if context.subdir != context._native_subdir():
+        # We will only allow a different subdir if it's specified by global
+        # configuration, environment variable or command line argument. IOW,
+        # prevent a non-base env configured for a non-native subdir from leaking
+        # its subdir to a newer env.
+        context_sources = context.collect_all()
+        if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
+            pass  # this is ok
+        elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
+            pass  # this is ok too
+        # config does not come from envvars or cmd_line, it must be a file
+        # that's ok as long as it's a base env or a global file
+        elif not paths_equal(context.active_prefix, context.root_prefix):
+            # this is only ok as long as it's base environment
path: conda/cli/common.py
user: login: jaimergp
body: ```suggestion
            # this is only ok as long as it's NOT base environment
```

If I'm reading correctly?
created_at: 2025-03-14T17:00:44Z
author_association: MEMBER
diff_hunk: @@ -172,120 +172,144 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exists - if it does not
path: conda/cli/install.py
user: login: jaimergp
body: ```suggestion
        # the conda-meta/history file must also exist - if it does not
```
created_at: 2025-03-14T17:01:47Z
author_association: MEMBER
diff_hunk: @@ -172,120 +172,144 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exists - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
path: conda/cli/install.py
user: login: jaimergp
body: https://github.com/conda/conda/pull/14668, aren't you checking if this file is writable too? Could do both here.
created_at: 2025-03-14T17:02:55Z
author_association: MEMBER
diff_hunk: @@ -172,120 +172,144 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exists - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
+            # fall-through expected under normal operation
+            pass
+        else:
             if paths_equal(prefix, context.conda_prefix):
                 raise NoBaseEnvironmentError()
             else:
                 if not path_is_clean(prefix):
                     raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            # fall-through expected under normal operation
-            pass
     else:
-        raise EnvironmentLocationNotFound(prefix)
+        # if this is not a new env and the prefix does not exist, then
+        # there is no existing environment - this is an error
+        EnvironmentLocationNotFound(prefix)
path: conda/cli/install.py
user: login: jaimergp
body: ```suggestion
        raise EnvironmentLocationNotFound(prefix)
```
created_at: 2025-03-14T17:03:15Z
author_association: MEMBER
diff_hunk: @@ -172,120 +172,144 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exists - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
+            # fall-through expected under normal operation
+            pass
+        else:
             if paths_equal(prefix, context.conda_prefix):
                 raise NoBaseEnvironmentError()
             else:
                 if not path_is_clean(prefix):
                     raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            # fall-through expected under normal operation
-            pass
     else:
-        raise EnvironmentLocationNotFound(prefix)
+        # if this is not a new env and the prefix does not exist, then
+        # there is no existing environment - this is an error
+        EnvironmentLocationNotFound(prefix)
 
-    args_packages = [s.strip("\"'") for s in args.packages]
-    if newenv and not args.no_default_packages:
-        # Override defaults if they are specified at the command line
-        names = [MatchSpec(pkg).name for pkg in args_packages]
-        for default_package in context.create_default_packages:
-            if MatchSpec(default_package).name not in names:
-                args_packages.append(default_package)
 
-    context_channels = context.channels
-    index_args = {
+def get_index_args(args) -> dict[str, any]:
+    """Retruns a dict of args required for fetching an index
path: conda/cli/install.py
user: login: jaimergp
body: ```suggestion
    """Returns a dict of args required for fetching an index
```
created_at: 2025-03-14T17:03:27Z
author_association: MEMBER
diff_hunk: @@ -479,6 +452,72 @@ def install(args, parser, command="install"):
     handle_txn(unlink_link_transaction, prefix, args, newenv)
 
 
+def install_revision(args, parser):
path: conda/cli/install.py
user: login: jaimergp
body: Since both `install` and `install_revision` end up with `handle_txn()`, would it make sense to architect this refactor in a way that `install*` functions return the transaction, and this is handled in a layer above?
created_at: 2025-03-14T17:06:53Z
author_association: MEMBER
diff_hunk: @@ -172,120 +172,144 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exists - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
path: conda/cli/install.py
user: login: soapy1
body: Yep! I think #14668 is scoped much more tightly and can be reviewed/merged before this. Will opt to pull those changes in when they get merged :fireworks: 
created_at: 2025-03-17T19:12:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -479,6 +452,72 @@ def install(args, parser, command="install"):
     handle_txn(unlink_link_transaction, prefix, args, newenv)
 
 
+def install_revision(args, parser):
path: conda/cli/install.py
user: login: soapy1
body: There is also `install_clone` to handle. This currently does not return a transaction, instead it executes the cloning action and prints the activation message. I think this can be refactored to be more in line with `install` and `install_revision`. 

However, I think it makes sense to me for the cli layer to deal with:
* validating that combinations of command line arguments are valid
* doing preliminary checks + validations to ensure that the whatever actions can be successful
* doing any post installation steps/validations/messaging

Then the install layer to deal with pulling together the appropriate plan for installation (given the command line arguments) and executing it.

How does that sound to you?
created_at: 2025-03-17T20:38:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -297,53 +360,20 @@ def install(args, parser, command="install"):
                     " packages \nconda create --help for details"
                 )
         if "@EXPLICIT" in specs:
+            # short circuit to installing explicit if explicit specs are provided
path: conda/cli/install.py
user: login: soapy1
body: This part of the code is kind of weird. If there is a mix of `--file`s that specify regular packages and explicit package then this will produce an error. For example, consider the following two environment files:

env1.txt
```
@EXPLICIT
https://repo.anaconda.com/pkgs/main/linux-64/_libgcc_mutex-0.1-main.conda
https://repo.anaconda.com/pkgs/main/linux-64/ca-certificates-2025.2.25-h06a4308_0.conda
```

env2.txt
```
numpy
```

Running a `conda create` against these files individually works. Providing both files as arguments produces an error:
```
$ conda create -n testenv-mixed --file env.txt --file env2.txt 

ParseError: Could not parse explicit URL: file:///home/sophia/projects/conda/numpy
```

Is this the intended behaviour here?

Would it be better to more clearly raise an error that specifying explicit files/packages are not compatible with specifying packages? That would include any combination of:
* explicit specs (eg. package files or `--file` arguments that have `@EXPLICIT` in them
* packages specified as part of `conda create` or as an entry in a `--file` argument

created_at: 2025-03-17T21:19:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -297,53 +360,20 @@ def install(args, parser, command="install"):
                     " packages \nconda create --help for details"
                 )
         if "@EXPLICIT" in specs:
+            # short circuit to installing explicit if explicit specs are provided
path: conda/cli/install.py
user: login: soapy1
body: Linking https://github.com/conda/ceps/pull/79 which describes the difference between these 2 types of input files
created_at: 2025-03-18T04:09:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -297,53 +360,20 @@ def install(args, parser, command="install"):
                     " packages \nconda create --help for details"
                 )
         if "@EXPLICIT" in specs:
+            # short circuit to installing explicit if explicit specs are provided
path: conda/cli/install.py
user: login: jaimergp
body: I wouldn't allow mixing `@EXPLICIT` (or, in the future, any other lockfile) with regular txt files. They have different purposes.

Basically, I wouldn't want `conda` to even touch the solver when all it needs to do is download, extract and link a series of packages. In that regard, I'd separate input fils in two categories: solverless (lockfiles like `@EXPLICIT` and `conda-lock.yml`) and solvable (environment.yml, requirements.txt).
created_at: 2025-03-18T16:16:11Z
author_association: MEMBER
diff_hunk: @@ -479,6 +452,72 @@ def install(args, parser, command="install"):
     handle_txn(unlink_link_transaction, prefix, args, newenv)
 
 
+def install_revision(args, parser):
path: conda/cli/install.py
user: login: jaimergp
body: Yes, in general I'd like to have a streamlined way of running all necessary pre-checks, then creating a transaction object, then reporting and post-checks.

Creating the transaction object here is the key step, because not every installation process results in one right now. Clones, explicits, revisions, and regular `conda create|install|update|remove` operations should all result in a transaction. Once we have a transaction, then reporting can be unified.
created_at: 2025-03-18T16:18:22Z
author_association: MEMBER
diff_hunk: @@ -479,6 +452,72 @@ def install(args, parser, command="install"):
     handle_txn(unlink_link_transaction, prefix, args, newenv)
 
 
+def install_revision(args, parser):
path: conda/cli/install.py
user: login: jaimergp
body: I think that's what I mean by transaction is what you meant by "installation plan", so we are in agreement.
created_at: 2025-03-18T16:18:53Z
author_association: MEMBER
diff_hunk: @@ -297,53 +360,20 @@ def install(args, parser, command="install"):
                     " packages \nconda create --help for details"
                 )
         if "@EXPLICIT" in specs:
+            # short circuit to installing explicit if explicit specs are provided
path: conda/cli/install.py
user: login: soapy1
body: Cool, this is also what I was thinking. I think this should not be addressed in this PR since it's a change in behaviour for conda. I'll open up an issue to raise an error if the user is mixing types of `file` arguments. 
created_at: 2025-03-18T18:28:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -479,6 +452,72 @@ def install(args, parser, command="install"):
     handle_txn(unlink_link_transaction, prefix, args, newenv)
 
 
+def install_revision(args, parser):
path: conda/cli/install.py
user: login: soapy1
body: Hmmm, ya I think we have a similar vision. I think this PR doesn't get all the way there. There is still more work to be done on aspects like collecting and parsing input files etc. I would advocate for punting on solving that fully for the purpose of this PR, so as not to explode the scope too much (I'd say the scope of this PR is to create a set of reusable validations, clarify the difference between different installation workflows and reduce the responsibility of `conda.cli.install.install`). But certainly address it in follow up work.
created_at: 2025-03-18T18:32:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -297,53 +360,20 @@ def install(args, parser, command="install"):
                     " packages \nconda create --help for details"
                 )
         if "@EXPLICIT" in specs:
+            # short circuit to installing explicit if explicit specs are provided
path: conda/cli/install.py
user: login: soapy1
body: https://github.com/conda/conda/issues/14684
created_at: 2025-03-18T18:47:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -361,76 +399,65 @@ def install(args, parser, command="install"):
         not in (UpdateModifier.FREEZE_INSTALLED, UpdateModifier.UPDATE_SPECS)
     ) and not newenv
 
-    for repodata_fn in repodata_fns:
+    update_modifier = context.update_modifier
+    if (isinstall or isremove) and args.update_modifier == NULL:
+        update_modifier = UpdateModifier.FREEZE_INSTALLED
+    deps_modifier = context.deps_modifier
+    if isupdate:
+        deps_modifier = context.deps_modifier or DepsModifier.UPDATE_SPECS
+
+    @retry_package_not_found(
+        repodata_fns=repodata_fns,
+        index_args=index_args,
+        retry_extra_errors=(
+            UnsatisfiableError,
+            SpecsConfigurationConflictError,
+            SystemExit,
+        ),
+    )
path: conda/cli/install.py
user: login: jaimergp
body: Having a decorated inline function is a bit strange. Would it be better to have `retry_package_not_found` as a context manager instead?
created_at: 2025-03-29T16:51:58Z
author_association: MEMBER
diff_hunk: @@ -361,76 +399,65 @@ def install(args, parser, command="install"):
         not in (UpdateModifier.FREEZE_INSTALLED, UpdateModifier.UPDATE_SPECS)
     ) and not newenv
 
-    for repodata_fn in repodata_fns:
+    update_modifier = context.update_modifier
+    if (isinstall or isremove) and args.update_modifier == NULL:
+        update_modifier = UpdateModifier.FREEZE_INSTALLED
+    deps_modifier = context.deps_modifier
+    if isupdate:
+        deps_modifier = context.deps_modifier or DepsModifier.UPDATE_SPECS
+
+    @retry_package_not_found(
+        repodata_fns=repodata_fns,
+        index_args=index_args,
+        retry_extra_errors=(
+            UnsatisfiableError,
+            SpecsConfigurationConflictError,
+            SystemExit,
+        ),
+    )
path: conda/cli/install.py
user: login: soapy1
body: ya, true! Giving a context manager a go
created_at: 2025-04-01T01:49:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -172,119 +171,222 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
path: conda/cli/install.py
user: login: dholth
body: Neat
created_at: 2025-04-01T20:30:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -361,76 +399,65 @@ def install(args, parser, command="install"):
         not in (UpdateModifier.FREEZE_INSTALLED, UpdateModifier.UPDATE_SPECS)
     ) and not newenv
 
-    for repodata_fn in repodata_fns:
+    update_modifier = context.update_modifier
+    if (isinstall or isremove) and args.update_modifier == NULL:
+        update_modifier = UpdateModifier.FREEZE_INSTALLED
+    deps_modifier = context.deps_modifier
+    if isupdate:
+        deps_modifier = context.deps_modifier or DepsModifier.UPDATE_SPECS
+
+    @retry_package_not_found(
+        repodata_fns=repodata_fns,
+        index_args=index_args,
+        retry_extra_errors=(
+            UnsatisfiableError,
+            SpecsConfigurationConflictError,
+            SystemExit,
+        ),
+    )
path: conda/cli/install.py
user: login: soapy1
body: @jaimergp I've updated this part of the code to use a context manager approach. Does this look better to you? Will add some extra unit tests to ensure that its expressing the right behaviour if this is the direction we want to go.
created_at: 2025-04-01T21:39:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
path: conda/cli/install.py
user: login: jaimergp
body: This should be an `isinstance()` check I think?
created_at: 2025-04-04T09:20:41Z
author_association: MEMBER
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
path: conda/cli/install.py
user: login: jaimergp
body: Same here, use `isinstance()`?
created_at: 2025-04-04T09:20:56Z
author_association: MEMBER
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+        ) + allows_errors
path: conda/cli/install.py
user: login: jaimergp
body: ```suggestion
        self.allowed_errors = (
            ResolvePackageNotFound,
            PackagesNotFoundError,
            *allows_errors,
        )
```
created_at: 2025-04-04T09:21:27Z
author_association: MEMBER
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+        ) + allows_errors
+
+    def __iter__(self):
+        for repodata in self.repodata_fns:
+            yield TryRepodata(
+                self.succeed,
+                repodata,
+                self.repodata_fns[-1],
+                self.index_args,
+                self.allowed_errors,
+            )
+            if self.success:
+                break
+
+    def succeed(self):
+        self.success = True
+
+
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exist - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
path: conda/cli/install.py
user: login: jaimergp
body: `validate_prefix()` is doing some of what this block does, there's some duplication there I don't understand. This part should be more streamlined.
created_at: 2025-04-04T09:26:43Z
author_association: MEMBER
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+        ) + allows_errors
+
+    def __iter__(self):
+        for repodata in self.repodata_fns:
+            yield TryRepodata(
+                self.succeed,
+                repodata,
+                self.repodata_fns[-1],
+                self.index_args,
+                self.allowed_errors,
+            )
+            if self.success:
+                break
+
+    def succeed(self):
+        self.success = True
+
+
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exist - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
+            # if the prefix exists, ensure that it is writable
+            validate_prefix_is_writable(prefix)
+        else:
             if paths_equal(prefix, context.conda_prefix):
                 raise NoBaseEnvironmentError()
             else:
                 if not path_is_clean(prefix):
                     raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            validate_prefix_is_writable(prefix)
     else:
+        # if this is not a new env and the prefix does not exist, then
+        # there is no existing environment - this is an error
         raise EnvironmentLocationNotFound(prefix)
 
-    args_packages = [s.strip("\"'") for s in args.packages]
-    if newenv and not args.no_default_packages:
-        # Override defaults if they are specified at the command line
-        names = [MatchSpec(pkg).name for pkg in args_packages]
-        for default_package in context.create_default_packages:
-            if MatchSpec(default_package).name not in names:
-                args_packages.append(default_package)
 
-    context_channels = context.channels
-    index_args = {
+def get_index_args(args) -> dict[str, any]:
+    """Returns a dict of args required for fetching an index
+
+    :param args: The args provided by the cli
+    :returns: dict of index args
+    """
+    return {
         # TODO: deprecate --use-index-cache
         # "use_cache": args.use_index_cache,  # --use-index-cache
-        "channel_urls": context_channels,
+        "channel_urls": context.channels,
         # TODO: deprecate --unknown
         # "unknown": args.unknown,  # --unknown
         "prepend": not args.override_channels,  # --override-channels
         "use_local": args.use_local,  # --use-local
     }
 
+
+def ensure_update_specs_exist(prefix: str, specs: list[str]):
+    """Checks that each spec that is requested as an update exists in the prefix
+
+    :param prefix: The target install prefix
+    :param specs: List of specs to be updated
+    :raises CondaError: if there is an invalid spec provided
+    :raises PackageNotInstalledError: if the requested specs to install don't exist in the prefix
+    """
+    prefix_data = PrefixData(prefix)
+    for spec in specs:
+        spec = MatchSpec(spec)
+        if not spec.is_name_only_spec:
+            raise CondaError(
+                f"Invalid spec for 'conda update': {spec}\nUse 'conda install' instead."
+            )
path: conda/cli/install.py
user: login: jaimergp
body: We should accumulate all offending specs, and then report them together.
created_at: 2025-04-04T09:28:24Z
author_association: MEMBER
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+        ) + allows_errors
+
+    def __iter__(self):
+        for repodata in self.repodata_fns:
+            yield TryRepodata(
+                self.succeed,
+                repodata,
+                self.repodata_fns[-1],
+                self.index_args,
+                self.allowed_errors,
+            )
+            if self.success:
+                break
+
+    def succeed(self):
+        self.success = True
+
+
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exist - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
+            # if the prefix exists, ensure that it is writable
+            validate_prefix_is_writable(prefix)
+        else:
             if paths_equal(prefix, context.conda_prefix):
                 raise NoBaseEnvironmentError()
             else:
                 if not path_is_clean(prefix):
                     raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            validate_prefix_is_writable(prefix)
     else:
+        # if this is not a new env and the prefix does not exist, then
+        # there is no existing environment - this is an error
         raise EnvironmentLocationNotFound(prefix)
 
-    args_packages = [s.strip("\"'") for s in args.packages]
-    if newenv and not args.no_default_packages:
-        # Override defaults if they are specified at the command line
-        names = [MatchSpec(pkg).name for pkg in args_packages]
-        for default_package in context.create_default_packages:
-            if MatchSpec(default_package).name not in names:
-                args_packages.append(default_package)
 
-    context_channels = context.channels
-    index_args = {
+def get_index_args(args) -> dict[str, any]:
+    """Returns a dict of args required for fetching an index
+
+    :param args: The args provided by the cli
+    :returns: dict of index args
+    """
+    return {
         # TODO: deprecate --use-index-cache
         # "use_cache": args.use_index_cache,  # --use-index-cache
-        "channel_urls": context_channels,
+        "channel_urls": context.channels,
         # TODO: deprecate --unknown
         # "unknown": args.unknown,  # --unknown
         "prepend": not args.override_channels,  # --override-channels
         "use_local": args.use_local,  # --use-local
     }
 
+
+def ensure_update_specs_exist(prefix: str, specs: list[str]):
+    """Checks that each spec that is requested as an update exists in the prefix
+
+    :param prefix: The target install prefix
+    :param specs: List of specs to be updated
+    :raises CondaError: if there is an invalid spec provided
+    :raises PackageNotInstalledError: if the requested specs to install don't exist in the prefix
+    """
+    prefix_data = PrefixData(prefix)
+    for spec in specs:
+        spec = MatchSpec(spec)
+        if not spec.is_name_only_spec:
+            raise CondaError(
+                f"Invalid spec for 'conda update': {spec}\nUse 'conda install' instead."
+            )
+        if not prefix_data.get(spec.name, None):
+            raise PackageNotInstalledError(prefix, spec.name)
path: conda/cli/install.py
user: login: jaimergp
body: Same here.
created_at: 2025-04-04T09:28:37Z
author_association: MEMBER
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+        ) + allows_errors
+
+    def __iter__(self):
+        for repodata in self.repodata_fns:
+            yield TryRepodata(
+                self.succeed,
+                repodata,
+                self.repodata_fns[-1],
+                self.index_args,
+                self.allowed_errors,
+            )
+            if self.success:
+                break
+
+    def succeed(self):
+        self.success = True
+
+
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exist - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
+            # if the prefix exists, ensure that it is writable
+            validate_prefix_is_writable(prefix)
+        else:
             if paths_equal(prefix, context.conda_prefix):
                 raise NoBaseEnvironmentError()
             else:
                 if not path_is_clean(prefix):
                     raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            validate_prefix_is_writable(prefix)
     else:
+        # if this is not a new env and the prefix does not exist, then
+        # there is no existing environment - this is an error
         raise EnvironmentLocationNotFound(prefix)
 
-    args_packages = [s.strip("\"'") for s in args.packages]
-    if newenv and not args.no_default_packages:
-        # Override defaults if they are specified at the command line
-        names = [MatchSpec(pkg).name for pkg in args_packages]
-        for default_package in context.create_default_packages:
-            if MatchSpec(default_package).name not in names:
-                args_packages.append(default_package)
 
-    context_channels = context.channels
-    index_args = {
+def get_index_args(args) -> dict[str, any]:
+    """Returns a dict of args required for fetching an index
+
+    :param args: The args provided by the cli
+    :returns: dict of index args
+    """
+    return {
         # TODO: deprecate --use-index-cache
         # "use_cache": args.use_index_cache,  # --use-index-cache
-        "channel_urls": context_channels,
+        "channel_urls": context.channels,
         # TODO: deprecate --unknown
         # "unknown": args.unknown,  # --unknown
         "prepend": not args.override_channels,  # --override-channels
         "use_local": args.use_local,  # --use-local
     }
 
+
+def ensure_update_specs_exist(prefix: str, specs: list[str]):
+    """Checks that each spec that is requested as an update exists in the prefix
+
+    :param prefix: The target install prefix
+    :param specs: List of specs to be updated
+    :raises CondaError: if there is an invalid spec provided
+    :raises PackageNotInstalledError: if the requested specs to install don't exist in the prefix
+    """
+    prefix_data = PrefixData(prefix)
+    for spec in specs:
+        spec = MatchSpec(spec)
+        if not spec.is_name_only_spec:
+            raise CondaError(
+                f"Invalid spec for 'conda update': {spec}\nUse 'conda install' instead."
+            )
+        if not prefix_data.get(spec.name, None):
+            raise PackageNotInstalledError(prefix, spec.name)
+
+
+def install(args, parser, command="install"):
+    """Logic for `conda install`, `conda update`, and `conda create`."""
+    prefix = context.target_prefix
+    newenv = bool(command == "create")
+    isupdate = bool(command == "update")
+    isinstall = bool(command == "install")
+    isremove = bool(command == "remove")
path: conda/cli/install.py
user: login: jaimergp
body: The `bool()` calls here are redundant 🤔 
created_at: 2025-04-04T09:30:50Z
author_association: MEMBER
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+        ) + allows_errors
+
+    def __iter__(self):
+        for repodata in self.repodata_fns:
+            yield TryRepodata(
+                self.succeed,
+                repodata,
+                self.repodata_fns[-1],
+                self.index_args,
+                self.allowed_errors,
+            )
+            if self.success:
+                break
+
+    def succeed(self):
+        self.success = True
+
+
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exist - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
+            # if the prefix exists, ensure that it is writable
+            validate_prefix_is_writable(prefix)
+        else:
             if paths_equal(prefix, context.conda_prefix):
                 raise NoBaseEnvironmentError()
             else:
                 if not path_is_clean(prefix):
                     raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            validate_prefix_is_writable(prefix)
     else:
+        # if this is not a new env and the prefix does not exist, then
+        # there is no existing environment - this is an error
         raise EnvironmentLocationNotFound(prefix)
 
-    args_packages = [s.strip("\"'") for s in args.packages]
-    if newenv and not args.no_default_packages:
-        # Override defaults if they are specified at the command line
-        names = [MatchSpec(pkg).name for pkg in args_packages]
-        for default_package in context.create_default_packages:
-            if MatchSpec(default_package).name not in names:
-                args_packages.append(default_package)
 
-    context_channels = context.channels
-    index_args = {
+def get_index_args(args) -> dict[str, any]:
+    """Returns a dict of args required for fetching an index
+
+    :param args: The args provided by the cli
+    :returns: dict of index args
+    """
+    return {
         # TODO: deprecate --use-index-cache
         # "use_cache": args.use_index_cache,  # --use-index-cache
-        "channel_urls": context_channels,
+        "channel_urls": context.channels,
         # TODO: deprecate --unknown
         # "unknown": args.unknown,  # --unknown
         "prepend": not args.override_channels,  # --override-channels
         "use_local": args.use_local,  # --use-local
     }
 
+
+def ensure_update_specs_exist(prefix: str, specs: list[str]):
+    """Checks that each spec that is requested as an update exists in the prefix
+
+    :param prefix: The target install prefix
+    :param specs: List of specs to be updated
+    :raises CondaError: if there is an invalid spec provided
+    :raises PackageNotInstalledError: if the requested specs to install don't exist in the prefix
+    """
+    prefix_data = PrefixData(prefix)
+    for spec in specs:
+        spec = MatchSpec(spec)
+        if not spec.is_name_only_spec:
+            raise CondaError(
+                f"Invalid spec for 'conda update': {spec}\nUse 'conda install' instead."
+            )
+        if not prefix_data.get(spec.name, None):
+            raise PackageNotInstalledError(prefix, spec.name)
+
+
+def install(args, parser, command="install"):
+    """Logic for `conda install`, `conda update`, and `conda create`."""
+    prefix = context.target_prefix
+    newenv = bool(command == "create")
+    isupdate = bool(command == "update")
+    isinstall = bool(command == "install")
+    isremove = bool(command == "remove")
+
+    # common validations for all types of installs
+    validate_install_command(prefix=prefix, newenv=newenv)
+
+    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
+    #    because it deduplicates records that exist as both formats.  Forcing this to
+    #    repodata.json ensures that .tar.bz2 files are available
+    if context.use_only_tar_bz2:
+        args.repodata_fns = ("repodata.json",)
path: conda/cli/install.py
user: login: jaimergp
body: We should only override if the user didn't provide their own. e.g. it would be surprising if we do this when they provided `--repodata-fn=my-custom-repodata.json`.
created_at: 2025-04-04T09:32:01Z
author_association: MEMBER
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+        ) + allows_errors
+
+    def __iter__(self):
+        for repodata in self.repodata_fns:
+            yield TryRepodata(
+                self.succeed,
+                repodata,
+                self.repodata_fns[-1],
+                self.index_args,
+                self.allowed_errors,
+            )
+            if self.success:
+                break
+
+    def succeed(self):
+        self.success = True
+
+
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exist - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
+            # if the prefix exists, ensure that it is writable
+            validate_prefix_is_writable(prefix)
+        else:
             if paths_equal(prefix, context.conda_prefix):
                 raise NoBaseEnvironmentError()
             else:
                 if not path_is_clean(prefix):
                     raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            validate_prefix_is_writable(prefix)
     else:
+        # if this is not a new env and the prefix does not exist, then
+        # there is no existing environment - this is an error
         raise EnvironmentLocationNotFound(prefix)
 
-    args_packages = [s.strip("\"'") for s in args.packages]
-    if newenv and not args.no_default_packages:
-        # Override defaults if they are specified at the command line
-        names = [MatchSpec(pkg).name for pkg in args_packages]
-        for default_package in context.create_default_packages:
-            if MatchSpec(default_package).name not in names:
-                args_packages.append(default_package)
 
-    context_channels = context.channels
-    index_args = {
+def get_index_args(args) -> dict[str, any]:
+    """Returns a dict of args required for fetching an index
+
+    :param args: The args provided by the cli
+    :returns: dict of index args
+    """
+    return {
         # TODO: deprecate --use-index-cache
         # "use_cache": args.use_index_cache,  # --use-index-cache
-        "channel_urls": context_channels,
+        "channel_urls": context.channels,
         # TODO: deprecate --unknown
         # "unknown": args.unknown,  # --unknown
         "prepend": not args.override_channels,  # --override-channels
         "use_local": args.use_local,  # --use-local
     }
 
+
+def ensure_update_specs_exist(prefix: str, specs: list[str]):
+    """Checks that each spec that is requested as an update exists in the prefix
+
+    :param prefix: The target install prefix
+    :param specs: List of specs to be updated
+    :raises CondaError: if there is an invalid spec provided
+    :raises PackageNotInstalledError: if the requested specs to install don't exist in the prefix
+    """
+    prefix_data = PrefixData(prefix)
+    for spec in specs:
+        spec = MatchSpec(spec)
+        if not spec.is_name_only_spec:
+            raise CondaError(
+                f"Invalid spec for 'conda update': {spec}\nUse 'conda install' instead."
+            )
+        if not prefix_data.get(spec.name, None):
+            raise PackageNotInstalledError(prefix, spec.name)
+
+
+def install(args, parser, command="install"):
+    """Logic for `conda install`, `conda update`, and `conda create`."""
+    prefix = context.target_prefix
+    newenv = bool(command == "create")
+    isupdate = bool(command == "update")
+    isinstall = bool(command == "install")
+    isremove = bool(command == "remove")
+
+    # common validations for all types of installs
+    validate_install_command(prefix=prefix, newenv=newenv)
+
+    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
+    #    because it deduplicates records that exist as both formats.  Forcing this to
+    #    repodata.json ensures that .tar.bz2 files are available
+    if context.use_only_tar_bz2:
+        args.repodata_fns = ("repodata.json",)
+
+    # ensure trash is cleared from existing prefix
+    if isdir(prefix):
+        delete_trash(prefix)
path: conda/cli/install.py
user: login: jaimergp
body: Should we swallow exceptions here if there's an error while cleaning up? Maybe it shouldn't be fatal.
created_at: 2025-04-04T09:32:50Z
author_association: MEMBER
diff_hunk: @@ -361,123 +408,122 @@ def install(args, parser, command="install"):
         not in (UpdateModifier.FREEZE_INSTALLED, UpdateModifier.UPDATE_SPECS)
     ) and not newenv
 
-    for repodata_fn in repodata_fns:
-        try:
-            if isinstall and args.revision:
-                with get_spinner(f"Collecting package metadata ({repodata_fn})"):
-                    index = get_index(
-                        channel_urls=index_args["channel_urls"],
-                        prepend=index_args["prepend"],  # --override-channels
-                        platform=None,
-                        use_local=index_args["use_local"],  # --use-local
-                        # use_cache=index_args["use_cache"],  # --use-index-cache
-                        # unknown=index_args["unknown"],  # --unknown
-                        prefix=prefix,
-                        repodata_fn=repodata_fn,
-                    )
-                revision_idx = get_revision(args.revision)
-                with get_spinner(f"Reverting to revision {revision_idx}"):
-                    unlink_link_transaction = revert_actions(
-                        prefix, revision_idx, index
-                    )
-            else:
-                solver_backend = context.plugin_manager.get_cached_solver_backend()
-                solver = solver_backend(
-                    prefix,
-                    context_channels,
-                    context.subdirs,
-                    specs_to_add=specs,
-                    repodata_fn=repodata_fn,
-                    command=args.cmd,
-                )
-                update_modifier = context.update_modifier
-                if (isinstall or isremove) and args.update_modifier == NULL:
-                    update_modifier = UpdateModifier.FREEZE_INSTALLED
-                deps_modifier = context.deps_modifier
-                if isupdate:
-                    deps_modifier = context.deps_modifier or DepsModifier.UPDATE_SPECS
-
+    update_modifier = context.update_modifier
+    if (isinstall or isremove) and args.update_modifier == NULL:
+        update_modifier = UpdateModifier.FREEZE_INSTALLED
+    deps_modifier = context.deps_modifier
+    if isupdate:
+        deps_modifier = context.deps_modifier or DepsModifier.UPDATE_SPECS
+
+    for repodata_fn in Repodatas(
+        repodata_fns,
+        index_args,
+        (UnsatisfiableError, SpecsConfigurationConflictError, SystemExit),
+    ):
+        with repodata_fn as repodata:
+            solver_backend = context.plugin_manager.get_cached_solver_backend()
+            solver = solver_backend(
+                prefix,
+                context_channels,
+                context.subdirs,
+                specs_to_add=specs,
+                repodata_fn=repodata,
+                command=args.cmd,
+            )
+            try:
                 unlink_link_transaction = solver.solve_for_transaction(
                     deps_modifier=deps_modifier,
                     update_modifier=update_modifier,
                     force_reinstall=context.force_reinstall or context.force,
                     should_retry_solve=(
-                        _should_retry_unfrozen or repodata_fn != repodata_fns[-1]
+                        _should_retry_unfrozen or repodata != repodata_fns[-1]
                     ),
                 )
-            # we only need one of these to work.  If we haven't raised an exception,
-            #   we're good.
-            break
path: conda/cli/install.py
user: login: jaimergp
body: We are not breaking early if successful anymore?
created_at: 2025-04-04T09:38:06Z
author_association: MEMBER
diff_hunk: @@ -137,4 +139,18 @@ def execute(args: Namespace, parser: ArgumentParser) -> int:
             file=sys.stderr,
         )
 
-    return install(args, parser, "install")
+    # Ensure provided combination of command line argments are valid
path: conda/cli/main_install.py
user: login: jaimergp
body: ```suggestion
    # Ensure provided combination of command line arguments are valid
```
created_at: 2025-04-04T09:39:52Z
author_association: MEMBER
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+        ) + allows_errors
+
+    def __iter__(self):
+        for repodata in self.repodata_fns:
+            yield TryRepodata(
+                self.succeed,
+                repodata,
+                self.repodata_fns[-1],
+                self.index_args,
+                self.allowed_errors,
+            )
+            if self.success:
+                break
+
+    def succeed(self):
+        self.success = True
+
+
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exist - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
path: conda/cli/install.py
user: login: jaimergp
body: Also `conda-meta/history` is a constant: `PREFIX_MAGIC_FILE`.
created_at: 2025-04-04T09:50:28Z
author_association: MEMBER
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+        ) + allows_errors
+
+    def __iter__(self):
+        for repodata in self.repodata_fns:
+            yield TryRepodata(
+                self.succeed,
+                repodata,
+                self.repodata_fns[-1],
+                self.index_args,
+                self.allowed_errors,
+            )
+            if self.success:
+                break
+
+    def succeed(self):
+        self.success = True
+
+
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exist - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
+            # if the prefix exists, ensure that it is writable
+            validate_prefix_is_writable(prefix)
+        else:
             if paths_equal(prefix, context.conda_prefix):
                 raise NoBaseEnvironmentError()
             else:
                 if not path_is_clean(prefix):
                     raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            validate_prefix_is_writable(prefix)
     else:
+        # if this is not a new env and the prefix does not exist, then
+        # there is no existing environment - this is an error
         raise EnvironmentLocationNotFound(prefix)
 
-    args_packages = [s.strip("\"'") for s in args.packages]
-    if newenv and not args.no_default_packages:
-        # Override defaults if they are specified at the command line
-        names = [MatchSpec(pkg).name for pkg in args_packages]
-        for default_package in context.create_default_packages:
-            if MatchSpec(default_package).name not in names:
-                args_packages.append(default_package)
 
-    context_channels = context.channels
-    index_args = {
+def get_index_args(args) -> dict[str, any]:
+    """Returns a dict of args required for fetching an index
+
+    :param args: The args provided by the cli
+    :returns: dict of index args
+    """
+    return {
         # TODO: deprecate --use-index-cache
         # "use_cache": args.use_index_cache,  # --use-index-cache
-        "channel_urls": context_channels,
+        "channel_urls": context.channels,
         # TODO: deprecate --unknown
         # "unknown": args.unknown,  # --unknown
         "prepend": not args.override_channels,  # --override-channels
         "use_local": args.use_local,  # --use-local
     }
 
+
+def ensure_update_specs_exist(prefix: str, specs: list[str]):
+    """Checks that each spec that is requested as an update exists in the prefix
+
+    :param prefix: The target install prefix
+    :param specs: List of specs to be updated
+    :raises CondaError: if there is an invalid spec provided
+    :raises PackageNotInstalledError: if the requested specs to install don't exist in the prefix
+    """
+    prefix_data = PrefixData(prefix)
+    for spec in specs:
+        spec = MatchSpec(spec)
+        if not spec.is_name_only_spec:
+            raise CondaError(
+                f"Invalid spec for 'conda update': {spec}\nUse 'conda install' instead."
+            )
+        if not prefix_data.get(spec.name, None):
+            raise PackageNotInstalledError(prefix, spec.name)
+
+
+def install(args, parser, command="install"):
+    """Logic for `conda install`, `conda update`, and `conda create`."""
+    prefix = context.target_prefix
+    newenv = bool(command == "create")
+    isupdate = bool(command == "update")
+    isinstall = bool(command == "install")
+    isremove = bool(command == "remove")
+
+    # common validations for all types of installs
+    validate_install_command(prefix=prefix, newenv=newenv)
+
+    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
+    #    because it deduplicates records that exist as both formats.  Forcing this to
+    #    repodata.json ensures that .tar.bz2 files are available
+    if context.use_only_tar_bz2:
+        args.repodata_fns = ("repodata.json",)
path: conda/cli/install.py
user: login: soapy1
body: I think this would be a pretty big change in behaviour and not appropriate to be in a refactor PR. For reference, compare this to the main branch https://github.com/conda/conda/blob/main/conda/cli/install.py#L182

Can you create a seperate issue (or is this a bug?) to address this.
created_at: 2025-04-07T18:01:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -361,123 +408,122 @@ def install(args, parser, command="install"):
         not in (UpdateModifier.FREEZE_INSTALLED, UpdateModifier.UPDATE_SPECS)
     ) and not newenv
 
-    for repodata_fn in repodata_fns:
-        try:
-            if isinstall and args.revision:
-                with get_spinner(f"Collecting package metadata ({repodata_fn})"):
-                    index = get_index(
-                        channel_urls=index_args["channel_urls"],
-                        prepend=index_args["prepend"],  # --override-channels
-                        platform=None,
-                        use_local=index_args["use_local"],  # --use-local
-                        # use_cache=index_args["use_cache"],  # --use-index-cache
-                        # unknown=index_args["unknown"],  # --unknown
-                        prefix=prefix,
-                        repodata_fn=repodata_fn,
-                    )
-                revision_idx = get_revision(args.revision)
-                with get_spinner(f"Reverting to revision {revision_idx}"):
-                    unlink_link_transaction = revert_actions(
-                        prefix, revision_idx, index
-                    )
-            else:
-                solver_backend = context.plugin_manager.get_cached_solver_backend()
-                solver = solver_backend(
-                    prefix,
-                    context_channels,
-                    context.subdirs,
-                    specs_to_add=specs,
-                    repodata_fn=repodata_fn,
-                    command=args.cmd,
-                )
-                update_modifier = context.update_modifier
-                if (isinstall or isremove) and args.update_modifier == NULL:
-                    update_modifier = UpdateModifier.FREEZE_INSTALLED
-                deps_modifier = context.deps_modifier
-                if isupdate:
-                    deps_modifier = context.deps_modifier or DepsModifier.UPDATE_SPECS
-
+    update_modifier = context.update_modifier
+    if (isinstall or isremove) and args.update_modifier == NULL:
+        update_modifier = UpdateModifier.FREEZE_INSTALLED
+    deps_modifier = context.deps_modifier
+    if isupdate:
+        deps_modifier = context.deps_modifier or DepsModifier.UPDATE_SPECS
+
+    for repodata_fn in Repodatas(
+        repodata_fns,
+        index_args,
+        (UnsatisfiableError, SpecsConfigurationConflictError, SystemExit),
+    ):
+        with repodata_fn as repodata:
+            solver_backend = context.plugin_manager.get_cached_solver_backend()
+            solver = solver_backend(
+                prefix,
+                context_channels,
+                context.subdirs,
+                specs_to_add=specs,
+                repodata_fn=repodata,
+                command=args.cmd,
+            )
+            try:
                 unlink_link_transaction = solver.solve_for_transaction(
                     deps_modifier=deps_modifier,
                     update_modifier=update_modifier,
                     force_reinstall=context.force_reinstall or context.force,
                     should_retry_solve=(
-                        _should_retry_unfrozen or repodata_fn != repodata_fns[-1]
+                        _should_retry_unfrozen or repodata != repodata_fns[-1]
                     ),
                 )
-            # we only need one of these to work.  If we haven't raised an exception,
-            #   we're good.
-            break
path: conda/cli/install.py
user: login: soapy1
body: We are. This part of the control flow is handled in the iterator/context manager bit. See https://github.com/conda/conda/pull/14670/files#diff-30852b2709103c10b41b1639d623f9d7eb8588f44f24ccf8cb35e8b4171866b0R238 and https://github.com/conda/conda/pull/14670/files#diff-30852b2709103c10b41b1639d623f9d7eb8588f44f24ccf8cb35e8b4171866b0R187
created_at: 2025-04-07T18:10:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+        ) + allows_errors
+
+    def __iter__(self):
+        for repodata in self.repodata_fns:
+            yield TryRepodata(
+                self.succeed,
+                repodata,
+                self.repodata_fns[-1],
+                self.index_args,
+                self.allowed_errors,
+            )
+            if self.success:
+                break
+
+    def succeed(self):
+        self.success = True
+
+
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exist - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
+            # if the prefix exists, ensure that it is writable
+            validate_prefix_is_writable(prefix)
+        else:
             if paths_equal(prefix, context.conda_prefix):
                 raise NoBaseEnvironmentError()
             else:
                 if not path_is_clean(prefix):
                     raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            validate_prefix_is_writable(prefix)
     else:
+        # if this is not a new env and the prefix does not exist, then
+        # there is no existing environment - this is an error
         raise EnvironmentLocationNotFound(prefix)
 
-    args_packages = [s.strip("\"'") for s in args.packages]
-    if newenv and not args.no_default_packages:
-        # Override defaults if they are specified at the command line
-        names = [MatchSpec(pkg).name for pkg in args_packages]
-        for default_package in context.create_default_packages:
-            if MatchSpec(default_package).name not in names:
-                args_packages.append(default_package)
 
-    context_channels = context.channels
-    index_args = {
+def get_index_args(args) -> dict[str, any]:
+    """Returns a dict of args required for fetching an index
+
+    :param args: The args provided by the cli
+    :returns: dict of index args
+    """
+    return {
         # TODO: deprecate --use-index-cache
         # "use_cache": args.use_index_cache,  # --use-index-cache
-        "channel_urls": context_channels,
+        "channel_urls": context.channels,
         # TODO: deprecate --unknown
         # "unknown": args.unknown,  # --unknown
         "prepend": not args.override_channels,  # --override-channels
         "use_local": args.use_local,  # --use-local
     }
 
+
+def ensure_update_specs_exist(prefix: str, specs: list[str]):
+    """Checks that each spec that is requested as an update exists in the prefix
+
+    :param prefix: The target install prefix
+    :param specs: List of specs to be updated
+    :raises CondaError: if there is an invalid spec provided
+    :raises PackageNotInstalledError: if the requested specs to install don't exist in the prefix
+    """
+    prefix_data = PrefixData(prefix)
+    for spec in specs:
+        spec = MatchSpec(spec)
+        if not spec.is_name_only_spec:
+            raise CondaError(
+                f"Invalid spec for 'conda update': {spec}\nUse 'conda install' instead."
+            )
+        if not prefix_data.get(spec.name, None):
+            raise PackageNotInstalledError(prefix, spec.name)
+
+
+def install(args, parser, command="install"):
+    """Logic for `conda install`, `conda update`, and `conda create`."""
+    prefix = context.target_prefix
+    newenv = bool(command == "create")
+    isupdate = bool(command == "update")
+    isinstall = bool(command == "install")
+    isremove = bool(command == "remove")
+
+    # common validations for all types of installs
+    validate_install_command(prefix=prefix, newenv=newenv)
+
+    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
+    #    because it deduplicates records that exist as both formats.  Forcing this to
+    #    repodata.json ensures that .tar.bz2 files are available
+    if context.use_only_tar_bz2:
+        args.repodata_fns = ("repodata.json",)
path: conda/cli/install.py
user: login: jaimergp
body: I'll raise an issue and assign you, thanks.
created_at: 2025-04-08T10:32:00Z
author_association: MEMBER
diff_hunk: @@ -172,119 +170,201 @@ def get_revision(arg, json=False):
         raise CondaValueError(f"expected revision number, not: '{arg}'", json)
 
 
-def install(args, parser, command="install"):
-    """Logic for `conda install`, `conda update`, and `conda create`."""
+class TryRepodata:
+    def __init__(
+        self, notify_success, repodata, last_repodata, index_args, allowed_errors
+    ):
+        self.notify_success = notify_success
+        self.repodata = repodata
+        self.last_repodata = last_repodata
+        self.index_args = index_args
+        self.allowed_errors = allowed_errors
+
+    def __enter__(self):
+        return self.repodata
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if not exc_value:
+            self.notify_success()
+
+        # Swallow the error to allow for the next repodata to be tried if:
+        # 1. the error is in the 'allowed_errors' type
+        # 2. there are more repodatas to try AND
+        # 3. the error says it's okay to retry
+        #
+        # TODO: Regarding (3) This is a temporary workaround to allow downstream libraries
+        # to inject this attribute set to False and skip the retry logic
+        # Other solvers might implement their own internal retry logic without
+        # depending --freeze-install implicitly like conda classic does. Example
+        # retry loop in conda-libmamba-solver:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/da5b1ba/conda_libmamba_solver/solver.py#L254-L299
+        # If we end up raising UnsatisfiableError, we annotate it with `allow_retry`
+        # so we don't have go through all the repodatas and freeze-installed logic
+        # unnecessarily (see https://github.com/conda/conda/issues/11294). see also:
+        # https://github.com/conda-incubator/conda-libmamba-solver/blob/7c698209/conda_libmamba_solver/solver.py#L617
+        if (
+            (exc_type in self.allowed_errors)
+            and (self.repodata != self.last_repodata)
+            and getattr(exc_value, "allow_retry", True)
+        ):
+            return True
+        elif exc_type == ResolvePackageNotFound:
+            # transform a ResolvePackageNotFound into PackagesNotFoundError
+            channels_urls = tuple(
+                calculate_channel_urls(
+                    channel_urls=self.index_args["channel_urls"],
+                    prepend=self.index_args["prepend"],
+                    platform=None,
+                    use_local=self.index_args["use_local"],
+                )
+            )
+            # convert the ResolvePackageNotFound into PackagesNotFoundError
+            raise PackagesNotFoundError(exc_value._formatted_chains, channels_urls)
+
+
+class Repodatas:
+    def __init__(self, repodata_fns, index_args, allows_errors=()):
+        self.repodata_fns = repodata_fns
+        self.index_args = index_args
+        self.success = False
+        self.allowed_errors = (
+            ResolvePackageNotFound,
+            PackagesNotFoundError,
+        ) + allows_errors
+
+    def __iter__(self):
+        for repodata in self.repodata_fns:
+            yield TryRepodata(
+                self.succeed,
+                repodata,
+                self.repodata_fns[-1],
+                self.index_args,
+                self.allowed_errors,
+            )
+            if self.success:
+                break
+
+    def succeed(self):
+        self.success = True
+
+
+def validate_install_command(prefix: str, newenv: bool):
+    """Executes a set of validations that are required before any installation
+    command is executed.
+
+    :param prefix: The prefix where the environment will be created
+    :param newenv: Boolean indicating the command is a request for a new environment
+    :raises: error if the configuration for the install is bad
+    """
     context.validate_configuration()
     check_non_admin()
-    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
-    #    because it deduplicates records that exist as both formats.  Forcing this to
-    #    repodata.json ensures that .tar.bz2 files are available
-    if context.use_only_tar_bz2:
-        args.repodata_fns = ("repodata.json",)
 
-    newenv = bool(command == "create")
-    isupdate = bool(command == "update")
-    isinstall = bool(command == "install")
-    isremove = bool(command == "remove")
-    prefix = context.target_prefix
     if context.force_32bit and prefix == context.root_prefix:
         raise CondaValueError("cannot use CONDA_FORCE_32BIT=1 in base env")
-    if isupdate and not (
-        args.file
-        or args.packages
-        or context.update_modifier == UpdateModifier.UPDATE_ALL
-    ):
-        raise CondaValueError(
-            """no package names supplied
-# Example: conda update -n myenv scipy
-"""
-        )
 
+    # Ensure the prefix exists if it is meant to
     if newenv:
-        check_prefix(prefix, json=context.json)
-        if context.subdir != context._native_subdir():
-            # We will only allow a different subdir if it's specified by global
-            # configuration, environment variable or command line argument. IOW,
-            # prevent a non-base env configured for a non-native subdir from leaking
-            # its subdir to a newer env.
-            context_sources = context.collect_all()
-            if context_sources.get("cmd_line", {}).get("subdir") == context.subdir:
-                pass  # this is ok
-            elif context_sources.get("envvars", {}).get("subdir") == context.subdir:
-                pass  # this is ok too
-            # config does not come from envvars or cmd_line, it must be a file
-            # that's ok as long as it's a base env or a global file
-            elif not paths_equal(context.active_prefix, context.root_prefix):
-                # this is only ok as long as it's base environment
-                active_env_config = next(
-                    (
-                        config
-                        for path, config in context_sources.items()
-                        if paths_equal(context.active_prefix, path.parent)
-                    ),
-                    None,
-                )
-                if active_env_config.get("subdir") == context.subdir:
-                    # In practice this never happens; the subdir info is not even
-                    # loaded from the active env for conda create :shrug:
-                    msg = dals(
-                        f"""
-                        Active environment configuration ({context.active_prefix}) is
-                        implicitly requesting a non-native platform ({context.subdir}).
-                        Please deactivate first or explicitly request the platform via
-                        the --platform=[value] command line flag.
-                        """
-                    )
-                    raise OperationNotAllowed(msg)
-            log.info(
-                "Creating new environment for a non-native platform %s",
-                context.subdir,
-            )
+        # new environments should not have a prefix that exists
+        pass
     elif isdir(prefix):
-        delete_trash(prefix)
-        if not isfile(join(prefix, "conda-meta", "history")):
+        # if the prefix exists (and this is not a new environment)
+        # the conda-meta/history file must also exist - if it does not
+        # then this is not a valid conda environment
+        if isfile(join(prefix, "conda-meta", "history")):
+            # if the prefix exists, ensure that it is writable
+            validate_prefix_is_writable(prefix)
+        else:
             if paths_equal(prefix, context.conda_prefix):
                 raise NoBaseEnvironmentError()
             else:
                 if not path_is_clean(prefix):
                     raise DirectoryNotACondaEnvironmentError(prefix)
-        else:
-            validate_prefix_is_writable(prefix)
     else:
+        # if this is not a new env and the prefix does not exist, then
+        # there is no existing environment - this is an error
         raise EnvironmentLocationNotFound(prefix)
 
-    args_packages = [s.strip("\"'") for s in args.packages]
-    if newenv and not args.no_default_packages:
-        # Override defaults if they are specified at the command line
-        names = [MatchSpec(pkg).name for pkg in args_packages]
-        for default_package in context.create_default_packages:
-            if MatchSpec(default_package).name not in names:
-                args_packages.append(default_package)
 
-    context_channels = context.channels
-    index_args = {
+def get_index_args(args) -> dict[str, any]:
+    """Returns a dict of args required for fetching an index
+
+    :param args: The args provided by the cli
+    :returns: dict of index args
+    """
+    return {
         # TODO: deprecate --use-index-cache
         # "use_cache": args.use_index_cache,  # --use-index-cache
-        "channel_urls": context_channels,
+        "channel_urls": context.channels,
         # TODO: deprecate --unknown
         # "unknown": args.unknown,  # --unknown
         "prepend": not args.override_channels,  # --override-channels
         "use_local": args.use_local,  # --use-local
     }
 
+
+def ensure_update_specs_exist(prefix: str, specs: list[str]):
+    """Checks that each spec that is requested as an update exists in the prefix
+
+    :param prefix: The target install prefix
+    :param specs: List of specs to be updated
+    :raises CondaError: if there is an invalid spec provided
+    :raises PackageNotInstalledError: if the requested specs to install don't exist in the prefix
+    """
+    prefix_data = PrefixData(prefix)
+    for spec in specs:
+        spec = MatchSpec(spec)
+        if not spec.is_name_only_spec:
+            raise CondaError(
+                f"Invalid spec for 'conda update': {spec}\nUse 'conda install' instead."
+            )
+        if not prefix_data.get(spec.name, None):
+            raise PackageNotInstalledError(prefix, spec.name)
+
+
+def install(args, parser, command="install"):
+    """Logic for `conda install`, `conda update`, and `conda create`."""
+    prefix = context.target_prefix
+    newenv = bool(command == "create")
+    isupdate = bool(command == "update")
+    isinstall = bool(command == "install")
+    isremove = bool(command == "remove")
+
+    # common validations for all types of installs
+    validate_install_command(prefix=prefix, newenv=newenv)
+
+    # this is sort of a hack.  current_repodata.json may not have any .tar.bz2 files,
+    #    because it deduplicates records that exist as both formats.  Forcing this to
+    #    repodata.json ensures that .tar.bz2 files are available
+    if context.use_only_tar_bz2:
+        args.repodata_fns = ("repodata.json",)
path: conda/cli/install.py
user: login: jaimergp
body: https://github.com/conda/conda/issues/14745 - can't assign you until you comment there, though.
created_at: 2025-04-08T10:33:58Z
author_association: MEMBER

PR comments:
user: login: codspeed-hq[bot]
created_at: 2025-03-13T19:48:08Z
body: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Performance Report](https://codspeed.io/conda/conda/branches/soapy1%3Arefactor-cli-install-validations)

### Merging #14670 will **not alter performance**

<sub>Comparing <code>soapy1:refactor-cli-install-validations</code> (bdcb540) with <code>main</code> (d0df0f8)</sub>



### Summary

`✅ 21` untouched benchmarks  




author_association: NONE
user: login: jezdez
created_at: 2025-03-14T17:34:57Z
body: Given the size of the refactor, let's punt on merging this after the upcoming March release, please.
author_association: MEMBER
user: login: soapy1
created_at: 2025-03-18T02:31:54Z
body: pre-commit.ci autofix

author_association: CONTRIBUTOR
user: login: soapy1
created_at: 2025-03-18T17:20:58Z
body: Looks like the failing tests are flaky ones.

Since this is a refactor PR I didn't add too many more tests. But very open to being more thorough about that if it seems like a good idea.
author_association: CONTRIBUTOR
user: login: jaimergp
created_at: 2025-03-18T17:36:32Z
body: Yea unfortunately libmamba exhibits some non-deterministic behavior on our CI tests that we haven't been able to identify or fix. A rerun usually fixes them though.
author_association: MEMBER
user: login: soapy1
created_at: 2025-04-01T22:29:38Z
body: pre-commit.ci autofix
author_association: CONTRIBUTOR
user: login: soapy1
created_at: 2025-04-03T17:19:29Z
body: Looks like these errors are not related to the changes from this PR
author_association: CONTRIBUTOR
user: login: soapy1
created_at: 2025-04-07T17:02:27Z
body: Hmmm, I think it will be better to split this PR out into a few smaller, more focused changes. Giving that a go
author_association: CONTRIBUTOR
user: login: jaimergp
created_at: 2025-04-08T16:54:42Z
body: Opened https://github.com/conda/conda/issues/14747 to track the first bit in https://github.com/conda/conda/pull/14670#pullrequestreview-2742443805.
author_association: MEMBER
user: login: jaimergp
created_at: 2025-04-18T13:05:17Z
body: This PR has been split in several ones down the line, so it's currently holding only the following changes:

- The `repodata_fn` iteration now uses a context manager driven loop.
- Some post-create tasks have moved to `conda.cli.main_create` (touch nonadmin file, print activate info).
- Some well defined checks have been refactored into its own little function for better readability.

Note that this PR interfaces a lot with https://github.com/conda/conda/pull/14750. I think we should merge this first, and then the other one. A lot of my concerns about prefix validation duplication have been addressed in that other PR, and the diff here is quite useful already.
author_association: MEMBER