From e4927b35d1d820545cb09c291bd3757131581a49 Mon Sep 17 00:00:00 2001
From: Harmen Stoppels <me@harmenstoppels.nl>
Date: Thu, 19 Sep 2024 23:25:36 +0200
Subject: package_base: break dependency on installer (#46423)

Removes `spack.package_base.PackageBase.do_{install,deprecate}` in favor of
`spack.installer.PackageInstaller.install` and `spack.installer.deprecate` resp.

That drops a dependency of `spack.package_base` on `spack.installer`, which is
necessary to get rid of circular dependencies in Spack.

Also change the signature of `PackageInstaller.__init__` from taking a dict as
positional argument, to an explicit list of keyword arguments.
---
 lib/spack/spack/bootstrap/core.py               |   5 +-
 lib/spack/spack/cmd/deprecate.py                |   3 +-
 lib/spack/spack/cmd/dev_build.py                |   7 +-
 lib/spack/spack/cmd/install.py                  |   2 +-
 lib/spack/spack/environment/environment.py      |   2 +-
 lib/spack/spack/installer.py                    | 118 ++++++++++++++++++++++--
 lib/spack/spack/package_base.py                 |  95 ++-----------------
 lib/spack/spack/test/build_distribution.py      |   3 +-
 lib/spack/spack/test/build_environment.py       |   3 +-
 lib/spack/spack/test/build_systems.py           |  11 ++-
 lib/spack/spack/test/cmd/buildcache.py          |  13 +--
 lib/spack/spack/test/cmd/extensions.py          |   6 +-
 lib/spack/spack/test/cmd/gc.py                  |  11 ++-
 lib/spack/spack/test/cmd/install.py             |   5 +-
 lib/spack/spack/test/cmd/module.py              |   5 +-
 lib/spack/spack/test/cmd/tags.py                |   3 +-
 lib/spack/spack/test/cmd/view.py                |   3 +-
 lib/spack/spack/test/concretize.py              |  27 +++---
 lib/spack/spack/test/concretize_requirements.py |   3 +-
 lib/spack/spack/test/conftest.py                |   3 +-
 lib/spack/spack/test/database.py                |  17 ++--
 lib/spack/spack/test/install.py                 |  65 +++++++------
 lib/spack/spack/test/installer.py               |   7 +-
 lib/spack/spack/test/modules/common.py          |   3 +-
 lib/spack/spack/test/package_class.py           |   2 +-
 lib/spack/spack/test/packaging.py               |   3 +-
 lib/spack/spack/test/rewiring.py                |  13 +--
 lib/spack/spack/test/spec_list.py               |   4 +-
 lib/spack/spack/test/views.py                   |   3 +-
 29 files changed, 249 insertions(+), 196 deletions(-)

(limited to 'lib')

diff --git a/lib/spack/spack/bootstrap/core.py b/lib/spack/spack/bootstrap/core.py
index 9713e2866a..6f1d9fdb9d 100644
--- a/lib/spack/spack/bootstrap/core.py
+++ b/lib/spack/spack/bootstrap/core.py
@@ -46,6 +46,7 @@ import spack.util.path
 import spack.util.spack_yaml
 import spack.util.url
 import spack.version
+from spack.installer import PackageInstaller
 
 from ._common import _executables_in_store, _python_import, _root_spec, _try_import_from_store
 from .clingo import ClingoBootstrapConcretizer
@@ -277,7 +278,7 @@ class SourceBootstrapper(Bootstrapper):
 
         # Install the spec that should make the module importable
         with spack.config.override(self.mirror_scope):
-            concrete_spec.package.do_install(fail_fast=True)
+            PackageInstaller([concrete_spec.package], fail_fast=True).install()
 
         if _try_import_from_store(module, query_spec=concrete_spec, query_info=info):
             self.last_search = info
@@ -300,7 +301,7 @@ class SourceBootstrapper(Bootstrapper):
         msg = "[BOOTSTRAP] Try installing '{0}' from sources"
         tty.debug(msg.format(abstract_spec_str))
         with spack.config.override(self.mirror_scope):
-            concrete_spec.package.do_install()
+            PackageInstaller([concrete_spec.package], fail_fast=True).install()
         if _executables_in_store(executables, concrete_spec, query_info=info):
             self.last_search = info
             return True
diff --git a/lib/spack/spack/cmd/deprecate.py b/lib/spack/spack/cmd/deprecate.py
index d7c6c49338..abca550cca 100644
--- a/lib/spack/spack/cmd/deprecate.py
+++ b/lib/spack/spack/cmd/deprecate.py
@@ -20,6 +20,7 @@ from llnl.util.symlink import symlink
 
 import spack.cmd
 import spack.environment as ev
+import spack.installer
 import spack.store
 from spack.cmd.common import arguments
 from spack.database import InstallStatuses
@@ -142,4 +143,4 @@ def deprecate(parser, args):
             tty.die("Will not deprecate any packages.")
 
     for dcate, dcator in zip(all_deprecate, all_deprecators):
-        dcate.package.do_deprecate(dcator, symlink)
+        spack.installer.deprecate(dcate, dcator, symlink)
diff --git a/lib/spack/spack/cmd/dev_build.py b/lib/spack/spack/cmd/dev_build.py
index b289d07dc9..696c16f4dc 100644
--- a/lib/spack/spack/cmd/dev_build.py
+++ b/lib/spack/spack/cmd/dev_build.py
@@ -14,6 +14,7 @@ import spack.cmd.common.arguments
 import spack.config
 import spack.repo
 from spack.cmd.common import arguments
+from spack.installer import PackageInstaller
 
 description = "developer build: build from code in current working directory"
 section = "build"
@@ -131,9 +132,9 @@ def dev_build(self, args):
     elif args.test == "root":
         tests = [spec.name for spec in specs]
 
-    spec.package.do_install(
+    PackageInstaller(
+        [spec.package],
         tests=tests,
-        make_jobs=args.jobs,
         keep_prefix=args.keep_prefix,
         install_deps=not args.ignore_deps,
         verbose=not args.quiet,
@@ -141,7 +142,7 @@ def dev_build(self, args):
         stop_before=args.before,
         skip_patch=args.skip_patch,
         stop_at=args.until,
-    )
+    ).install()
 
     # drop into the build environment of the package?
     if args.shell is not None:
diff --git a/lib/spack/spack/cmd/install.py b/lib/spack/spack/cmd/install.py
index 27b2ededcd..5040032f2b 100644
--- a/lib/spack/spack/cmd/install.py
+++ b/lib/spack/spack/cmd/install.py
@@ -474,5 +474,5 @@ def install_without_active_env(args, install_kwargs, reporter_factory):
 
         installs = [s.package for s in concrete_specs]
         install_kwargs["explicit"] = [s.dag_hash() for s in concrete_specs]
-        builder = PackageInstaller(installs, install_kwargs)
+        builder = PackageInstaller(installs, **install_kwargs)
         builder.install()
diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py
index 2a22a76abe..900fd3f072 100644
--- a/lib/spack/spack/environment/environment.py
+++ b/lib/spack/spack/environment/environment.py
@@ -1967,7 +1967,7 @@ class Environment:
         )
         install_args["explicit"] = explicit
 
-        PackageInstaller([spec.package for spec in specs], install_args).install()
+        PackageInstaller([spec.package for spec in specs], **install_args).install()
 
     def all_specs_generator(self) -> Iterable[Spec]:
         """Returns a generator for all concrete specs"""
diff --git a/lib/spack/spack/installer.py b/lib/spack/spack/installer.py
index fd46b9006d..e73fa1dc1f 100644
--- a/lib/spack/spack/installer.py
+++ b/lib/spack/spack/installer.py
@@ -37,7 +37,7 @@ import sys
 import time
 from collections import defaultdict
 from gzip import GzipFile
-from typing import Dict, Iterator, List, Optional, Set, Tuple
+from typing import Dict, Iterator, List, Optional, Set, Tuple, Union
 
 import llnl.util.filesystem as fs
 import llnl.util.lock as lk
@@ -1053,8 +1053,87 @@ class PackageInstaller:
     """
 
     def __init__(
-        self, packages: List["spack.package_base.PackageBase"], install_args: dict
+        self,
+        packages: List["spack.package_base.PackageBase"],
+        *,
+        cache_only: bool = False,
+        dependencies_cache_only: bool = False,
+        dependencies_use_cache: bool = True,
+        dirty: bool = False,
+        explicit: Union[Set[str], bool] = False,
+        overwrite: Optional[Union[List[str], Set[str]]] = None,
+        fail_fast: bool = False,
+        fake: bool = False,
+        include_build_deps: bool = False,
+        install_deps: bool = True,
+        install_package: bool = True,
+        install_source: bool = False,
+        keep_prefix: bool = False,
+        keep_stage: bool = False,
+        package_cache_only: bool = False,
+        package_use_cache: bool = True,
+        restage: bool = False,
+        skip_patch: bool = False,
+        stop_at: Optional[str] = None,
+        stop_before: Optional[str] = None,
+        tests: Union[bool, List[str], Set[str]] = False,
+        unsigned: Optional[bool] = None,
+        use_cache: bool = False,
+        verbose: bool = False,
     ) -> None:
+        """
+        Arguments:
+            explicit: Set of package hashes to be marked as installed explicitly in the db. If
+                True, the specs from ``packages`` are marked explicit, while their dependencies are
+                not.
+            fail_fast: Fail if any dependency fails to install; otherwise, the default is to
+                install as many dependencies as possible (i.e., best effort installation).
+            fake: Don't really build; install fake stub files instead.
+            install_deps: Install dependencies before installing this package
+            install_source: By default, source is not installed, but for debugging it might be
+                useful to keep it around.
+            keep_prefix: Keep install prefix on failure. By default, destroys it.
+            keep_stage: By default, stage is destroyed only if there are no exceptions during
+                build. Set to True to keep the stage even with exceptions.
+            restage: Force spack to restage the package source.
+            skip_patch: Skip patch stage of build if True.
+            stop_before: stop execution before this installation phase (or None)
+            stop_at: last installation phase to be executed (or None)
+            tests: False to run no tests, True to test all packages, or a list of package names to
+                run tests for some
+            use_cache: Install from binary package, if available.
+            verbose: Display verbose build output (by default, suppresses it)
+        """
+        if isinstance(explicit, bool):
+            explicit = {pkg.spec.dag_hash() for pkg in packages} if explicit else set()
+
+        install_args = {
+            "cache_only": cache_only,
+            "dependencies_cache_only": dependencies_cache_only,
+            "dependencies_use_cache": dependencies_use_cache,
+            "dirty": dirty,
+            "explicit": explicit,
+            "fail_fast": fail_fast,
+            "fake": fake,
+            "include_build_deps": include_build_deps,
+            "install_deps": install_deps,
+            "install_package": install_package,
+            "install_source": install_source,
+            "keep_prefix": keep_prefix,
+            "keep_stage": keep_stage,
+            "overwrite": overwrite or [],
+            "package_cache_only": package_cache_only,
+            "package_use_cache": package_use_cache,
+            "restage": restage,
+            "skip_patch": skip_patch,
+            "stop_at": stop_at,
+            "stop_before": stop_before,
+            "tests": tests,
+            "unsigned": unsigned,
+            "use_cache": use_cache,
+            "verbose": verbose,
+        }
+
         # List of build requests
         self.build_requests = [BuildRequest(pkg, install_args) for pkg in packages]
 
@@ -1518,8 +1597,8 @@ class PackageInstaller:
             spack.store.STORE.db.add(pkg.spec, explicit=explicit)
 
         except spack.error.StopPhase as e:
-            # A StopPhase exception means that do_install was asked to
-            # stop early from clients, and is not an error at this point
+            # A StopPhase exception means that the installer was asked to stop early from clients,
+            # and is not an error at this point
             pid = f"{self.pid}: " if tty.show_pid() else ""
             tty.debug(f"{pid}{str(e)}")
             tty.debug(f"Package stage directory: {pkg.stage.source_path}")
@@ -2070,7 +2149,7 @@ class BuildProcessInstaller:
 
         Arguments:
             pkg: the package being installed.
-            install_args: arguments to do_install() from parent process.
+            install_args: arguments to the installer from parent process.
 
         """
         self.pkg = pkg
@@ -2274,7 +2353,7 @@ def build_process(pkg: "spack.package_base.PackageBase", install_args: dict) ->
 
     Arguments:
         pkg: the package being installed.
-        install_args: arguments to do_install() from parent process.
+        install_args: arguments to installer from parent process.
 
     """
     installer = BuildProcessInstaller(pkg, install_args)
@@ -2284,6 +2363,33 @@ def build_process(pkg: "spack.package_base.PackageBase", install_args: dict) ->
         return installer.run()
 
 
+def deprecate(spec: "spack.spec.Spec", deprecator: "spack.spec.Spec", link_fn) -> None:
+    """Deprecate this package in favor of deprecator spec"""
+    # Install deprecator if it isn't installed already
+    if not spack.store.STORE.db.query(deprecator):
+        PackageInstaller([deprecator.package], explicit=True).install()
+
+    old_deprecator = spack.store.STORE.db.deprecator(spec)
+    if old_deprecator:
+        # Find this spec file from its old deprecation
+        specfile = spack.store.STORE.layout.deprecated_file_path(spec, old_deprecator)
+    else:
+        specfile = spack.store.STORE.layout.spec_file_path(spec)
+
+    # copy spec metadata to "deprecated" dir of deprecator
+    depr_specfile = spack.store.STORE.layout.deprecated_file_path(spec, deprecator)
+    fs.mkdirp(os.path.dirname(depr_specfile))
+    shutil.copy2(specfile, depr_specfile)
+
+    # Any specs deprecated in favor of this spec are re-deprecated in favor of its new deprecator
+    for deprecated in spack.store.STORE.db.specs_deprecated_by(spec):
+        deprecate(deprecated, deprecator, link_fn)
+
+    # Now that we've handled metadata, uninstall and replace with link
+    spack.package_base.PackageBase.uninstall_by_spec(spec, force=True, deprecator=deprecator)
+    link_fn(deprecator.prefix, spec.prefix)
+
+
 class OverwriteInstall:
     def __init__(
         self,
diff --git a/lib/spack/spack/package_base.py b/lib/spack/spack/package_base.py
index 5be23ff124..cc5f11cb72 100644
--- a/lib/spack/spack/package_base.py
+++ b/lib/spack/spack/package_base.py
@@ -19,7 +19,6 @@ import importlib
 import io
 import os
 import re
-import shutil
 import sys
 import textwrap
 import time
@@ -64,7 +63,6 @@ from spack.install_test import (
     cache_extra_test_sources,
     install_test_root,
 )
-from spack.installer import PackageInstaller
 from spack.solver.version_order import concretization_version_order
 from spack.stage import DevelopStage, ResourceStage, Stage, StageComposite, compute_stage_name
 from spack.util.executable import ProcessError, which
@@ -556,19 +554,16 @@ class PackageBase(WindowsRPath, PackageViewMixin, RedistributionMixin, metaclass
 
     There are two main parts of a Spack package:
 
-      1. **The package class**.  Classes contain ``directives``, which are
-         special functions, that add metadata (versions, patches,
-         dependencies, and other information) to packages (see
-         ``directives.py``). Directives provide the constraints that are
-         used as input to the concretizer.
+      1. **The package class**.  Classes contain ``directives``, which are special functions, that
+         add metadata (versions, patches, dependencies, and other information) to packages (see
+         ``directives.py``). Directives provide the constraints that are used as input to the
+         concretizer.
 
-      2. **Package instances**. Once instantiated, a package is
-         essentially a software installer.  Spack calls methods like
-         ``do_install()`` on the ``Package`` object, and it uses those to
-         drive user-implemented methods like ``patch()``, ``install()``, and
-         other build steps.  To install software, an instantiated package
-         needs a *concrete* spec, which guides the behavior of the various
-         install methods.
+      2. **Package instances**. Once instantiated, a package can be passed to the PackageInstaller.
+         It calls methods like ``do_stage()`` on the ``Package`` object, and it uses those to drive
+         user-implemented methods like ``patch()``, ``install()``, and other build steps. To
+         install software, an instantiated package needs a *concrete* spec, which guides the
+         behavior of the various install methods.
 
     Packages are imported from repos (see ``repo.py``).
 
@@ -590,7 +585,6 @@ class PackageBase(WindowsRPath, PackageViewMixin, RedistributionMixin, metaclass
        p.do_fetch()              # downloads tarball from a URL (or VCS)
        p.do_stage()              # expands tarball in a temp directory
        p.do_patch()              # applies patches to expanded source
-       p.do_install()            # calls package's install() function
        p.do_uninstall()          # removes install directory
 
     although packages that do not have code have nothing to fetch so omit
@@ -1956,48 +1950,6 @@ class PackageBase(WindowsRPath, PackageViewMixin, RedistributionMixin, metaclass
         resource_stage_folder = "-".join(pieces)
         return resource_stage_folder
 
-    def do_install(self, **kwargs):
-        """Called by commands to install a package and or its dependencies.
-
-        Package implementations should override install() to describe
-        their build process.
-
-        Args:
-            cache_only (bool): Fail if binary package unavailable.
-            dirty (bool): Don't clean the build environment before installing.
-            explicit (bool): True if package was explicitly installed, False
-                if package was implicitly installed (as a dependency).
-            fail_fast (bool): Fail if any dependency fails to install;
-                otherwise, the default is to install as many dependencies as
-                possible (i.e., best effort installation).
-            fake (bool): Don't really build; install fake stub files instead.
-            force (bool): Install again, even if already installed.
-            install_deps (bool): Install dependencies before installing this
-                package
-            install_source (bool): By default, source is not installed, but
-                for debugging it might be useful to keep it around.
-            keep_prefix (bool): Keep install prefix on failure. By default,
-                destroys it.
-            keep_stage (bool): By default, stage is destroyed only if there
-                are no exceptions during build. Set to True to keep the stage
-                even with exceptions.
-            restage (bool): Force spack to restage the package source.
-            skip_patch (bool): Skip patch stage of build if True.
-            stop_before (str): stop execution before this
-                installation phase (or None)
-            stop_at (str): last installation phase to be executed
-                (or None)
-            tests (bool or list or set): False to run no tests, True to test
-                all packages, or a list of package names to run tests for some
-            use_cache (bool): Install from binary package, if available.
-            verbose (bool): Display verbose build output (by default,
-                suppresses it)
-        """
-        explicit = kwargs.get("explicit", True)
-        if isinstance(explicit, bool):
-            kwargs["explicit"] = {self.spec.dag_hash()} if explicit else set()
-        PackageInstaller([self], kwargs).install()
-
     # TODO (post-34236): Update tests and all packages that use this as a
     # TODO (post-34236): package method to the routine made available to
     # TODO (post-34236): packages. Once done, remove this method.
@@ -2454,35 +2406,6 @@ class PackageBase(WindowsRPath, PackageViewMixin, RedistributionMixin, metaclass
         # delegate to instance-less method.
         PackageBase.uninstall_by_spec(self.spec, force)
 
-    def do_deprecate(self, deprecator, link_fn):
-        """Deprecate this package in favor of deprecator spec"""
-        spec = self.spec
-
-        # Install deprecator if it isn't installed already
-        if not spack.store.STORE.db.query(deprecator):
-            deprecator.package.do_install()
-
-        old_deprecator = spack.store.STORE.db.deprecator(spec)
-        if old_deprecator:
-            # Find this specs yaml file from its old deprecation
-            self_yaml = spack.store.STORE.layout.deprecated_file_path(spec, old_deprecator)
-        else:
-            self_yaml = spack.store.STORE.layout.spec_file_path(spec)
-
-        # copy spec metadata to "deprecated" dir of deprecator
-        depr_yaml = spack.store.STORE.layout.deprecated_file_path(spec, deprecator)
-        fsys.mkdirp(os.path.dirname(depr_yaml))
-        shutil.copy2(self_yaml, depr_yaml)
-
-        # Any specs deprecated in favor of this spec are re-deprecated in
-        # favor of its new deprecator
-        for deprecated in spack.store.STORE.db.specs_deprecated_by(spec):
-            deprecated.package.do_deprecate(deprecator, link_fn)
-
-        # Now that we've handled metadata, uninstall and replace with link
-        PackageBase.uninstall_by_spec(spec, force=True, deprecator=deprecator)
-        link_fn(deprecator.prefix, spec.prefix)
-
     def view(self):
         """Create a view with the prefix of this package as the root.
         Extensions added to this view will modify the installation prefix of
diff --git a/lib/spack/spack/test/build_distribution.py b/lib/spack/spack/test/build_distribution.py
index dc1e763e2a..5edcbe5673 100644
--- a/lib/spack/spack/test/build_distribution.py
+++ b/lib/spack/spack/test/build_distribution.py
@@ -11,13 +11,14 @@ import pytest
 import spack.binary_distribution as bd
 import spack.mirror
 import spack.spec
+from spack.installer import PackageInstaller
 
 pytestmark = pytest.mark.not_on_windows("does not run on windows")
 
 
 def test_build_tarball_overwrite(install_mockery, mock_fetch, monkeypatch, tmp_path):
     spec = spack.spec.Spec("trivial-install-test-package").concretized()
-    spec.package.do_install(fake=True)
+    PackageInstaller([spec.package], fake=True).install()
 
     specs = [spec]
 
diff --git a/lib/spack/spack/test/build_environment.py b/lib/spack/spack/test/build_environment.py
index 8d7a09ab7e..c3ccaee029 100644
--- a/lib/spack/spack/test/build_environment.py
+++ b/lib/spack/spack/test/build_environment.py
@@ -21,6 +21,7 @@ import spack.spec
 import spack.util.spack_yaml as syaml
 from spack.build_environment import UseMode, _static_to_shared_library, dso_suffix
 from spack.context import Context
+from spack.installer import PackageInstaller
 from spack.paths import build_env_path
 from spack.util.environment import EnvironmentModifications
 from spack.util.executable import Executable
@@ -181,7 +182,7 @@ def test_setup_dependent_package_inherited_modules(
 ):
     # This will raise on regression
     s = spack.spec.Spec("cmake-client-inheritor").concretized()
-    s.package.do_install()
+    PackageInstaller([s.package]).install()
 
 
 @pytest.mark.parametrize(
diff --git a/lib/spack/spack/test/build_systems.py b/lib/spack/spack/test/build_systems.py
index a28742488a..212ec412d3 100644
--- a/lib/spack/spack/test/build_systems.py
+++ b/lib/spack/spack/test/build_systems.py
@@ -21,6 +21,7 @@ import spack.paths
 import spack.platforms
 import spack.platforms.test
 from spack.build_environment import ChildError, setup_package
+from spack.installer import PackageInstaller
 from spack.spec import Spec
 from spack.util.executable import which
 
@@ -144,7 +145,7 @@ class TestAutotoolsPackage:
     def test_libtool_archive_files_are_deleted_by_default(self, mutable_database):
         # Install a package that creates a mock libtool archive
         s = Spec("libtool-deletion").concretized()
-        s.package.do_install(explicit=True)
+        PackageInstaller([s.package], explicit=True).install()
 
         # Assert the libtool archive is not there and we have
         # a log of removed files
@@ -160,7 +161,7 @@ class TestAutotoolsPackage:
         # patch its package to preserve the installation
         s = Spec("libtool-deletion").concretized()
         monkeypatch.setattr(type(s.package.builder), "install_libtool_archives", True)
-        s.package.do_install(explicit=True)
+        PackageInstaller([s.package], explicit=True).install()
 
         # Assert libtool archives are installed
         assert os.path.exists(s.package.builder.libtool_archive_file)
@@ -171,7 +172,7 @@ class TestAutotoolsPackage:
         files from working alternatives from the gnuconfig package.
         """
         s = Spec("autotools-config-replacement +patch_config_files +gnuconfig").concretized()
-        s.package.do_install()
+        PackageInstaller([s.package]).install()
 
         with open(os.path.join(s.prefix.broken, "config.sub")) as f:
             assert "gnuconfig version of config.sub" in f.read()
@@ -190,7 +191,7 @@ class TestAutotoolsPackage:
         Tests whether disabling patch_config_files
         """
         s = Spec("autotools-config-replacement ~patch_config_files +gnuconfig").concretized()
-        s.package.do_install()
+        PackageInstaller([s.package]).install()
 
         with open(os.path.join(s.prefix.broken, "config.sub")) as f:
             assert "gnuconfig version of config.sub" not in f.read()
@@ -219,7 +220,7 @@ class TestAutotoolsPackage:
 
         msg = "Cannot patch config files: missing dependencies: gnuconfig"
         with pytest.raises(ChildError, match=msg):
-            s.package.do_install()
+            PackageInstaller([s.package]).install()
 
     @pytest.mark.disable_clean_stage_check
     def test_broken_external_gnuconfig(self, mutable_database, tmpdir):
diff --git a/lib/spack/spack/test/cmd/buildcache.py b/lib/spack/spack/test/cmd/buildcache.py
index 30c779e705..840dd61f1a 100644
--- a/lib/spack/spack/test/cmd/buildcache.py
+++ b/lib/spack/spack/test/cmd/buildcache.py
@@ -19,6 +19,7 @@ import spack.main
 import spack.mirror
 import spack.spec
 import spack.util.url
+from spack.installer import PackageInstaller
 from spack.spec import Spec
 
 buildcache = spack.main.SpackCommand("buildcache")
@@ -178,7 +179,7 @@ def test_buildcache_autopush(tmp_path, install_mockery, mock_fetch):
     s = Spec("libdwarf").concretized()
 
     # Install and generate build cache index
-    s.package.do_install()
+    PackageInstaller([s.package], explicit=True).install()
 
     metadata_file = spack.binary_distribution.tarball_name(s, ".spec.json")
 
@@ -379,7 +380,7 @@ def test_correct_specs_are_pushed(
     things_to_install, expected, tmpdir, monkeypatch, default_mock_concretization, temporary_store
 ):
     spec = default_mock_concretization("dttop")
-    spec.package.do_install(fake=True)
+    PackageInstaller([spec.package], explicit=True, fake=True).install()
     slash_hash = f"/{spec.dag_hash()}"
 
     class DontUpload(spack.binary_distribution.Uploader):
@@ -438,13 +439,13 @@ def test_push_and_install_with_mirror_marked_unsigned_does_not_require_extra_fla
     # Install
     if signed:
         # Need to pass "--no-check-signature" to avoid install errors
-        kwargs = {"cache_only": True, "unsigned": True}
+        kwargs = {"explicit": True, "cache_only": True, "unsigned": True}
     else:
         # No need to pass "--no-check-signature" if the mirror is unsigned
-        kwargs = {"cache_only": True}
+        kwargs = {"explicit": True, "cache_only": True}
 
     spec.package.do_uninstall(force=True)
-    spec.package.do_install(**kwargs)
+    PackageInstaller([spec.package], **kwargs).install()
 
 
 def test_skip_no_redistribute(mock_packages, config):
@@ -489,7 +490,7 @@ def test_push_without_build_deps(tmp_path, temporary_store, mock_packages, mutab
     mirror("add", "--unsigned", "my-mirror", str(tmp_path))
 
     s = spack.spec.Spec("dtrun3").concretized()
-    s.package.do_install(fake=True)
+    PackageInstaller([s.package], explicit=True, fake=True).install()
     s["dtbuild3"].package.do_uninstall()
 
     # fails when build deps are required
diff --git a/lib/spack/spack/test/cmd/extensions.py b/lib/spack/spack/test/cmd/extensions.py
index b97bfa8b06..082628cc34 100644
--- a/lib/spack/spack/test/cmd/extensions.py
+++ b/lib/spack/spack/test/cmd/extensions.py
@@ -6,6 +6,7 @@
 
 import pytest
 
+from spack.installer import PackageInstaller
 from spack.main import SpackCommand, SpackCommandError
 from spack.spec import Spec
 
@@ -15,10 +16,7 @@ extensions = SpackCommand("extensions")
 @pytest.fixture
 def python_database(mock_packages, mutable_database):
     specs = [Spec(s).concretized() for s in ["python", "py-extension1", "py-extension2"]]
-
-    for spec in specs:
-        spec.package.do_install(fake=True, explicit=True)
-
+    PackageInstaller([s.package for s in specs], explicit=True, fake=True).install()
     yield
 
 
diff --git a/lib/spack/spack/test/cmd/gc.py b/lib/spack/spack/test/cmd/gc.py
index 883d37ae33..2eab41a4fb 100644
--- a/lib/spack/spack/test/cmd/gc.py
+++ b/lib/spack/spack/test/cmd/gc.py
@@ -11,6 +11,7 @@ import spack.environment as ev
 import spack.main
 import spack.spec
 import spack.traverse
+from spack.installer import PackageInstaller
 
 gc = spack.main.SpackCommand("gc")
 add = spack.main.SpackCommand("add")
@@ -27,7 +28,7 @@ def test_gc_without_build_dependency(mutable_database):
 def test_gc_with_build_dependency(mutable_database):
     s = spack.spec.Spec("simple-inheritance")
     s.concretize()
-    s.package.do_install(fake=True, explicit=True)
+    PackageInstaller([s.package], explicit=True, fake=True).install()
 
     assert "There are no unused specs." in gc("-yb")
     assert "Successfully uninstalled cmake" in gc("-y")
@@ -38,7 +39,7 @@ def test_gc_with_build_dependency(mutable_database):
 def test_gc_with_environment(mutable_database, mutable_mock_env_path):
     s = spack.spec.Spec("simple-inheritance")
     s.concretize()
-    s.package.do_install(fake=True, explicit=True)
+    PackageInstaller([s.package], explicit=True, fake=True).install()
 
     e = ev.create("test_gc")
     with e:
@@ -54,7 +55,7 @@ def test_gc_with_environment(mutable_database, mutable_mock_env_path):
 def test_gc_with_build_dependency_in_environment(mutable_database, mutable_mock_env_path):
     s = spack.spec.Spec("simple-inheritance")
     s.concretize()
-    s.package.do_install(fake=True, explicit=True)
+    PackageInstaller([s.package], explicit=True, fake=True).install()
 
     e = ev.create("test_gc")
     with e:
@@ -106,7 +107,7 @@ def test_gc_except_any_environments(mutable_database, mutable_mock_env_path):
 def test_gc_except_specific_environments(mutable_database, mutable_mock_env_path):
     s = spack.spec.Spec("simple-inheritance")
     s.concretize()
-    s.package.do_install(fake=True, explicit=True)
+    PackageInstaller([s.package], explicit=True, fake=True).install()
 
     assert mutable_database.query_local("zmpi")
 
@@ -133,7 +134,7 @@ def test_gc_except_nonexisting_dir_env(mutable_database, mutable_mock_env_path,
 def test_gc_except_specific_dir_env(mutable_database, mutable_mock_env_path, tmpdir):
     s = spack.spec.Spec("simple-inheritance")
     s.concretize()
-    s.package.do_install(fake=True, explicit=True)
+    PackageInstaller([s.package], explicit=True, fake=True).install()
 
     assert mutable_database.query_local("zmpi")
 
diff --git a/lib/spack/spack/test/cmd/install.py b/lib/spack/spack/test/cmd/install.py
index da85366165..13721b2a0d 100644
--- a/lib/spack/spack/test/cmd/install.py
+++ b/lib/spack/spack/test/cmd/install.py
@@ -28,6 +28,7 @@ import spack.installer
 import spack.package_base
 import spack.store
 from spack.error import SpackError, SpecSyntaxError
+from spack.installer import PackageInstaller
 from spack.main import SpackCommand
 from spack.spec import Spec
 
@@ -136,7 +137,7 @@ def test_package_output(tmpdir, capsys, install_mockery, mock_fetch):
     # when nested AND in pytest
     spec = Spec("printing-package").concretized()
     pkg = spec.package
-    pkg.do_install(verbose=True)
+    PackageInstaller([pkg], explicit=True, verbose=True).install()
 
     with gzip.open(pkg.install_log_path, "rt") as f:
         out = f.read()
@@ -261,7 +262,7 @@ def test_install_commit(mock_git_version_info, install_mockery, mock_packages, m
 
     # Use the earliest commit in the respository
     spec = Spec(f"git-test-commit@{commits[-1]}").concretized()
-    spec.package.do_install()
+    PackageInstaller([spec.package], explicit=True).install()
 
     # Ensure first commit file contents were written
     installed = os.listdir(spec.prefix.bin)
diff --git a/lib/spack/spack/test/cmd/module.py b/lib/spack/spack/test/cmd/module.py
index e16c8edecb..759d1391c9 100644
--- a/lib/spack/spack/test/cmd/module.py
+++ b/lib/spack/spack/test/cmd/module.py
@@ -15,6 +15,7 @@ import spack.modules.lmod
 import spack.repo
 import spack.spec
 import spack.store
+from spack.installer import PackageInstaller
 
 module = spack.main.SpackCommand("module")
 
@@ -184,8 +185,8 @@ def test_setdefault_command(mutable_database, mutable_config):
     # Install two different versions of pkg-a
     other_spec, preferred = "pkg-a@1.0", "pkg-a@2.0"
 
-    spack.spec.Spec(other_spec).concretized().package.do_install(fake=True)
-    spack.spec.Spec(preferred).concretized().package.do_install(fake=True)
+    specs = [spack.spec.Spec(other_spec).concretized(), spack.spec.Spec(preferred).concretized()]
+    PackageInstaller([s.package for s in specs], explicit=True, fake=True).install()
 
     writers = {
         preferred: writer_cls(spack.spec.Spec(preferred).concretized(), "default"),
diff --git a/lib/spack/spack/test/cmd/tags.py b/lib/spack/spack/test/cmd/tags.py
index 7de107c923..0e8e7f0165 100644
--- a/lib/spack/spack/test/cmd/tags.py
+++ b/lib/spack/spack/test/cmd/tags.py
@@ -6,6 +6,7 @@
 import spack.main
 import spack.repo
 import spack.spec
+from spack.installer import PackageInstaller
 
 tags = spack.main.SpackCommand("tags")
 
@@ -48,7 +49,7 @@ def test_tags_no_tags(monkeypatch):
 
 def test_tags_installed(install_mockery, mock_fetch):
     s = spack.spec.Spec("mpich").concretized()
-    s.package.do_install()
+    PackageInstaller([s.package], explicit=True, fake=True).install()
 
     out = tags("-i")
     for tag in ["tag1", "tag2"]:
diff --git a/lib/spack/spack/test/cmd/view.py b/lib/spack/spack/test/cmd/view.py
index f385a69e85..6c349fe68c 100644
--- a/lib/spack/spack/test/cmd/view.py
+++ b/lib/spack/spack/test/cmd/view.py
@@ -8,6 +8,7 @@ import os.path
 import pytest
 
 import spack.util.spack_yaml as s_yaml
+from spack.installer import PackageInstaller
 from spack.main import SpackCommand
 from spack.spec import Spec
 
@@ -180,7 +181,7 @@ def test_view_files_not_ignored(
 ):
     spec = Spec("view-not-ignored").concretized()
     pkg = spec.package
-    pkg.do_install()
+    PackageInstaller([pkg], explicit=True).install()
     pkg.assert_installed(spec.prefix)
 
     install("view-file")  # Arbitrary package to add noise
diff --git a/lib/spack/spack/test/concretize.py b/lib/spack/spack/test/concretize.py
index ef004272a7..9f4d411aec 100644
--- a/lib/spack/spack/test/concretize.py
+++ b/lib/spack/spack/test/concretize.py
@@ -33,6 +33,7 @@ import spack.store
 import spack.util.file_cache
 import spack.variant as vt
 from spack.concretize import find_spec
+from spack.installer import PackageInstaller
 from spack.spec import CompilerSpec, Spec
 from spack.version import Version, VersionList, ver
 
@@ -1319,7 +1320,7 @@ class TestConcretize:
         # Install a spec
         root = Spec("root").concretized()
         dependency = root["changing"].copy()
-        root.package.do_install(fake=True, explicit=True)
+        PackageInstaller([root.package], fake=True, explicit=True).install()
 
         # Modify package.py
         repo_with_changing_recipe.change(context)
@@ -1345,7 +1346,7 @@ class TestConcretize:
 
         # Install a spec for which the `version_based` variant condition does not hold
         old = Spec("conditional-variant-pkg @1").concretized()
-        old.package.do_install(fake=True, explicit=True)
+        PackageInstaller([old.package], fake=True, explicit=True).install()
 
         # Then explicitly require a spec with `+version_based`, which shouldn't reuse previous spec
         new1 = Spec("conditional-variant-pkg +version_based").concretized()
@@ -1357,7 +1358,7 @@ class TestConcretize:
     def test_reuse_with_flags(self, mutable_database, mutable_config):
         spack.config.set("concretizer:reuse", True)
         spec = Spec("pkg-a cflags=-g cxxflags=-g").concretized()
-        spec.package.do_install(fake=True)
+        PackageInstaller([spec.package], fake=True, explicit=True).install()
 
         testspec = Spec("pkg-a cflags=-g")
         testspec.concretize()
@@ -1658,7 +1659,7 @@ class TestConcretize:
         declared in package.py
         """
         root = Spec("root").concretized()
-        root.package.do_install(fake=True, explicit=True)
+        PackageInstaller([root.package], fake=True, explicit=True).install()
         repo_with_changing_recipe.change({"delete_version": True})
 
         with spack.config.override("concretizer:reuse", True):
@@ -1676,7 +1677,7 @@ class TestConcretize:
         # Install a dependency that cannot be reused with "root"
         # because of a conflict in a variant, then delete its version
         dependency = Spec("changing@1.0~foo").concretized()
-        dependency.package.do_install(fake=True, explicit=True)
+        PackageInstaller([dependency.package], fake=True, explicit=True).install()
         repo_with_changing_recipe.change({"delete_version": True})
 
         with spack.config.override("concretizer:reuse", True):
@@ -1691,7 +1692,7 @@ class TestConcretize:
         with spack.repo.use_repositories(mock_custom_repository, override=False):
             s = Spec("pkg-c").concretized()
             assert s.namespace != "builtin.mock"
-            s.package.do_install(fake=True, explicit=True)
+            PackageInstaller([s.package], fake=True, explicit=True).install()
 
         with spack.config.override("concretizer:reuse", True):
             s = Spec("pkg-c").concretized()
@@ -1703,7 +1704,7 @@ class TestConcretize:
         myrepo.add_package("zlib")
 
         builtin = Spec("zlib").concretized()
-        builtin.package.do_install(fake=True, explicit=True)
+        PackageInstaller([builtin.package], fake=True, explicit=True).install()
 
         with spack.repo.use_repositories(myrepo.root, override=False):
             with spack.config.override("concretizer:reuse", True):
@@ -1718,7 +1719,7 @@ class TestConcretize:
         with spack.repo.use_repositories(builder.root, override=False):
             s = Spec("pkg-c").concretized()
             assert s.namespace == "myrepo"
-            s.package.do_install(fake=True, explicit=True)
+            PackageInstaller([s.package], fake=True, explicit=True).install()
 
         del sys.modules["spack.pkg.myrepo.pkg-c"]
         del sys.modules["spack.pkg.myrepo"]
@@ -1936,7 +1937,7 @@ class TestConcretize:
 
         # Install the external spec
         external1 = Spec("changing@1.0").concretized()
-        external1.package.do_install(fake=True, explicit=True)
+        PackageInstaller([external1.package], fake=True, explicit=True).install()
         assert external1.external
 
         # Modify the package.py file
@@ -2309,7 +2310,7 @@ class TestConcretize:
         """
         s = Spec("py-extension1").concretized()
         python_hash = s["python"].dag_hash()
-        s.package.do_install(fake=True, explicit=True)
+        PackageInstaller([s.package], fake=True, explicit=True).install()
 
         with spack.config.override("concretizer:reuse", True):
             with_reuse = Spec(f"py-extension2 ^/{python_hash}").concretized()
@@ -3023,7 +3024,7 @@ def test_spec_filters(specs, include, exclude, expected):
 @pytest.mark.regression("38484")
 def test_git_ref_version_can_be_reused(install_mockery, do_not_check_runtimes_on_reuse):
     first_spec = spack.spec.Spec("git-ref-package@git.2.1.5=2.1.5~opt").concretized()
-    first_spec.package.do_install(fake=True, explicit=True)
+    PackageInstaller([first_spec.package], fake=True, explicit=True).install()
 
     with spack.config.override("concretizer:reuse", True):
         # reproducer of the issue is that spack will solve when there is a change to the base spec
@@ -3047,10 +3048,10 @@ def test_reuse_prefers_standard_over_git_versions(
     so install git ref last and ensure it is not picked up by reuse
     """
     standard_spec = spack.spec.Spec(f"git-ref-package@{standard_version}").concretized()
-    standard_spec.package.do_install(fake=True, explicit=True)
+    PackageInstaller([standard_spec.package], fake=True, explicit=True).install()
 
     git_spec = spack.spec.Spec("git-ref-package@git.2.1.5=2.1.5").concretized()
-    git_spec.package.do_install(fake=True, explicit=True)
+    PackageInstaller([git_spec.package], fake=True, explicit=True).install()
 
     with spack.config.override("concretizer:reuse", True):
         test_spec = spack.spec.Spec("git-ref-package@2").concretized()
diff --git a/lib/spack/spack/test/concretize_requirements.py b/lib/spack/spack/test/concretize_requirements.py
index aef8d0ed5c..be66b2b0a8 100644
--- a/lib/spack/spack/test/concretize_requirements.py
+++ b/lib/spack/spack/test/concretize_requirements.py
@@ -14,6 +14,7 @@ import spack.repo
 import spack.solver.asp
 import spack.util.spack_yaml as syaml
 import spack.version
+from spack.installer import PackageInstaller
 from spack.solver.asp import InternalConcretizerError, UnsatisfiableSpecError
 from spack.spec import Spec
 from spack.util.url import path_to_file_url
@@ -436,7 +437,7 @@ packages:
     store_dir = tmp_path / "store"
     with spack.store.use_store(str(store_dir)):
         s1 = Spec("y@2.5 ~shared").concretized()
-        s1.package.do_install(fake=True, explicit=True)
+        PackageInstaller([s1.package], fake=True, explicit=True).install()
 
         update_packages_config(conf_str)
 
diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py
index 2726061cb3..dc53f50688 100644
--- a/lib/spack/spack/test/conftest.py
+++ b/lib/spack/spack/test/conftest.py
@@ -61,6 +61,7 @@ import spack.util.url as url_util
 import spack.util.web
 import spack.version
 from spack.fetch_strategy import URLFetchStrategy
+from spack.installer import PackageInstaller
 from spack.util.pattern import Bunch
 
 
@@ -852,7 +853,7 @@ def _populate(mock_db):
 
     def _install(spec):
         s = spack.spec.Spec(spec).concretized()
-        s.package.do_install(fake=True, explicit=True)
+        PackageInstaller([s.package], fake=True, explicit=True).install()
 
     _install("mpileaks ^mpich")
     _install("mpileaks ^mpich2")
diff --git a/lib/spack/spack/test/database.py b/lib/spack/spack/test/database.py
index e34c85a788..6fd7575ffc 100644
--- a/lib/spack/spack/test/database.py
+++ b/lib/spack/spack/test/database.py
@@ -34,6 +34,7 @@ import spack.repo
 import spack.spec
 import spack.store
 import spack.version as vn
+from spack.installer import PackageInstaller
 from spack.schema.database_index import schema
 from spack.util.executable import Executable
 
@@ -385,7 +386,7 @@ def _check_remove_and_add_package(database: spack.database.Database, spec):
 
 def _mock_install(spec: str):
     s = spack.spec.Spec(spec).concretized()
-    s.package.do_install(fake=True)
+    PackageInstaller([s.package], fake=True, explicit=True).install()
 
 
 def _mock_remove(spec):
@@ -713,7 +714,7 @@ def test_external_entries_in_db(mutable_database):
     assert not rec.spec.external_modules
     assert rec.explicit is False
 
-    rec.spec.package.do_install(fake=True, explicit=True)
+    PackageInstaller([rec.spec.package], fake=True, explicit=True).install()
     rec = mutable_database.get_record("externaltool")
     assert rec.spec.external_path == os.path.sep + os.path.join("path", "to", "external_tool")
     assert not rec.spec.external_modules
@@ -731,7 +732,7 @@ def test_regression_issue_8036(mutable_database, usr_folder_exists):
     assert not s.installed
 
     # Now install the external package and check again the `installed` property
-    s.package.do_install(fake=True)
+    PackageInstaller([s.package], fake=True, explicit=True).install()
     assert s.installed
 
 
@@ -774,7 +775,7 @@ def test_query_unused_specs(mutable_database):
     # This spec installs a fake cmake as a build only dependency
     s = spack.spec.Spec("simple-inheritance")
     s.concretize()
-    s.package.do_install(fake=True, explicit=True)
+    PackageInstaller([s.package], fake=True, explicit=True).install()
 
     si = s.dag_hash()
     ml_mpich = spack.store.STORE.db.query_one("mpileaks ^mpich").dag_hash()
@@ -817,7 +818,7 @@ def test_query_spec_with_conditional_dependency(mutable_database):
     # conditional on a Boolean variant
     s = spack.spec.Spec("hdf5~mpi")
     s.concretize()
-    s.package.do_install(fake=True, explicit=True)
+    PackageInstaller([s.package], fake=True, explicit=True).install()
 
     results = spack.store.STORE.db.query_local("hdf5 ^mpich")
     assert not results
@@ -1144,7 +1145,7 @@ def test_reindex_with_upstreams(tmp_path, monkeypatch, mock_packages, config):
         {"config": {"install_tree": {"root": str(tmp_path / "upstream")}}}
     )
     monkeypatch.setattr(spack.store, "STORE", upstream_store)
-    callpath.package.do_install(fake=True)
+    PackageInstaller([callpath.package], fake=True, explicit=True).install()
 
     local_store = spack.store.create(
         {
@@ -1153,7 +1154,7 @@ def test_reindex_with_upstreams(tmp_path, monkeypatch, mock_packages, config):
         }
     )
     monkeypatch.setattr(spack.store, "STORE", local_store)
-    mpileaks.package.do_install(fake=True)
+    PackageInstaller([mpileaks.package], fake=True, explicit=True).install()
 
     # Sanity check that callpath is from upstream.
     assert not local_store.db.query_local("callpath")
@@ -1163,7 +1164,7 @@ def test_reindex_with_upstreams(tmp_path, monkeypatch, mock_packages, config):
     # checks local installs before upstream databases, even when the local database is being
     # reindexed.
     monkeypatch.setattr(spack.store, "STORE", upstream_store)
-    mpileaks.package.do_install(fake=True)
+    PackageInstaller([mpileaks.package], fake=True, explicit=True).install()
 
     # Delete the local database
     shutil.rmtree(local_store.db.database_directory)
diff --git a/lib/spack/spack/test/install.py b/lib/spack/spack/test/install.py
index ba7084960a..efaa7cc171 100644
--- a/lib/spack/spack/test/install.py
+++ b/lib/spack/spack/test/install.py
@@ -24,6 +24,7 @@ import spack.store
 import spack.util.spack_json as sjson
 from spack import binary_distribution
 from spack.error import InstallError
+from spack.installer import PackageInstaller
 from spack.package_base import (
     PackageBase,
     PackageStillNeededError,
@@ -42,7 +43,7 @@ def find_nothing(*args):
 def test_install_and_uninstall(install_mockery, mock_fetch, monkeypatch):
     spec = Spec("trivial-install-test-package").concretized()
 
-    spec.package.do_install()
+    PackageInstaller([spec.package], explicit=True).install()
     assert spec.installed
 
     spec.package.do_uninstall()
@@ -54,7 +55,7 @@ def test_uninstall_non_existing_package(install_mockery, mock_fetch, monkeypatch
     """Ensure that we can uninstall a package that has been deleted from the repo"""
     spec = Spec("trivial-install-test-package").concretized()
 
-    spec.package.do_install()
+    PackageInstaller([spec.package], explicit=True).install()
     assert spec.installed
 
     # Mock deletion of the package
@@ -75,7 +76,7 @@ def test_pkg_attributes(install_mockery, mock_fetch, monkeypatch):
     assert spec.concrete
 
     pkg = spec.package
-    pkg.do_install()
+    PackageInstaller([pkg], explicit=True).install()
     foo = "attributes-foo"
     assert spec["bar"].prefix == spec[foo].prefix
     assert spec["baz"].prefix == spec[foo].prefix
@@ -132,7 +133,7 @@ def test_partial_install_delete_prefix_and_stage(install_mockery, mock_fetch, wo
 
     s.package.remove_prefix = mock_remove_prefix
     with pytest.raises(MockInstallError):
-        s.package.do_install()
+        PackageInstaller([s.package], explicit=True).install()
     assert os.path.isdir(s.package.prefix)
     rm_prefix_checker = RemovePrefixChecker(instance_rm_prefix)
     s.package.remove_prefix = rm_prefix_checker.remove_prefix
@@ -141,7 +142,7 @@ def test_partial_install_delete_prefix_and_stage(install_mockery, mock_fetch, wo
     spack.store.STORE.failure_tracker.clear(s, True)
 
     s.package.set_install_succeed()
-    s.package.do_install(restage=True)
+    PackageInstaller([s.package], explicit=True, restage=True).install()
     assert rm_prefix_checker.removed
     assert s.package.spec.installed
 
@@ -160,12 +161,12 @@ def test_failing_overwrite_install_should_keep_previous_installation(
     s.package.set_install_succeed()
 
     # Do a failing overwrite install
-    s.package.do_install()
+    PackageInstaller([s.package], explicit=True).install()
     s.package.set_install_fail()
     kwargs = {"overwrite": [s.dag_hash()]}
 
     with pytest.raises(Exception):
-        s.package.do_install(**kwargs)
+        PackageInstaller([s.package], explicit=True, **kwargs).install()
 
     assert s.package.spec.installed
     assert os.path.exists(s.prefix)
@@ -174,7 +175,7 @@ def test_failing_overwrite_install_should_keep_previous_installation(
 def test_dont_add_patches_to_installed_package(install_mockery, mock_fetch, monkeypatch):
     dependency = Spec("dependency-install")
     dependency.concretize()
-    dependency.package.do_install()
+    PackageInstaller([dependency.package], explicit=True).install()
 
     dependency_hash = dependency.dag_hash()
     dependent = Spec("dependent-install ^/" + dependency_hash)
@@ -192,7 +193,7 @@ def test_dont_add_patches_to_installed_package(install_mockery, mock_fetch, monk
 def test_installed_dependency_request_conflicts(install_mockery, mock_fetch, mutable_mock_repo):
     dependency = Spec("dependency-install")
     dependency.concretize()
-    dependency.package.do_install()
+    PackageInstaller([dependency.package], explicit=True).install()
 
     dependency_hash = dependency.dag_hash()
     dependent = Spec("conflicting-dependent ^/" + dependency_hash)
@@ -205,7 +206,7 @@ def test_install_dependency_symlinks_pkg(install_mockery, mock_fetch, mutable_mo
     spec = Spec("flatten-deps")
     spec.concretize()
     pkg = spec.package
-    pkg.do_install()
+    PackageInstaller([pkg], explicit=True).install()
 
     # Ensure dependency directory exists after the installation.
     dependency_dir = os.path.join(pkg.prefix, "dependency-install")
@@ -215,7 +216,7 @@ def test_install_dependency_symlinks_pkg(install_mockery, mock_fetch, mutable_mo
 def test_install_times(install_mockery, mock_fetch, mutable_mock_repo):
     """Test install times added."""
     spec = Spec("dev-build-test-install-phases").concretized()
-    spec.package.do_install()
+    PackageInstaller([spec.package], explicit=True).install()
 
     # Ensure dependency directory exists after the installation.
     install_times = os.path.join(spec.package.prefix, ".spack", spack_times_log)
@@ -238,7 +239,7 @@ def test_flatten_deps(install_mockery, mock_fetch, mutable_mock_repo):
     spec = Spec("dependent-install")
     spec.concretize()
     pkg = spec.package
-    pkg.do_install()
+    PackageInstaller([pkg], explicit=True).install()
 
     # Demonstrate that the directory does not appear under the spec
     # prior to the flatten operation.
@@ -291,7 +292,7 @@ def test_installed_upstream_external(install_upstream, mock_fetch):
         assert new_dependency.external
         assert new_dependency.prefix == os.path.sep + os.path.join("path", "to", "external_tool")
 
-        dependent.package.do_install()
+        PackageInstaller([dependent.package], explicit=True).install()
 
         assert not os.path.exists(new_dependency.prefix)
         assert os.path.exists(dependent.prefix)
@@ -310,7 +311,7 @@ def test_installed_upstream(install_upstream, mock_fetch):
         assert new_dependency.installed_upstream
         assert new_dependency.prefix == upstream_layout.path_for_spec(dependency)
 
-        dependent.package.do_install()
+        PackageInstaller([dependent.package], explicit=True).install()
 
         assert not os.path.exists(new_dependency.prefix)
         assert os.path.exists(dependent.prefix)
@@ -323,14 +324,14 @@ def test_partial_install_keep_prefix(install_mockery, mock_fetch, monkeypatch, w
     # If remove_prefix is called at any point in this test, that is an error
     monkeypatch.setattr(spack.package_base.PackageBase, "remove_prefix", mock_remove_prefix)
     with pytest.raises(spack.build_environment.ChildError):
-        s.package.do_install(keep_prefix=True)
+        PackageInstaller([s.package], explicit=True, keep_prefix=True).install()
     assert os.path.exists(s.package.prefix)
 
     # must clear failure markings for the package before re-installing it
     spack.store.STORE.failure_tracker.clear(s, True)
 
     s.package.set_install_succeed()
-    s.package.do_install(keep_prefix=True)
+    PackageInstaller([s.package], explicit=True, keep_prefix=True).install()
     assert s.package.spec.installed
 
 
@@ -339,12 +340,12 @@ def test_second_install_no_overwrite_first(install_mockery, mock_fetch, monkeypa
     monkeypatch.setattr(spack.package_base.PackageBase, "remove_prefix", mock_remove_prefix)
 
     s.package.set_install_succeed()
-    s.package.do_install()
+    PackageInstaller([s.package], explicit=True).install()
     assert s.package.spec.installed
 
     # If Package.install is called after this point, it will fail
     s.package.set_install_fail()
-    s.package.do_install()
+    PackageInstaller([s.package], explicit=True).install()
 
 
 def test_install_prefix_collision_fails(config, mock_fetch, mock_packages, tmpdir):
@@ -357,16 +358,16 @@ def test_install_prefix_collision_fails(config, mock_fetch, mock_packages, tmpdi
         with spack.config.override("config:checksum", False):
             pkg_a = Spec("libelf@0.8.13").concretized().package
             pkg_b = Spec("libelf@0.8.12").concretized().package
-            pkg_a.do_install()
+            PackageInstaller([pkg_a], explicit=True).install()
 
             with pytest.raises(InstallError, match="Install prefix collision"):
-                pkg_b.do_install()
+                PackageInstaller([pkg_b], explicit=True).install()
 
 
 def test_store(install_mockery, mock_fetch):
     spec = Spec("cmake-client").concretized()
     pkg = spec.package
-    pkg.do_install()
+    PackageInstaller([pkg], explicit=True).install()
 
 
 @pytest.mark.disable_clean_stage_check
@@ -375,7 +376,7 @@ def test_failing_build(install_mockery, mock_fetch, capfd):
     pkg = spec.package
 
     with pytest.raises(spack.build_environment.ChildError, match="Expected failure"):
-        pkg.do_install()
+        PackageInstaller([pkg], explicit=True).install()
 
 
 class MockInstallError(spack.error.SpackError):
@@ -404,7 +405,7 @@ def test_nosource_pkg_install(install_mockery, mock_fetch, mock_packages, capfd,
     pkg = spec.package
 
     # Make sure install works even though there is no associated code.
-    pkg.do_install()
+    PackageInstaller([pkg], explicit=True).install()
     out = capfd.readouterr()
     assert "Installing dependency-install" in out[0]
 
@@ -421,7 +422,7 @@ def test_nosource_bundle_pkg_install(
     pkg = spec.package
 
     # Make sure install works even though there is no associated code.
-    pkg.do_install()
+    PackageInstaller([pkg], explicit=True).install()
     out = capfd.readouterr()
     assert "Installing dependency-install" in out[0]
 
@@ -435,7 +436,7 @@ def test_nosource_pkg_install_post_install(install_mockery, mock_fetch, mock_pac
     pkg = spec.package
 
     # Make sure both the install and post-install package methods work.
-    pkg.do_install()
+    PackageInstaller([pkg], explicit=True).install()
 
     # Ensure the file created in the package's `install` method exists.
     install_txt = os.path.join(spec.prefix, "install.txt")
@@ -564,7 +565,7 @@ def test_unconcretized_install(install_mockery, mock_fetch, mock_packages):
     pkg_cls = spack.repo.PATH.get_pkg_class(spec.name)
 
     with pytest.raises(ValueError, match="must have a concrete spec"):
-        pkg_cls(spec).do_install()
+        PackageInstaller([pkg_cls(spec)], explicit=True).install()
 
     with pytest.raises(ValueError, match="only patch concrete packages"):
         pkg_cls(spec).do_patch()
@@ -588,7 +589,7 @@ def test_empty_install_sanity_check_prefix(
     """Test empty install triggers sanity_check_prefix."""
     spec = Spec("failing-empty-install").concretized()
     with pytest.raises(spack.build_environment.ChildError, match="Nothing was installed"):
-        spec.package.do_install()
+        PackageInstaller([spec.package], explicit=True).install()
 
 
 def test_install_from_binary_with_missing_patch_succeeds(
@@ -624,10 +625,16 @@ def test_install_from_binary_with_missing_patch_succeeds(
 
     # Source install: fails, we don't have the patch.
     with pytest.raises(spack.error.SpecError, match="Couldn't find patch for package"):
-        s.package.do_install()
+        PackageInstaller([s.package], explicit=True).install()
 
     # Binary install: succeeds, we don't need the patch.
     spack.mirror.add(mirror)
-    s.package.do_install(package_cache_only=True, dependencies_cache_only=True, unsigned=True)
+    PackageInstaller(
+        [s.package],
+        explicit=True,
+        package_cache_only=True,
+        dependencies_cache_only=True,
+        unsigned=True,
+    ).install()
 
     assert temporary_store.db.query_local_by_spec_hash(s.dag_hash())
diff --git a/lib/spack/spack/test/installer.py b/lib/spack/spack/test/installer.py
index 25dcafb64d..a95d151b50 100644
--- a/lib/spack/spack/test/installer.py
+++ b/lib/spack/spack/test/installer.py
@@ -28,6 +28,7 @@ import spack.repo
 import spack.spec
 import spack.store
 import spack.util.lock as lk
+from spack.installer import PackageInstaller
 
 
 def _mock_repo(root, namespace):
@@ -82,7 +83,7 @@ def create_installer(
     concretized."""
     _specs = [spack.spec.Spec(s).concretized() if isinstance(s, str) else s for s in specs]
     _install_args = {} if install_args is None else install_args
-    return inst.PackageInstaller([spec.package for spec in _specs], _install_args)
+    return inst.PackageInstaller([spec.package for spec in _specs], **_install_args)
 
 
 @pytest.mark.parametrize(
@@ -140,7 +141,9 @@ def test_install_from_cache_errors(install_mockery):
     with pytest.raises(
         spack.error.InstallError, match="No binary found when cache-only was specified"
     ):
-        spec.package.do_install(package_cache_only=True, dependencies_cache_only=True)
+        PackageInstaller(
+            [spec.package], package_cache_only=True, dependencies_cache_only=True
+        ).install()
     assert not spec.package.installed_from_binary_cache
 
     # Check when don't expect to install only from binary cache
diff --git a/lib/spack/spack/test/modules/common.py b/lib/spack/spack/test/modules/common.py
index 49f63f6c3b..f1430ecf6e 100644
--- a/lib/spack/spack/test/modules/common.py
+++ b/lib/spack/spack/test/modules/common.py
@@ -18,6 +18,7 @@ import spack.package_base
 import spack.package_prefs
 import spack.repo
 import spack.spec
+from spack.installer import PackageInstaller
 from spack.modules.common import UpstreamModuleIndex
 from spack.spec import Spec
 
@@ -180,7 +181,7 @@ module_index:
 def test_load_installed_package_not_in_repo(install_mockery, mock_fetch, monkeypatch):
     """Test that installed packages that have been removed are still loadable"""
     spec = Spec("trivial-install-test-package").concretized()
-    spec.package.do_install()
+    PackageInstaller([spec.package], explicit=True).install()
     spack.modules.module_types["tcl"](spec, "default", True).write()
 
     def find_nothing(*args):
diff --git a/lib/spack/spack/test/package_class.py b/lib/spack/spack/test/package_class.py
index 5a61d90d33..70955e9638 100644
--- a/lib/spack/spack/test/package_class.py
+++ b/lib/spack/spack/test/package_class.py
@@ -5,7 +5,7 @@
 
 """Test class methods on Package objects.
 
-This doesn't include methods on package *instances* (like do_install(),
+This doesn't include methods on package *instances* (like do_patch(),
 etc.).  Only methods like ``possible_dependencies()`` that deal with the
 static DSL metadata for packages.
 """
diff --git a/lib/spack/spack/test/packaging.py b/lib/spack/spack/test/packaging.py
index f616cb47a7..d6ac1b190d 100644
--- a/lib/spack/spack/test/packaging.py
+++ b/lib/spack/spack/test/packaging.py
@@ -30,6 +30,7 @@ import spack.stage
 import spack.util.gpg
 import spack.util.url as url_util
 from spack.fetch_strategy import URLFetchStrategy
+from spack.installer import PackageInstaller
 from spack.paths import mock_gpg_keys_path
 from spack.relocate import (
     macho_find_paths,
@@ -50,7 +51,7 @@ def test_buildcache(mock_archive, tmp_path, monkeypatch, mutable_config):
     # Install a test package
     spec = Spec("trivial-install-test-package").concretized()
     monkeypatch.setattr(spec.package, "fetcher", URLFetchStrategy(url=mock_archive.url))
-    spec.package.do_install()
+    PackageInstaller([spec.package], explicit=True).install()
     pkghash = "/" + str(spec.dag_hash(7))
 
     # Put some non-relocatable file in there
diff --git a/lib/spack/spack/test/rewiring.py b/lib/spack/spack/test/rewiring.py
index f082b1b153..523ae5325b 100644
--- a/lib/spack/spack/test/rewiring.py
+++ b/lib/spack/spack/test/rewiring.py
@@ -11,6 +11,7 @@ import pytest
 
 import spack.rewiring
 import spack.store
+from spack.installer import PackageInstaller
 from spack.spec import Spec
 from spack.test.relocate import text_in_bin
 
@@ -27,8 +28,7 @@ def test_rewire_db(mock_fetch, install_mockery, transitive):
     """Tests basic rewiring without binary executables."""
     spec = Spec("splice-t^splice-h~foo").concretized()
     dep = Spec("splice-h+foo").concretized()
-    spec.package.do_install()
-    dep.package.do_install()
+    PackageInstaller([spec.package, dep.package], explicit=True).install()
     spliced_spec = spec.splice(dep, transitive=transitive)
     assert spec.dag_hash() != spliced_spec.dag_hash()
 
@@ -57,8 +57,7 @@ def test_rewire_bin(mock_fetch, install_mockery, transitive):
     """Tests basic rewiring with binary executables."""
     spec = Spec("quux").concretized()
     dep = Spec("garply cflags=-g").concretized()
-    spec.package.do_install()
-    dep.package.do_install()
+    PackageInstaller([spec.package, dep.package], explicit=True).install()
     spliced_spec = spec.splice(dep, transitive=transitive)
     assert spec.dag_hash() != spliced_spec.dag_hash()
 
@@ -86,8 +85,7 @@ def test_rewire_writes_new_metadata(mock_fetch, install_mockery):
     Accuracy of metadata is left to other tests."""
     spec = Spec("quux").concretized()
     dep = Spec("garply cflags=-g").concretized()
-    spec.package.do_install()
-    dep.package.do_install()
+    PackageInstaller([spec.package, dep.package], explicit=True).install()
     spliced_spec = spec.splice(dep, transitive=True)
     spack.rewiring.rewire(spliced_spec)
 
@@ -129,8 +127,7 @@ def test_uninstall_rewired_spec(mock_fetch, install_mockery, transitive):
     """Test that rewired packages can be uninstalled as normal."""
     spec = Spec("quux").concretized()
     dep = Spec("garply cflags=-g").concretized()
-    spec.package.do_install()
-    dep.package.do_install()
+    PackageInstaller([spec.package, dep.package], explicit=True).install()
     spliced_spec = spec.splice(dep, transitive=transitive)
     spack.rewiring.rewire(spliced_spec)
     spliced_spec.package.do_uninstall()
diff --git a/lib/spack/spack/test/spec_list.py b/lib/spack/spack/test/spec_list.py
index 98f0f8b312..295665ecfb 100644
--- a/lib/spack/spack/test/spec_list.py
+++ b/lib/spack/spack/test/spec_list.py
@@ -6,6 +6,7 @@ import itertools
 
 import pytest
 
+from spack.installer import PackageInstaller
 from spack.spec import Spec
 from spack.spec_list import SpecList
 
@@ -200,8 +201,7 @@ class TestSpecList:
         # Put mpich in the database so it can be referred to by hash.
         mpich_1 = Spec("mpich+debug").concretized()
         mpich_2 = Spec("mpich~debug").concretized()
-        mpich_1.package.do_install(fake=True)
-        mpich_2.package.do_install(fake=True)
+        PackageInstaller([mpich_1.package, mpich_2.package], explicit=True, fake=True).install()
 
         # Create matrix and exclude +debug, which excludes the first mpich after its abstract hash
         # is resolved.
diff --git a/lib/spack/spack/test/views.py b/lib/spack/spack/test/views.py
index c8ff50eeb9..2a62d04312 100644
--- a/lib/spack/spack/test/views.py
+++ b/lib/spack/spack/test/views.py
@@ -9,6 +9,7 @@ import pytest
 
 from spack.directory_layout import DirectoryLayout
 from spack.filesystem_view import SimpleFilesystemView, YamlFilesystemView
+from spack.installer import PackageInstaller
 from spack.spec import Spec
 
 
@@ -17,7 +18,7 @@ def test_remove_extensions_ordered(install_mockery, mock_fetch, tmpdir):
     layout = DirectoryLayout(view_dir)
     view = YamlFilesystemView(view_dir, layout)
     e2 = Spec("extension2").concretized()
-    e2.package.do_install()
+    PackageInstaller([e2.package], explicit=True).install()
     view.add_specs(e2)
 
     e1 = e2["extension1"]
-- 
cgit v1.2.3-70-g09d2