# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
from __future__ import annotations
import re
from extra_platforms import LINUX_LIKE, MACOS
from ..capabilities import search_capabilities
from ..manager import PackageManager
from ..version import parse_version
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator
from ..package import Package
[docs]
class Spack(PackageManager):
"""Spack, the package manager built for supercomputers and HPC clusters.
A package is a *spec*: a package name carrying a version, a compiler, a
target architecture and a set of build options, each combination installed
into a prefix of its own. mpm keys a package on its id alone, so the spec is
reduced to the name every verb accepts back.
```{note}
Holding several builds of one package at once is Spack's whole purpose, so
the inventory repeats a name as many times as the host has builds of it.
That reduction to one entry per id is what makes this a class rather than a
bundled definition, the same case `luarocks` and `vagrant` answer.
Reducing on the version alone is a deliberate loss: two builds of the *same*
version differing only in compiler or build options collapse into one entry,
which mpm has no way to tell apart and no verb to address separately.
```
```{caution}
`--no-env` is load-bearing, and silently so. Spack scopes every command to
the active environment, which the user activates with `spack env activate`
or by exporting `SPACK_ENV`, and an environment is a *project*, not the
machine. With one active, `spack find` reports that environment's contents:
a host holding four installed packages reports none at all, an empty answer
indistinguishable from an empty machine. Worse, an `SPACK_ENV` left pointing
at a deleted environment makes the command fail outright with `Error: no
environment in ...`.
The flag is a global one, placed before the subcommand, which is why it is
declared as {attr}`pre_args` rather than repeated per operation. Note that
`-E` is *not* a portable shorthand for it: `spack gc` binds its own `-E` to
`--except-any-environment`, so the long form is the only spelling that means
the same thing everywhere. The version probe skips {attr}`pre_args` entirely
and so runs bare.
`haxelib` forces `--global` and `luarocks` `--no-project` against the same
hazard.
```
```{note}
The inventory is a projection rather than the default display. `spack find`
normally groups its output under `-- darwin-tahoe-m1 / %[email protected]`
banners whose text depends on the host's architecture and compilers, while
`--format` prints the requested fields alone. `gcloud` reaches for the same
escape.
```
```{note}
Every spec Spack's database holds is reported, which is broader than what
Spack built. Packages pulled in as build dependencies appear, matching the
`raco` reading that an inventory hiding what was installed on the user's
behalf is the wrong answer, and so do *externals*: the system compiler
Spack registers on first use is listed as `apple-clang`, since Spack tracks
it as installed and its own garbage collector removes it. No flag separates
them out.
```
```{warning}
Spack has no `outdated` and no upgrade verb, and neither is faked. Installing
a package that is already present *adds* a second build rather than replacing
the first, which is the point of the tool, so wiring `upgrade` to `install`
would leave the old build in place while reporting success. Reclaiming the
superseded build is what `cleanup --orphans` does.
```
```{warning}
A read bootstraps the host on first use. Since Spack 1.0 the package recipes
live in [spack/spack-packages](https://github.com/spack/spack-packages)
rather than in Spack itself, and upstream states Spack "clones the package
repository automatically when you first run", so an inventory or a search on
a fresh install fetches some twenty thousand objects into `~/.spack` before
answering. `ollama` starting a daemon from a listing is the same shape,
smaller.
```
Documentation: [Spack documentation](https://spack.readthedocs.io).
"""
name = "Spack"
homepage_url = "https://spack.io"
platforms = LINUX_LIKE, MACOS
requirement = ">=1.0.0"
"""The release that moved the package recipes into a repository of their own,
giving them a lifecycle Spack can refresh and `spack repo update` something
to update.
"""
pre_args = ("--no-env",)
"""Ignore whichever environment the user has activated, so every operation
answers for the machine rather than for a project.
"""
_INSTALLED_REGEXP = re.compile(r"^(?P<package_id>[^@\s]+)@(?P<version>\S+)$")
"""One installed spec, as projected by `--format`: the package name, an `@`
and the version.
The projection is deliberately written without a space, which keeps the whole
format string a single shell word. mpm discloses the command it runs so the
user can replay it by hand, and it does not quote arguments, so `{name}
{version}` would disclose a line that no longer runs when pasted back into a
shell. `{name}@{version}` needs no quoting and is Spack's own spec syntax
besides. Anchoring at both ends rejects the progress and error lines Spack
writes around a listing.
"""
def _newest_per_name(
self,
rows: Iterator[tuple[str, str]],
) -> Iterator[Package]:
"""Reduce specs to one package per name, keeping the highest version.
Spack lists every build the host holds, so a package installed at two
versions, or at one version with two sets of build options, comes back
once per build.
"""
best: dict[str, str] = {}
for package_id, version in rows:
current = best.get(package_id)
if current is None or parse_version(version) > parse_version(current):
best[package_id] = version
for package_id, version in best.items():
yield self.package(id=package_id, installed_version=version)
@property
def installed(self) -> Iterator[Package]:
"""Fetch installed packages.
```{code-block} shell-session
$ spack --no-env find --format {name}@{version}
[email protected]
[email protected]
[email protected]
[email protected]
[email protected]
```
"""
output = self.run_cli("find", "--format", "{name}@{version}")
yield from self._newest_per_name(
(match.group("package_id"), match.group("version"))
for match in map(self._INSTALLED_REGEXP.match, output.splitlines())
if match
)
[docs]
@search_capabilities(exact_support=False)
def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]:
"""Fetch matching packages.
Spack matches a query as a substring of the package name, and
`--search-description` widens that to the recipes' descriptions, which is
the extended search. There is no way to narrow it to an exact name, so
mpm filters exact matches itself.
```{code-block} shell-session
$ spack --no-env list --format version_json zlib-ng
[
{"name": "zlib-ng",
"latest_version": "2.3.3",
"versions": ["2.3.3", "2.3.2", "2.2.5", "2.2.4", "2.2.3", "2.2.2", "2.2.1", "2.1.7", "2.1.6", "2.1.5", "2.1.4", "2.0.7", "2.0.0"],
"homepage": "https://github.com/zlib-ng/zlib-ng",
"file": "https://github.com/spack/spack-packages/blob/develop/repos/spack_repo/builtin/packages/zlib_ng/package.py",
"maintainers": ["haampie"],
"dependencies": {"build": ["gnuconfig", "cxx", "gmake", "ninja", "c", "cmake"], "link": [], "run": [], "test": []}}
]
```
"""
args = ["list", "--format", "version_json"]
if extended:
args.append("--search-description")
args.append(query)
output = self.run_cli(*args)
yield from self.parse_json_items(
output,
fields={"package_id": "name", "latest_version": "latest_version"},
)
[docs]
def install(self, package_id: str, version: str | None = None) -> str:
"""Install one package.
A version is pinned by appending it to the name after an `@`, which is
Spack's own spec syntax rather than a separate argument.
```{code-block} shell-session
$ spack --no-env install [email protected]
[ ] 3iyb7mz [email protected] staging (0s)
[ ] 3iyb7mz [email protected] edit (0s)
[ ] 3iyb7mz [email protected] build (1s)
[ ] 3iyb7mz [email protected] install (2s)
[+] 3iyb7mz [email protected] /opt/homebrew/Cellar/spack/1.2.2/opt/spack/darwin-m1/zlib-1.3.1-3iyb7mzv7ij3w2o43t4nb2pq25az6pt7 (2s)
```
"""
spec = package_id if version is None else f"{package_id}@{version}"
return self.run_cli("install", spec)
[docs]
def remove(self, package_id: str) -> str:
"""Remove one package.
`--all` is load-bearing rather than a convenience. Naming a package that
the host holds more than one build of is refused outright, with `Error:
zlib matches multiple packages`, so without the flag a removal fails on
exactly the hosts Spack exists to serve.
```{code-block} shell-session
$ spack --no-env uninstall --yes-to-all --all zlib
==> Successfully uninstalled [email protected]+optimize+pic+shared build_system=makefile platform=darwin os=tahoe target=m1/3iyb7mz
==> Successfully uninstalled [email protected]+optimize+pic+shared build_system=makefile platform=darwin os=tahoe target=m1/7ib3y53
```
"""
return self.run_cli("uninstall", "--yes-to-all", "--all", package_id)
[docs]
def sync(self) -> None:
"""Sync package metadata.
Pulls the package repository Spack clones on first use, which is where
every recipe now lives.
```{code-block} shell-session
$ spack --no-env repo update
==> builtin: Already up to date.
```
"""
self.run_cli("repo", "update")
[docs]
def cleanup_orphan(self) -> None:
"""Remove every spec nothing depends on any more.
Reclaims the build dependencies an install pulled in, and the builds a
later install superseded. Explicitly installed packages are kept.
```{code-block} shell-session
$ spack --no-env gc --yes-to-all
==> Successfully uninstalled [email protected]~guile build_system=generic platform=darwin os=tahoe target=m1/x7otsag
==> Successfully uninstalled [email protected] build_system=bundle platform=darwin os=tahoe target=aarch64/ti7wjie
==> Successfully uninstalled [email protected] build_system=generic platform=darwin os=tahoe target=m1/2eppidz
```
"""
self.run_cli("gc", "--yes-to-all")
[docs]
def cleanup_cache(self) -> None:
"""Scrub the build stages, downloads and caches.
Bootstrap software is deliberately left alone: `--bootstrap` would remove
the concretizer Spack needs before it can resolve anything, so the next
operation would have to fetch it again.
```{code-block} shell-session
$ spack --no-env clean --downloads --misc-cache --python-cache --stage
==> Removing all temporary build stages
==> Removing cached downloads
==> Removing cached information on repositories
==> Removing python cache files
```
"""
self.run_cli(
"clean", "--downloads", "--misc-cache", "--python-cache", "--stage"
)