Command extensions can import code from modules in root or cmd folder (#11209)
#8612 added command extensions to Spack: a command implemented in a separate directory. This improves the implementation by allowing the command to import additional utility code stored within the established directory structure for commands. This also: * Adds tests for command extensions * Documents command extensions (including the expected directory layout)
This commit is contained in:

committed by
Peter Scheibel

parent
b9370bf20b
commit
c03be0d65a
128
lib/spack/docs/extensions.rst
Normal file
128
lib/spack/docs/extensions.rst
Normal file
@@ -0,0 +1,128 @@
|
||||
.. Copyright 2013-2019 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)
|
||||
|
||||
.. extensions:
|
||||
|
||||
=================
|
||||
Custom Extensions
|
||||
=================
|
||||
|
||||
.. warning::
|
||||
|
||||
The support for extending Spack with custom commands is still experimental.
|
||||
Users should expect APIs or prescribed directory structures to
|
||||
change at any time.
|
||||
|
||||
*Spack extensions* permit you to extend Spack capabilities by deploying your
|
||||
own custom commands or logic in an arbitrary location on your filesystem.
|
||||
This might be extremely useful e.g. to develop and maintain a command whose purpose is
|
||||
too specific to be considered for reintegration into the mainline or to
|
||||
evolve a command through its early stages before starting a discussion to merge
|
||||
it upstream.
|
||||
From Spack's point of view an extension is any path in your filesystem which
|
||||
respects a prescribed naming and layout for files:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
spack-scripting/ # The top level directory must match the format 'spack-{extension_name}'
|
||||
├── pytest.ini # Optional file if the extension ships its own tests
|
||||
├── scripting # Folder that may contain modules that are needed for the extension commands
|
||||
│ └── cmd # Folder containing extension commands
|
||||
│ └── filter.py # A new command that will be available
|
||||
├── tests # Tests for this extension
|
||||
│ ├── conftest.py
|
||||
│ └── test_filter.py
|
||||
└── templates # Templates that may be needed by the extension
|
||||
|
||||
In the example above the extension named *scripting* adds an additional command (``filter``)
|
||||
and unit tests to verify its behavior. The code for this example can be
|
||||
obtained by cloning the corresponding git repository:
|
||||
|
||||
.. TODO: write an ad-hoc "hello world" extension and make it part of the spack organization
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ pwd
|
||||
/home/user
|
||||
$ mkdir tmp && cd tmp
|
||||
$ git clone https://github.com/alalazo/spack-scripting.git
|
||||
Cloning into 'spack-scripting'...
|
||||
remote: Counting objects: 11, done.
|
||||
remote: Compressing objects: 100% (7/7), done.
|
||||
remote: Total 11 (delta 0), reused 11 (delta 0), pack-reused 0
|
||||
Receiving objects: 100% (11/11), done.
|
||||
|
||||
As you can see by inspecting the sources, Python modules that are part of the extension
|
||||
can import any core Spack module.
|
||||
|
||||
---------------------------------
|
||||
Configure Spack to Use Extensions
|
||||
---------------------------------
|
||||
|
||||
To make your current Spack instance aware of extensions you should add their root
|
||||
paths to ``config.yaml``. In the case of our example this means ensuring that:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
config:
|
||||
extensions:
|
||||
- /home/user/tmp/spack-scripting
|
||||
|
||||
is part of your configuration file. Once this is setup any command that the extension provides
|
||||
will be available from the command line:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ spack filter --help
|
||||
usage: spack filter [-h] [--installed | --not-installed]
|
||||
[--explicit | --implicit] [--output OUTPUT]
|
||||
...
|
||||
|
||||
filter specs based on their properties
|
||||
|
||||
positional arguments:
|
||||
specs specs to be filtered
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--installed select installed specs
|
||||
--not-installed select specs that are not yet installed
|
||||
--explicit select specs that were installed explicitly
|
||||
--implicit select specs that are not installed or were installed implicitly
|
||||
--output OUTPUT where to dump the result
|
||||
|
||||
The corresponding unit tests can be run giving the appropriate options to ``spack test``:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ spack test --extension=scripting
|
||||
|
||||
============================================================== test session starts ===============================================================
|
||||
platform linux2 -- Python 2.7.15rc1, pytest-3.2.5, py-1.4.34, pluggy-0.4.0
|
||||
rootdir: /home/mculpo/tmp/spack-scripting, inifile: pytest.ini
|
||||
collected 5 items
|
||||
|
||||
tests/test_filter.py ...XX
|
||||
============================================================ short test summary info =============================================================
|
||||
XPASS tests/test_filter.py::test_filtering_specs[flags3-specs3-expected3]
|
||||
XPASS tests/test_filter.py::test_filtering_specs[flags4-specs4-expected4]
|
||||
|
||||
=========================================================== slowest 20 test durations ============================================================
|
||||
3.74s setup tests/test_filter.py::test_filtering_specs[flags0-specs0-expected0]
|
||||
0.17s call tests/test_filter.py::test_filtering_specs[flags3-specs3-expected3]
|
||||
0.16s call tests/test_filter.py::test_filtering_specs[flags2-specs2-expected2]
|
||||
0.15s call tests/test_filter.py::test_filtering_specs[flags1-specs1-expected1]
|
||||
0.13s call tests/test_filter.py::test_filtering_specs[flags4-specs4-expected4]
|
||||
0.08s call tests/test_filter.py::test_filtering_specs[flags0-specs0-expected0]
|
||||
0.04s teardown tests/test_filter.py::test_filtering_specs[flags4-specs4-expected4]
|
||||
0.00s setup tests/test_filter.py::test_filtering_specs[flags4-specs4-expected4]
|
||||
0.00s setup tests/test_filter.py::test_filtering_specs[flags3-specs3-expected3]
|
||||
0.00s setup tests/test_filter.py::test_filtering_specs[flags1-specs1-expected1]
|
||||
0.00s setup tests/test_filter.py::test_filtering_specs[flags2-specs2-expected2]
|
||||
0.00s teardown tests/test_filter.py::test_filtering_specs[flags2-specs2-expected2]
|
||||
0.00s teardown tests/test_filter.py::test_filtering_specs[flags1-specs1-expected1]
|
||||
0.00s teardown tests/test_filter.py::test_filtering_specs[flags0-specs0-expected0]
|
||||
0.00s teardown tests/test_filter.py::test_filtering_specs[flags3-specs3-expected3]
|
||||
====================================================== 3 passed, 2 xpassed in 4.51 seconds =======================================================
|
@@ -72,6 +72,7 @@ or refer to the full manual below.
|
||||
command_index
|
||||
package_list
|
||||
chain
|
||||
extensions
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
@@ -7,13 +7,13 @@
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
|
||||
import llnl.util.lang
|
||||
import llnl.util.tty as tty
|
||||
|
||||
import spack.config
|
||||
|
||||
|
||||
extension_regexp = re.compile(r'spack-([\w]*)')
|
||||
|
||||
|
||||
@@ -50,14 +50,47 @@ def load_command_extension(command, path):
|
||||
if not extension:
|
||||
return None
|
||||
|
||||
# Compute the name of the module we search, exit early if already imported
|
||||
cmd_package = '{0}.{1}.cmd'.format(__name__, extension)
|
||||
python_name = command.replace('-', '_')
|
||||
module_name = '{0}.{1}'.format(cmd_package, python_name)
|
||||
if module_name in sys.modules:
|
||||
return sys.modules[module_name]
|
||||
|
||||
def ensure_package_creation(name):
|
||||
package_name = '{0}.{1}'.format(__name__, name)
|
||||
if package_name in sys.modules:
|
||||
return
|
||||
|
||||
parts = [path] + name.split('.') + ['__init__.py']
|
||||
init_file = os.path.join(*parts)
|
||||
if os.path.exists(init_file):
|
||||
m = llnl.util.lang.load_module_from_file(package_name, init_file)
|
||||
else:
|
||||
m = types.ModuleType(package_name)
|
||||
|
||||
# Setting __path__ to give spack extensions the
|
||||
# ability to import from their own tree, see:
|
||||
#
|
||||
# https://docs.python.org/3/reference/import.html#package-path-rules
|
||||
#
|
||||
m.__path__ = [os.path.dirname(init_file)]
|
||||
sys.modules[package_name] = m
|
||||
|
||||
# Create a searchable package for both the root folder of the extension
|
||||
# and the subfolder containing the commands
|
||||
ensure_package_creation(extension)
|
||||
ensure_package_creation(extension + '.cmd')
|
||||
|
||||
# Compute the absolute path of the file to be loaded, along with the
|
||||
# name of the python module where it will be stored
|
||||
cmd_path = os.path.join(path, extension, 'cmd', command + '.py')
|
||||
python_name = command.replace('-', '_')
|
||||
module_name = '{0}.{1}'.format(__name__, python_name)
|
||||
|
||||
try:
|
||||
# TODO: Upon removal of support for Python 2.6 substitute the call
|
||||
# TODO: below with importlib.import_module(module_name)
|
||||
module = llnl.util.lang.load_module_from_file(module_name, cmd_path)
|
||||
sys.modules[module_name] = module
|
||||
except (ImportError, IOError):
|
||||
module = None
|
||||
|
||||
|
111
lib/spack/spack/test/cmd_extensions.py
Normal file
111
lib/spack/spack/test/cmd_extensions.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# Copyright 2013-2019 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 pytest
|
||||
|
||||
import sys
|
||||
|
||||
import spack.config
|
||||
import spack.main
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def extension_root(tmpdir):
|
||||
root = tmpdir.mkdir('spack-testcommand')
|
||||
root.ensure('testcommand', 'cmd', dir=True)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hello_world_cmd(extension_root):
|
||||
"""Simple extension command with code contained in a single file."""
|
||||
hello = extension_root.ensure('testcommand', 'cmd', 'hello.py')
|
||||
hello.write("""
|
||||
description = "hello world extension command"
|
||||
section = "test command"
|
||||
level = "long"
|
||||
|
||||
def setup_parser(subparser):
|
||||
pass
|
||||
|
||||
|
||||
def hello(parser, args):
|
||||
print('Hello world!')
|
||||
""")
|
||||
list_of_modules = list(sys.modules.keys())
|
||||
with spack.config.override('config:extensions', [str(extension_root)]):
|
||||
yield spack.main.SpackCommand('hello')
|
||||
|
||||
to_be_deleted = [x for x in sys.modules if x not in list_of_modules]
|
||||
for module_name in to_be_deleted:
|
||||
del sys.modules[module_name]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hello_world_with_module_in_root(extension_root):
|
||||
"""Extension command with additional code in the root folder."""
|
||||
extension_root.ensure('testcommand', '__init__.py')
|
||||
command_root = extension_root.join('testcommand', 'cmd')
|
||||
hello = command_root.ensure('hello.py')
|
||||
hello.write("""
|
||||
# Test an absolute import
|
||||
from spack.extensions.testcommand.implementation import hello_world
|
||||
|
||||
# Test a relative import
|
||||
from ..implementation import hello_folks
|
||||
|
||||
description = "hello world extension command"
|
||||
section = "test command"
|
||||
level = "long"
|
||||
|
||||
# Test setting a global variable in setup_parser and retrieving
|
||||
# it in the command
|
||||
global_message = 'foo'
|
||||
|
||||
def setup_parser(subparser):
|
||||
sp = subparser.add_subparsers(metavar='SUBCOMMAND', dest='subcommand')
|
||||
global global_message
|
||||
sp.add_parser('world', help='Print Hello world!')
|
||||
sp.add_parser('folks', help='Print Hello folks!')
|
||||
sp.add_parser('global', help='Print Hello folks!')
|
||||
global_message = 'bar'
|
||||
|
||||
def hello(parser, args):
|
||||
if args.subcommand == 'world':
|
||||
hello_world()
|
||||
elif args.subcommand == 'folks':
|
||||
hello_folks()
|
||||
elif args.subcommand == 'global':
|
||||
print(global_message)
|
||||
""")
|
||||
implementation = extension_root.ensure('testcommand', 'implementation.py')
|
||||
implementation.write("""
|
||||
def hello_world():
|
||||
print('Hello world!')
|
||||
|
||||
def hello_folks():
|
||||
print('Hello folks!')
|
||||
""")
|
||||
list_of_modules = list(sys.modules.keys())
|
||||
with spack.config.override('config:extensions', [str(extension_root)]):
|
||||
yield spack.main.SpackCommand('hello')
|
||||
|
||||
to_be_deleted = [x for x in sys.modules if x not in list_of_modules]
|
||||
for module_name in to_be_deleted:
|
||||
del sys.modules[module_name]
|
||||
|
||||
|
||||
def test_simple_command_extension(hello_world_cmd):
|
||||
output = hello_world_cmd()
|
||||
assert 'Hello world!' in output
|
||||
|
||||
|
||||
def test_command_with_import(hello_world_with_module_in_root):
|
||||
output = hello_world_with_module_in_root('world')
|
||||
assert 'Hello world!' in output
|
||||
output = hello_world_with_module_in_root('folks')
|
||||
assert 'Hello folks!' in output
|
||||
output = hello_world_with_module_in_root('global')
|
||||
assert 'bar' in output
|
Reference in New Issue
Block a user