Define Package API version (#49274)

Defines `spack.package_api_version` and `spack.min_package_api_version`
as tuples (major, minor).

This defines resp. the current Package API version implemented by this version
of Spack and the minimal Package API version it is backwards compatible with.

Repositories can optionally define:
```yaml
repo:
    namespace: my_repo
    api: v1.2
```
which indicates they are compatible with versions of Spack that implement
Package API `>= 1.2` and `< 2.0`. When the `api` key is omitted, the default
`v1.0` is assumed.
This commit is contained in:
Harmen Stoppels 2025-03-05 15:42:48 +01:00
parent dd3ee5ffef
commit 9a6e51ccf0
3 changed files with 100 additions and 7 deletions

View File

@ -7,6 +7,18 @@
__version__ = "0.22.6.dev0" __version__ = "0.22.6.dev0"
spack_version = __version__ spack_version = __version__
#: The current Package API version implemented by this version of Spack. The Package API defines
#: the Python interface for packages as well as the layout of package repositories. The minor
#: version is incremented when the package API is extended in a backwards-compatible way. The major
#: version is incremented upon breaking changes. This version is changed independently from the
#: Spack version.
package_api_version = (1, 0)
#: The minimum Package API version that this version of Spack is compatible with. This should
#: always be a tuple of the form ``(major, 0)``, since compatibility with vX.Y implies
#: compatibility with vX.0.
min_package_api_version = (1, 0)
def __try_int(v): def __try_int(v):
try: try:
@ -19,4 +31,4 @@ def __try_int(v):
spack_version_info = tuple([__try_int(v) for v in __version__.split(".")]) spack_version_info = tuple([__try_int(v) for v in __version__.split(".")])
__all__ = ["spack_version_info", "spack_version"] __all__ = ["spack_version_info", "spack_version", "package_api_version", "min_package_api_version"]

View File

@ -33,6 +33,7 @@
import llnl.util.tty as tty import llnl.util.tty as tty
from llnl.util.filesystem import working_dir from llnl.util.filesystem import working_dir
import spack
import spack.caches import spack.caches
import spack.config import spack.config
import spack.error import spack.error
@ -49,6 +50,8 @@
#: Package modules are imported as spack.pkg.<repo-namespace>.<pkg-name> #: Package modules are imported as spack.pkg.<repo-namespace>.<pkg-name>
ROOT_PYTHON_NAMESPACE = "spack.pkg" ROOT_PYTHON_NAMESPACE = "spack.pkg"
_API_REGEX = re.compile(r"^v(\d+)\.(\d+)$")
def python_package_for_repo(namespace): def python_package_for_repo(namespace):
"""Returns the full namespace of a repository, given its relative one """Returns the full namespace of a repository, given its relative one
@ -909,17 +912,49 @@ def __contains__(self, pkg_name):
return self.exists(pkg_name) return self.exists(pkg_name)
def _parse_package_api_version(
config: Dict[str, Any],
min_api: Tuple[int, int] = spack.min_package_api_version,
max_api: Tuple[int, int] = spack.package_api_version,
) -> Tuple[int, int]:
api = config.get("api")
if api is None:
package_api = (1, 0)
else:
if not isinstance(api, str):
raise BadRepoError(f"Invalid Package API version '{api}'. Must be of the form vX.Y")
api_match = _API_REGEX.match(api)
if api_match is None:
raise BadRepoError(f"Invalid Package API version '{api}'. Must be of the form vX.Y")
package_api = (int(api_match.group(1)), int(api_match.group(2)))
if min_api <= package_api <= max_api:
return package_api
min_str = ".".join(str(i) for i in min_api)
max_str = ".".join(str(i) for i in max_api)
curr_str = ".".join(str(i) for i in package_api)
raise BadRepoError(
f"Package API v{curr_str} is not supported by this version of Spack ("
f"must be between v{min_str} and v{max_str})"
)
class Repo: class Repo:
"""Class representing a package repository in the filesystem. """Class representing a package repository in the filesystem.
Each package repository must have a top-level configuration file Each package repository must have a top-level configuration file called `repo.yaml`.
called `repo.yaml`.
Currently, `repo.yaml` this must define: It contains the following keys:
`namespace`: `namespace`:
A Python namespace where the repository's packages should live. A Python namespace where the repository's packages should live.
`api`:
A string of the form vX.Y that indicates the Package API version. The default is "v1.0".
For the repo to be compatible with the current version of Spack, the version must be
greater than or equal to :py:data:`spack.min_package_api_version` and less than or equal to
:py:data:`spack.package_api_version`.
""" """
def __init__(self, root, cache=None): def __init__(self, root, cache=None):
@ -948,7 +983,7 @@ def check(condition, msg):
"%s must define a namespace." % os.path.join(root, repo_config_name), "%s must define a namespace." % os.path.join(root, repo_config_name),
) )
self.namespace = config["namespace"] self.namespace: str = config["namespace"]
check( check(
re.match(r"[a-zA-Z][a-zA-Z0-9_.]+", self.namespace), re.match(r"[a-zA-Z][a-zA-Z0-9_.]+", self.namespace),
("Invalid namespace '%s' in repo '%s'. " % (self.namespace, self.root)) ("Invalid namespace '%s' in repo '%s'. " % (self.namespace, self.root))
@ -961,13 +996,15 @@ def check(condition, msg):
# Keep name components around for checking prefixes. # Keep name components around for checking prefixes.
self._names = self.full_namespace.split(".") self._names = self.full_namespace.split(".")
packages_dir = config.get("subdirectory", packages_dir_name) packages_dir: str = config.get("subdirectory", packages_dir_name)
self.packages_path = os.path.join(self.root, packages_dir) self.packages_path = os.path.join(self.root, packages_dir)
check( check(
os.path.isdir(self.packages_path), os.path.isdir(self.packages_path),
"No directory '%s' found in '%s'" % (packages_dir, root), "No directory '%s' found in '%s'" % (packages_dir, root),
) )
self.package_api = _parse_package_api_version(config)
# These are internal cache variables. # These are internal cache variables.
self._modules = {} self._modules = {}
self._classes = {} self._classes = {}
@ -1010,7 +1047,7 @@ def is_prefix(self, fullname):
parts = fullname.split(".") parts = fullname.split(".")
return self._names[: len(parts)] == parts return self._names[: len(parts)] == parts
def _read_config(self): def _read_config(self) -> Dict[str, Any]:
"""Check for a YAML config file in this db's root directory.""" """Check for a YAML config file in this db's root directory."""
try: try:
with open(self.config_file) as reponame_file: with open(self.config_file) as reponame_file:

View File

@ -195,3 +195,47 @@ def test_path_computation_with_names(method_name, mock_repo_path):
unqualified = method("mpileaks") unqualified = method("mpileaks")
qualified = method("builtin.mock.mpileaks") qualified = method("builtin.mock.mpileaks")
assert qualified == unqualified assert qualified == unqualified
def test_parse_package_api_version():
"""Test that we raise an error if a repository has a version that is not supported."""
# valid version
assert spack.repo._parse_package_api_version(
{"api": "v1.2"}, min_api=(1, 0), max_api=(2, 3)
) == (1, 2)
# too new and too old
with pytest.raises(
spack.repo.BadRepoError,
match=r"Package API v2.4 is not supported .* \(must be between v1.0 and v2.3\)",
):
spack.repo._parse_package_api_version({"api": "v2.4"}, min_api=(1, 0), max_api=(2, 3))
with pytest.raises(
spack.repo.BadRepoError,
match=r"Package API v0.9 is not supported .* \(must be between v1.0 and v2.3\)",
):
spack.repo._parse_package_api_version({"api": "v0.9"}, min_api=(1, 0), max_api=(2, 3))
# default to v1.0 if not specified
assert spack.repo._parse_package_api_version({}, min_api=(1, 0), max_api=(2, 3)) == (1, 0)
# if v1.0 support is dropped we should also raise
with pytest.raises(
spack.repo.BadRepoError,
match=r"Package API v1.0 is not supported .* \(must be between v2.0 and v2.3\)",
):
spack.repo._parse_package_api_version({}, min_api=(2, 0), max_api=(2, 3))
# finally test invalid input
with pytest.raises(spack.repo.BadRepoError, match="Invalid Package API version"):
spack.repo._parse_package_api_version({"api": "v2"}, min_api=(1, 0), max_api=(3, 3))
with pytest.raises(spack.repo.BadRepoError, match="Invalid Package API version"):
spack.repo._parse_package_api_version({"api": 2.0}, min_api=(1, 0), max_api=(3, 3))
def test_repo_package_api_version(tmp_path):
"""Test that we can specify the API version of a repository."""
(tmp_path / "example" / "packages").mkdir(parents=True)
(tmp_path / "example" / "repo.yaml").write_text(
"""\
repo:
namespace: example
"""
)
assert spack.repo.Repo(str(tmp_path / "example"), cache=None).package_api == (1, 0)