From 7af5a72e0ddbbf6f409e08a5e3564bf3de55beba 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 | 50 +++++++++++++++++++++++++++++++----- lib/spack/spack/test/repo.py | 45 ++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/lib/spack/spack/__init__.py b/lib/spack/spack/__init__.py index c9a8640b868..7f272865157 100644 --- a/lib/spack/spack/__init__.py +++ b/lib/spack/spack/__init__.py @@ -14,6 +14,18 @@ __version__ = "0.23.2.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: @@ -80,4 +92,6 @@ def get_short_version() -> str: "get_version", "get_spack_commit", "get_short_version", + "package_api_version", + "min_package_api_version", ] diff --git a/lib/spack/spack/repo.py b/lib/spack/spack/repo.py index f3872aed8d1..bc0e84ad2b3 100644 --- a/lib/spack/spack/repo.py +++ b/lib/spack/spack/repo.py @@ -34,6 +34,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 @@ -50,6 +51,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 @@ -946,19 +949,52 @@ def __reduce__(self): return RepoPath.unmarshal, self.marshal() +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` must define: + It contains the following keys: `namespace`: A Python namespace where the repository's packages should live. `subdirectory`: An optional subdirectory name where packages are placed + + `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__( @@ -995,7 +1031,7 @@ def check(condition, msg): f"{os.path.join(root, repo_config_name)} must define a namespace.", ) - self.namespace = config["namespace"] + self.namespace: str = config["namespace"] check( re.match(r"[a-zA-Z][a-zA-Z0-9_.]+", self.namespace), f"Invalid namespace '{self.namespace}' in repo '{self.root}'. " @@ -1008,12 +1044,14 @@ 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), f"No directory '{packages_dir}' found in '{root}'" ) + self.package_api = _parse_package_api_version(config) + # Class attribute overrides by package name self.overrides = overrides or {} @@ -1063,7 +1101,7 @@ def is_prefix(self, fullname: str) -> bool: parts = fullname.split(".") return self._names[: len(parts)] == parts - def _read_config(self) -> Dict[str, str]: + 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 a013078a82b..13bd3a7e199 100644 --- a/lib/spack/spack/test/repo.py +++ b/lib/spack/spack/test/repo.py @@ -302,3 +302,48 @@ def test_get_repo(self, mock_test_cache): # foo is not there, raise with pytest.raises(spack.repo.UnknownNamespaceError): repo.get_repo("foo") + + +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: pathlib.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 +""" + ) + cache = spack.util.file_cache.FileCache(str(tmp_path / "cache")) + assert spack.repo.Repo(str(tmp_path / "example"), cache=cache).package_api == (1, 0)