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:
Massimiliano Culpo
2019-05-17 02:27:42 +02:00
committed by Peter Scheibel
parent b9370bf20b
commit c03be0d65a
4 changed files with 277 additions and 4 deletions

View 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 =======================================================

View File

@@ -72,6 +72,7 @@ or refer to the full manual below.
command_index command_index
package_list package_list
chain chain
extensions
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2

View File

@@ -7,13 +7,13 @@
""" """
import os import os
import re import re
import sys
import types
import llnl.util.lang import llnl.util.lang
import llnl.util.tty as tty import llnl.util.tty as tty
import spack.config import spack.config
extension_regexp = re.compile(r'spack-([\w]*)') extension_regexp = re.compile(r'spack-([\w]*)')
@@ -50,14 +50,47 @@ def load_command_extension(command, path):
if not extension: if not extension:
return None 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 # Compute the absolute path of the file to be loaded, along with the
# name of the python module where it will be stored # name of the python module where it will be stored
cmd_path = os.path.join(path, extension, 'cmd', command + '.py') cmd_path = os.path.join(path, extension, 'cmd', command + '.py')
python_name = command.replace('-', '_')
module_name = '{0}.{1}'.format(__name__, python_name)
try: 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) module = llnl.util.lang.load_module_from_file(module_name, cmd_path)
sys.modules[module_name] = module
except (ImportError, IOError): except (ImportError, IOError):
module = None module = None

View 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