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)