From 9a6e51ccf024d8bb54426516e0f68a9c50d30b8c Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 5 Mar 2025 15:42:48 +0100 Subject: [PATCH] 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. --- lib/spack/spack/__init__.py | 14 ++++++++++- lib/spack/spack/repo.py | 49 +++++++++++++++++++++++++++++++----- lib/spack/spack/test/repo.py | 44 ++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/lib/spack/spack/__init__.py b/lib/spack/spack/__init__.py index 39fe13e6b8e..02eec2b7fc8 100644 --- a/lib/spack/spack/__init__.py +++ b/lib/spack/spack/__init__.py @@ -7,6 +7,18 @@ __version__ = "0.22.6.dev0" 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): try: @@ -19,4 +31,4 @@ def __try_int(v): 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"] diff --git a/lib/spack/spack/repo.py b/lib/spack/spack/repo.py index 743f5a18e8f..5126e30585d 100644 --- a/lib/spack/spack/repo.py +++ b/lib/spack/spack/repo.py @@ -33,6 +33,7 @@ import llnl.util.tty as tty from llnl.util.filesystem import working_dir +import spack import spack.caches import spack.config import spack.error @@ -49,6 +50,8 @@ #: Package modules are imported as spack.pkg.. ROOT_PYTHON_NAMESPACE = "spack.pkg" +_API_REGEX = re.compile(r"^v(\d+)\.(\d+)$") + def python_package_for_repo(namespace): """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) +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 representing a package repository in the filesystem. - Each package repository must have a top-level configuration file - called `repo.yaml`. + Each package repository must have a top-level configuration file called `repo.yaml`. - Currently, `repo.yaml` this must define: + It contains the following keys: `namespace`: 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): @@ -948,7 +983,7 @@ def check(condition, msg): "%s must define a namespace." % os.path.join(root, repo_config_name), ) - self.namespace = config["namespace"] + self.namespace: str = config["namespace"] check( re.match(r"[a-zA-Z][a-zA-Z0-9_.]+", self.namespace), ("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. 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) check( os.path.isdir(self.packages_path), "No directory '%s' found in '%s'" % (packages_dir, root), ) + self.package_api = _parse_package_api_version(config) + # These are internal cache variables. self._modules = {} self._classes = {} @@ -1010,7 +1047,7 @@ def is_prefix(self, fullname): parts = fullname.split(".") 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.""" try: with open(self.config_file) as reponame_file: diff --git a/lib/spack/spack/test/repo.py b/lib/spack/spack/test/repo.py index a958f48921a..7eb1683329e 100644 --- a/lib/spack/spack/test/repo.py +++ b/lib/spack/spack/test/repo.py @@ -195,3 +195,47 @@ def test_path_computation_with_names(method_name, mock_repo_path): unqualified = method("mpileaks") qualified = method("builtin.mock.mpileaks") 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)