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:
parent
e685d04f84
commit
7e468aefd5
7 changed files with 293 additions and 1 deletions
|
@ -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:
|
||||||
|
|
||||||
------------------------
|
------------------------
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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 = []
|
||||||
|
|
112
lib/spack/spack/test/entry_points.py
Normal file
112
lib/spack/spack/test/entry_points.py
Normal 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
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in a new issue