Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add cli options for config file management #33

Merged
merged 16 commits into from
Apr 1, 2020
Merged
11 changes: 2 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,7 @@ jobs:
python-version: [3.6, 3.8]
backend: ['django']
steps:
- uses: actions/checkout@v1
- uses: harmon758/postgresql-action@v1
with:
postgresql version: '11'
postgresql db: test_${{ matrix.backend }}
postgresql user: 'postgres'
postgresql password: ''

- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
Expand All @@ -32,7 +25,7 @@ jobs:
echo 'deb https://dl.bintray.com/rabbitmq/debian bionic main' | sudo tee -a /etc/apt/sources.list.d/bintray.rabbitmq.list
sudo rm -f /etc/apt/sources.list.d/dotnetdev.list /etc/apt/sources.list.d/microsoft-prod.list
sudo apt update
sudo apt install postgresql postgresql-server-dev-all postgresql-client rabbitmq-server graphviz
sudo apt install postgresql-10 rabbitmq-server graphviz
sudo systemctl status rabbitmq-server.service

- name: Install python dependencies
Expand Down
91 changes: 74 additions & 17 deletions aiida_testing/_config.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,87 @@
# -*- coding: utf-8 -*-
"""
Defines a helper for loading the ``.aiida-testing-config.yml``
configuration file.
Helpers for managing the ``.aiida-testing-config.yml`` configuration file.
"""

import os
import pathlib
import typing as ty
import collections
from voluptuous import Schema
from enum import Enum

import yaml

CONFIG_FILE_NAME = '.aiida-testing-config.yml'

def get_config() -> ty.Dict[str, str]:

class ConfigActions(Enum):
"""
Reads the configuration file ``.aiida-testing-config.yml``. The
file is searched in the current working directory and all its parent
directories.
An enum containing the actions to perform on the config file.
"""
cwd = pathlib.Path(os.getcwd())
config: ty.Dict[str, str]
for dir_path in [cwd, *cwd.parents]:
config_file_path = (dir_path / '.aiida-testing-config.yml')
if config_file_path.exists():
with open(config_file_path) as config_file:
config = yaml.load(config_file, Loader=yaml.SafeLoader)
break
else:
config = {}
return config
READ = 'read'
GENERATE = 'generate'
REQUIRE = 'require'


class Config(collections.abc.MutableMapping):
"""Configuration of aiida-testing package."""

schema = Schema({'mock_code': Schema({str: str})})

def __init__(self, config=None):
self._dict = config or {}
self.validate()

def validate(self):
"""Validate configuration dictionary."""
return self.schema(self._dict)

@classmethod
def from_file(cls):
"""
Parses the configuration file ``.aiida-testing-config.yml``.

The file is searched in the current working directory and all its parent
directories.
"""
cwd = pathlib.Path(os.getcwd())
config: ty.Dict[str, str]
for dir_path in [cwd, *cwd.parents]:
config_file_path = (dir_path / CONFIG_FILE_NAME)
if config_file_path.exists():
with open(config_file_path) as config_file:
config = yaml.load(config_file, Loader=yaml.SafeLoader)
break
else:
config = {}

return cls(config)

def to_file(self):
"""Write configuration to file in yaml format.

Writes to current working directory.

:param handle: File handle to write config file to.
"""
cwd = pathlib.Path(os.getcwd())
config_file_path = (cwd / CONFIG_FILE_NAME)

with open(config_file_path, 'w') as handle:
yaml.dump(self._dict, handle, Dumper=yaml.SafeDumper)

def __getitem__(self, item):
return self._dict.__getitem__(item)

def __setitem__(self, key, value):
return self._dict.__setitem__(key, value)

def __delitem__(self, key):
return self._dict.__delitem__(key)

def __iter__(self):
return self._dict.__init__()
ltalirz marked this conversation as resolved.
Show resolved Hide resolved

def __len__(self):
return self._dict.__len__()
13 changes: 9 additions & 4 deletions aiida_testing/mock_code/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@
the executable.
"""

import typing as ty
from ._fixtures import *

from ._fixtures import mock_code_factory

__all__: ty.Tuple[str, ...] = ('mock_code_factory', )
# Note: This is necessary for the sphinx doc - otherwise it does not find aiida_testing.mock_code.mock_code_factory
__all__ = (
"pytest_addoption",
"testing_config_action",
"mock_regenerate_test_data",
"testing_config",
"mock_code_factory",
)
7 changes: 6 additions & 1 deletion aiida_testing/mock_code/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,15 @@ def run() -> None:
data_dir = os.environ[EnvKeys.DATA_DIR.value]
executable_path = os.environ[EnvKeys.EXECUTABLE_PATH.value]
ignore_files = os.environ[EnvKeys.IGNORE_FILES.value].split(':')
regenerate_data = os.environ[EnvKeys.REGENERATE_DATA.value] == 'True'

hash_digest = get_hash().hexdigest()

res_dir = pathlib.Path(data_dir) / f"mock-{label}-{hash_digest}"

if regenerate_data and res_dir.exists():
shutil.rmtree(res_dir)

if not res_dir.exists():
if not executable_path:
sys.exit("No existing output, and no executable specified.")
Expand Down Expand Up @@ -116,7 +121,7 @@ def replace_submit_file(executable_path: str) -> None:
for line in submit_file_content.splitlines():
if 'export AIIDA_MOCK' in line:
continue
elif 'aiida-mock-code' in line:
if 'aiida-mock-code' in line:
submit_file_res_lines.append(
f"'{executable_path}' " + line.split("aiida-mock-code'")[1]
)
Expand Down
1 change: 1 addition & 0 deletions aiida_testing/mock_code/_env_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ class EnvKeys(Enum):
DATA_DIR = 'AIIDA_MOCK_DATA_DIR'
EXECUTABLE_PATH = 'AIIDA_MOCK_EXECUTABLE_PATH'
IGNORE_FILES = 'AIIDA_MOCK_IGNORE_FILES'
REGENERATE_DATA = 'AIIDA_MOCK_REGENERATE_DATA'
117 changes: 104 additions & 13 deletions aiida_testing/mock_code/_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,89 @@
import inspect
import pathlib
import typing as ty

import click
import pytest

from aiida.orm import Code

from ._env_keys import EnvKeys
from .._config import get_config
from .._config import Config, CONFIG_FILE_NAME, ConfigActions

__all__ = (
"pytest_addoption",
"testing_config_action",
"mock_regenerate_test_data",
"testing_config",
"mock_code_factory",
)


def pytest_addoption(parser):
"""Add pytest command line options."""
parser.addoption(
"--testing-config-action",
type=click.Choice((c.value for c in ConfigActions)),
ltalirz marked this conversation as resolved.
Show resolved Hide resolved
default=ConfigActions.READ.value,
help=f"Read {CONFIG_FILE_NAME} config file if present ('read'), require config file ('require') or " \
"generate new config file ('generate').",
)
parser.addoption(
"--regenerate-test-data", action="store_true", default=False, help="Regenerate test data."
)


@pytest.fixture(scope='session')
def testing_config_action(request):
return request.config.getoption("--testing-config-action")


@pytest.fixture(scope='session')
def mock_regenerate_test_data(request):
return request.config.getoption("--regenerate-test-data")


@pytest.fixture(scope='session')
def testing_config(testing_config_action): # pylint: disable=redefined-outer-name
"""Get testing-config-action.

Specifying CLI parameter --testing-config-action will raise if no config file is found.
Specifying CLI parameter --generate-testing-config-action results in config
template being written during test run.
"""
config = Config.from_file()

if not config and testing_config_action == ConfigActions.REQUIRE.value:
raise ValueError(f"Unable to find {CONFIG_FILE_NAME}.")

yield config

__all__ = ("mock_code_factory", )
if testing_config_action == ConfigActions.GENERATE.value:
config.to_file()


@pytest.fixture(scope='function')
def mock_code_factory(aiida_localhost):
def mock_code_factory(
aiida_localhost, testing_config, testing_config_action, mock_regenerate_test_data
): # pylint: disable=redefined-outer-name
"""
Fixture to create a mock AiiDA Code.
"""
config = get_config().get('mock_code', {})

Specifying CLI parameter --testing-config-action will raise if a required code label is not found.
Specifying CLI parameter --generate-testing-config-action results in config
template being written during test run.

"""
def _get_mock_code(
label: str,
entry_point: str,
data_dir_abspath: ty.Union[str, pathlib.Path],
ignore_files: ty.Iterable[str] = ('_aiidasubmit.sh', )
):
ignore_files: ty.Iterable[str] = ('_aiidasubmit.sh'),
#ignore_files: ty.Iterable[str] = ('_aiidasubmit.sh', '_scheduler-stderr.txt', '_scheduler-stdout.txt',),
executable_name: str = '',
config: dict = testing_config,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are config, config_action and regenerate_test_data here passed just for being able to test the fixture internally, or do we expect users to set them?

I'm not really sure if there's an established convention for that, but maybe we could indicate this by a leading underscore in the kwarg names (_config, _config_action, _regenerate_test_data)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

config_action: str = testing_config_action,
regenerate_test_data: bool = mock_regenerate_test_data,
): # pylint: disable=too-many-arguments
"""
Creates a mock AiiDA code. If the same inputs have been run previously,
the results are copied over from the corresponding sub-directory of
Expand All @@ -49,25 +110,55 @@ def _get_mock_code(
ignore_files :
A list of files which are not copied to the results directory
when the code is executed.
executable_name :
Name of code executable to search for in PATH, if configuration file does not specify location already.
config :
Dict with contents of configuration file
config_action :
Read config file if present ('read'), require config file ('require') or generate new config file ('generate').
generate_config :
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generate_config parameter doesn't exist, and regenerate_test_data is missing in the docstring.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks, fixed!

Generate configuration file template, if it does not yet exist
"""
from aiida.orm import Code

# we want to set a custom prepend_text, which is why the code
# can not be reused.
code_label = f'mock-{label}-{uuid.uuid4()}'

executable_path = shutil.which('aiida-mock-code')
data_dir_abspath = pathlib.Path(data_dir_abspath).absolute()
ltalirz marked this conversation as resolved.
Show resolved Hide resolved
if not data_dir_abspath.exists():
raise ValueError("Data directory '{}' does not exist".format(data_dir_abspath))

mock_executable_path = shutil.which('aiida-mock-code')
if not mock_executable_path:
raise ValueError(
"'aiida-mock-code' executable not found in the PATH. " +
"Have you run `pip install aiida-testing` in this python environment?"
)

# try determine path to actual code executable
mock_code_config = config.get('mock_code', {})
if config_action == ConfigActions.REQUIRE.value and label not in mock_code_config:
raise ValueError(
f"Configuration file {CONFIG_FILE_NAME} does not specify path to executable for code label '{label}'."
)
code_executable_path = mock_code_config.get(label, 'TO_SPECIFY')
if (not code_executable_path) and executable_name:
code_executable_path = shutil.which(executable_name) or 'NOT_FOUND'
if config_action == ConfigActions.GENERATE.value:
mock_code_config[label] = code_executable_path

code = Code(
input_plugin_name=entry_point, remote_computer_exec=[aiida_localhost, executable_path]
input_plugin_name=entry_point,
remote_computer_exec=[aiida_localhost, mock_executable_path]
)
code.label = code_label
code.set_prepend_text(
inspect.cleandoc(
f"""
export {EnvKeys.LABEL.value}={label}
export {EnvKeys.DATA_DIR.value}={data_dir_abspath}
export {EnvKeys.EXECUTABLE_PATH.value}={config.get(label, '')}
export {EnvKeys.EXECUTABLE_PATH.value}={code_executable_path}
export {EnvKeys.IGNORE_FILES.value}={':'.join(ignore_files)}
export {EnvKeys.REGENERATE_DATA.value}={'True' if regenerate_test_data else 'False'}
"""
)
)
Expand Down
8 changes: 6 additions & 2 deletions docs/source/user_guide/mock_code.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ First, we want to define a fixture for our mocked code in the ``conftest.py``:
pytest_plugins = ['aiida.manage.tests.pytest_fixtures', 'aiida_testing.mock_code']

# Directory where to store outputs for known inputs (usually tests/data)
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data'),
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')

@pytest.fixture(scope='function')
def mocked_diff(mock_code_factory):
Expand All @@ -35,7 +35,7 @@ First, we want to define a fixture for our mocked code in the ``conftest.py``:
ignore_files=('_aiidasubmit.sh', 'file*')
)

Second, we need to tell the mock executable where to find the *actual* ``diff`` executable by creating a ``.aiida-testing-config.yml`` file in the top level of our plugin.
Second, we need to tell the mock executable where to find the *actual* ``diff`` executable by creating a ``.testing-config-action.yml`` file in the top level of our plugin.

.. note::
This step is needed **only** when we want to use the actual executable to (re)generate test data.
Expand Down Expand Up @@ -74,6 +74,10 @@ Finally, we can use our fixture in our tests as if it would provide a normal :py
When running the test for the first time, ``aiida-mock-code`` will pipe through to the actual ``diff`` executable.
The next time, it will recognise the inputs and directly use the outputs cached in the data directory.

.. note::
``aiida-mock-code`` "recognizes" calculations by computing a hash of the working directory of the calculation (as prepared by the calculation input plugin).
It does *not* rely on the hashing mechanism of AiiDA.

Don't forget to add your data directory to your test data in order to make them available in CI and to other users of your plugin!


Expand Down
7 changes: 2 additions & 5 deletions setup.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,11 @@
"cache"
],
"include_package_data": true,
"setup_requires": [
"reentry"
],
"reentry_register": true,
"install_requires": [
"aiida-core>=1.0.0<2.0.0",
"pytest>=3.6",
"pyyaml~=5.1.2"
"pyyaml~=5.1.2",
"voluptuous~=0.11.7"
],
"extras_require": {
"docs": [
Expand Down
Loading