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.__iter__()

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'
130 changes: 112 additions & 18 deletions aiida_testing/mock_code/_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,94 @@
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(
"--mock-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("--mock-regenerate-test-data")


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

testing_config_action :
Read config file if present ('read'), require config file ('require') or generate new config file ('generate').
"""
config = Config.from_file()

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

yield config

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', {})

testing_config_action :
Read config file if present ('read'), require config file ('require') or generate new config file ('generate').


"""
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'),
executable_name: str = '',
_config: dict = testing_config,
_config_action: str = testing_config_action,
_regenerate_test_data: bool = mock_regenerate_test_data,
):
"""
Creates a mock AiiDA code. If the same inputs have been run previously,
the results are copied over from the corresponding sub-directory of
the ``data_dir_abspath``. Otherwise, the code is executed if an
executable is specified in the configuration, or fails if it is not.
the ``data_dir_abspath``. Otherwise, the code is executed.

Parameters
----------
Expand All @@ -48,26 +109,59 @@ def _get_mock_code(
stored.
ignore_files :
A list of files which are not copied to the results directory
when the code is executed.
after the code has been 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 :
If 'require', raise ValueError if config dictionary does not specify path of executable.
If 'generate', add new key (label) to config dictionary.
_regenerate_test_data :
If True, regenerate test data instead of reusing.
"""
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_pl = pathlib.Path(data_dir_abspath)
if not data_dir_pl.exists():
raise ValueError("Data directory '{}' does not exist".format(data_dir_abspath))
if not data_dir_pl.is_absolute():
raise ValueError("Please provide absolute path to data directory.")

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.IGNORE_FILES.value}={':'.join(ignore_files)}
export {EnvKeys.LABEL.value}="{label}"
export {EnvKeys.DATA_DIR.value}="{data_dir_abspath}"
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