Allow loading extensions through python entry-points (#42370)

This PR adds the ability to load spack extensions through `importlib.metadata` entry 
points, in addition to the regular configuration variable.

It requires Python 3.8 or greater to be properly supported.
This commit is contained in:
Tim Fuller 2024-03-06 03:18:49 -07:00 committed by GitHub
parent e685d04f84
commit 7e468aefd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 293 additions and 1 deletions

View file

@ -73,9 +73,12 @@ are six configuration scopes. From lowest to highest:
Spack instance per project) or for site-wide settings on a multi-user Spack instance per project) or for site-wide settings on a multi-user
machine (e.g., for a common Spack instance). machine (e.g., for a common Spack instance).
#. **plugin**: Read from a Python project's entry points. Settings here affect
all instances of Spack running with the same Python installation. This scope takes higher precedence than site, system, and default scopes.
#. **user**: Stored in the home directory: ``~/.spack/``. These settings #. **user**: Stored in the home directory: ``~/.spack/``. These settings
affect all instances of Spack and take higher precedence than site, affect all instances of Spack and take higher precedence than site,
system, or defaults scopes. system, plugin, or defaults scopes.
#. **custom**: Stored in a custom directory specified by ``--config-scope``. #. **custom**: Stored in a custom directory specified by ``--config-scope``.
If multiple scopes are listed on the command line, they are ordered If multiple scopes are listed on the command line, they are ordered
@ -196,6 +199,45 @@ with MPICH. You can create different configuration scopes for use with
mpi: [mpich] mpi: [mpich]
.. _plugin-scopes:
^^^^^^^^^^^^^
Plugin scopes
^^^^^^^^^^^^^
.. note::
Python version >= 3.8 is required to enable plugin configuration.
Spack can be made aware of configuration scopes that are installed as part of a python package. To do so, register a function that returns the scope's path to the ``"spack.config"`` entry point. Consider the Python package ``my_package`` that includes Spack configurations:
.. code-block:: console
my-package/
├── src
│   ├── my_package
│   │   ├── __init__.py
│   │   └── spack/
│   │   │   └── config.yaml
└── pyproject.toml
adding the following to ``my_package``'s ``pyproject.toml`` will make ``my_package``'s ``spack/`` configurations visible to Spack when ``my_package`` is installed:
.. code-block:: toml
[project.entry_points."spack.config"]
my_package = "my_package:get_config_path"
The function ``my_package.get_extension_path`` in ``my_package/__init__.py`` might look like
.. code-block:: python
import importlib.resources
def get_config_path():
dirname = importlib.resources.files("my_package").joinpath("spack")
if dirname.exists():
return str(dirname)
.. _platform-scopes: .. _platform-scopes:
------------------------ ------------------------

View file

@ -111,3 +111,39 @@ The corresponding unit tests can be run giving the appropriate options to ``spac
(5 durations < 0.005s hidden. Use -vv to show these durations.) (5 durations < 0.005s hidden. Use -vv to show these durations.)
=========================================== 5 passed in 5.06s ============================================ =========================================== 5 passed in 5.06s ============================================
---------------------------------------
Registering Extensions via Entry Points
---------------------------------------
.. note::
Python version >= 3.8 is required to register extensions via entry points.
Spack can be made aware of extensions that are installed as part of a python package. To do so, register a function that returns the extension path, or paths, to the ``"spack.extensions"`` entry point. Consider the Python package ``my_package`` that includes a Spack extension:
.. code-block:: console
my-package/
├── src
│   ├── my_package
│   │   └── __init__.py
│   └── spack-scripting/ # the spack extensions
└── pyproject.toml
adding the following to ``my_package``'s ``pyproject.toml`` will make the ``spack-scripting`` extension visible to Spack when ``my_package`` is installed:
.. code-block:: toml
[project.entry_points."spack.extenions"]
my_package = "my_package:get_extension_path"
The function ``my_package.get_extension_path`` in ``my_package/__init__.py`` might look like
.. code-block:: python
import importlib.resources
def get_extension_path():
dirname = importlib.resources.files("my_package").joinpath("spack-scripting")
if dirname.exists():
return str(dirname)

View file

@ -12,6 +12,7 @@
import re import re
import sys import sys
import traceback import traceback
import warnings
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Callable, Iterable, List, Tuple from typing import Any, Callable, Iterable, List, Tuple
@ -843,6 +844,48 @@ def __repr__(self):
return repr(self.instance) return repr(self.instance)
def get_entry_points(*, group: str):
"""Wrapper for ``importlib.metadata.entry_points``
Adapted from https://github.com/HypothesisWorks/hypothesis/blob/0a90ed6edf56319149956c7321d4110078a5c228/hypothesis-python/src/hypothesis/entry_points.py
Args:
group (str): the group of entry points to select
Returns:
EntryPoints for ``group``
"""
try:
try:
from importlib import metadata as importlib_metadata # type: ignore # novermin
except ImportError:
import importlib_metadata # type: ignore # mypy thinks this is a redefinition
try:
entry_points = importlib_metadata.entry_points(group=group)
except TypeError:
# Prior to Python 3.10, entry_points accepted no parameters and always
# returned a dictionary of entry points, keyed by group. See
# https://docs.python.org/3/library/importlib.metadata.html#entry-points
entry_points = importlib_metadata.entry_points().get(group, [])
yield from entry_points
except ImportError:
# But if we're not on Python >= 3.8 and the importlib_metadata backport
# is not installed, we fall back to pkg_resources anyway.
try:
import pkg_resources # type: ignore
except ImportError:
warnings.warn(
"Under Python <= 3.7, Spack requires either the importlib_metadata "
"or setuptools package in order to load extensions via entrypoints.",
ImportWarning,
)
yield from ()
else:
yield from pkg_resources.iter_entry_points(group)
def load_module_from_file(module_name, module_path): def load_module_from_file(module_name, module_path):
"""Loads a python module from the path of the corresponding file. """Loads a python module from the path of the corresponding file.

View file

@ -764,6 +764,31 @@ def _add_platform_scope(
cfg.push_scope(scope_type(plat_name, plat_path)) cfg.push_scope(scope_type(plat_name, plat_path))
def config_paths_from_entry_points() -> List[Tuple[str, str]]:
"""Load configuration paths from entry points
A python package can register entry point metadata so that Spack can find
its configuration by adding the following to the project's pyproject.toml:
.. code-block:: toml
[project.entry-points."spack.config"]
baz = "baz:get_spack_config_path"
The function ``get_spack_config_path`` returns the path to the package's
spack configuration scope
"""
config_paths: List[Tuple[str, str]] = []
for entry_point in lang.get_entry_points(group="spack.config"):
hook = entry_point.load()
if callable(hook):
config_path = hook()
if config_path and os.path.exists(config_path):
config_paths.append(("plugin-%s" % entry_point.name, str(config_path)))
return config_paths
def _add_command_line_scopes( def _add_command_line_scopes(
cfg: Union[Configuration, lang.Singleton], command_line_scopes: List[str] cfg: Union[Configuration, lang.Singleton], command_line_scopes: List[str]
) -> None: ) -> None:
@ -816,6 +841,9 @@ def create() -> Configuration:
# No site-level configs should be checked into spack by default. # No site-level configs should be checked into spack by default.
configuration_paths.append(("site", os.path.join(spack.paths.etc_path))) configuration_paths.append(("site", os.path.join(spack.paths.etc_path)))
# Python package's can register configuration scopes via entry_points
configuration_paths.extend(config_paths_from_entry_points())
# User configuration can override both spack defaults and site config # User configuration can override both spack defaults and site config
# This is disabled if user asks for no local configuration. # This is disabled if user asks for no local configuration.
if not disable_local_config: if not disable_local_config:

View file

@ -12,6 +12,7 @@
import re import re
import sys import sys
import types import types
from pathlib import Path
from typing import List from typing import List
import llnl.util.lang import llnl.util.lang
@ -132,10 +133,38 @@ def load_extension(name: str) -> str:
def get_extension_paths(): def get_extension_paths():
"""Return the list of canonicalized extension paths from config:extensions.""" """Return the list of canonicalized extension paths from config:extensions."""
extension_paths = spack.config.get("config:extensions") or [] extension_paths = spack.config.get("config:extensions") or []
extension_paths.extend(extension_paths_from_entry_points())
paths = [spack.util.path.canonicalize_path(p) for p in extension_paths] paths = [spack.util.path.canonicalize_path(p) for p in extension_paths]
return paths return paths
def extension_paths_from_entry_points() -> List[str]:
"""Load extensions from a Python package's entry points.
A python package can register entry point metadata so that Spack can find
its extensions by adding the following to the project's pyproject.toml:
.. code-block:: toml
[project.entry-points."spack.extensions"]
baz = "baz:get_spack_extensions"
The function ``get_spack_extensions`` returns paths to the package's
spack extensions
"""
extension_paths: List[str] = []
for entry_point in llnl.util.lang.get_entry_points(group="spack.extensions"):
hook = entry_point.load()
if callable(hook):
paths = hook() or []
if isinstance(paths, (Path, str)):
extension_paths.append(str(paths))
else:
extension_paths.extend(paths)
return extension_paths
def get_command_paths(): def get_command_paths():
"""Return the list of paths where to search for command files.""" """Return the list of paths where to search for command files."""
command_paths = [] command_paths = []

View file

@ -0,0 +1,112 @@
# Copyright 2013-2024 Lawrence Livermore National Security, LLC and other
# Spack Project Developers. See the top-level COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import os
import sys
import pytest
import spack.config
import spack.extensions
class MockConfigEntryPoint:
def __init__(self, tmp_path):
self.dir = tmp_path
self.name = "mypackage_config"
def load(self):
etc_path = self.dir.joinpath("spack/etc")
etc_path.mkdir(exist_ok=True, parents=True)
f = self.dir / "spack/etc/config.yaml"
with open(f, "w") as fh:
fh.write("config:\n install_tree:\n root: /spam/opt\n")
def ep():
return self.dir / "spack/etc"
return ep
class MockExtensionsEntryPoint:
def __init__(self, tmp_path):
self.dir = tmp_path
self.name = "mypackage_extensions"
def load(self):
cmd_path = self.dir.joinpath("spack/spack-myext/myext/cmd")
cmd_path.mkdir(exist_ok=True, parents=True)
f = self.dir / "spack/spack-myext/myext/cmd/spam.py"
with open(f, "w") as fh:
fh.write("description = 'hello world extension command'\n")
fh.write("section = 'test command'\n")
fh.write("level = 'long'\n")
fh.write("def setup_parser(subparser):\n pass\n")
fh.write("def spam(parser, args):\n print('spam for all!')\n")
def ep():
return self.dir / "spack/spack-myext"
return ep
def entry_points_factory(tmp_path):
def entry_points(group=None):
if group == "spack.config":
return (MockConfigEntryPoint(tmp_path),)
elif group == "spack.extensions":
return (MockExtensionsEntryPoint(tmp_path),)
return ()
return entry_points
@pytest.fixture()
def mock_entry_points(tmp_path, monkeypatch):
entry_points = entry_points_factory(tmp_path)
try:
try:
import importlib.metadata as importlib_metadata # type: ignore # novermin
except ImportError:
import importlib_metadata
monkeypatch.setattr(importlib_metadata, "entry_points", entry_points)
except ImportError:
try:
import pkg_resources # type: ignore
except ImportError:
return
monkeypatch.setattr(pkg_resources, "iter_entry_points", entry_points)
@pytest.mark.skipif(sys.version_info[:2] < (3, 8), reason="Python>=3.8 required")
def test_spack_entry_point_config(tmp_path, mock_entry_points):
"""Test config scope entry point"""
config_paths = dict(spack.config.config_paths_from_entry_points())
config_path = config_paths.get("plugin-mypackage_config")
my_config_path = tmp_path / "spack/etc"
if config_path is None:
raise ValueError("Did not find entry point config in %s" % str(config_paths))
else:
assert os.path.samefile(config_path, my_config_path)
config = spack.config.create()
assert config.get("config:install_tree:root", scope="plugin-mypackage_config") == "/spam/opt"
@pytest.mark.skipif(sys.version_info[:2] < (3, 8), reason="Python>=3.8 required")
def test_spack_entry_point_extension(tmp_path, mock_entry_points):
"""Test config scope entry point"""
my_ext = tmp_path / "spack/spack-myext"
extensions = spack.extensions.get_extension_paths()
found = bool([ext for ext in extensions if os.path.samefile(ext, my_ext)])
if not found:
raise ValueError("Did not find extension in %s" % ", ".join(extensions))
extensions = spack.extensions.extension_paths_from_entry_points()
found = bool([ext for ext in extensions if os.path.samefile(ext, my_ext)])
if not found:
raise ValueError("Did not find extension in %s" % ", ".join(extensions))
root = spack.extensions.load_extension("myext")
assert os.path.samefile(root, my_ext)
module = spack.extensions.get_module("spam")
assert module is not None

View file

@ -154,11 +154,13 @@ ignore_missing_imports = true
'boto3', 'boto3',
'botocore', 'botocore',
'distro', 'distro',
'importlib.metadata',
'jinja2', 'jinja2',
'jsonschema', 'jsonschema',
'macholib', 'macholib',
'markupsafe', 'markupsafe',
'numpy', 'numpy',
'pkg_resources',
'pyristent', 'pyristent',
'pytest', 'pytest',
'ruamel.yaml', 'ruamel.yaml',