Skip to content

Commit

Permalink
add cli options for config file management (#33)
Browse files Browse the repository at this point in the history
* add cli options for config file management

Add cli options:

  --testing-config-action: Read config file if present ('read'), require config file ('require') or generate new config file ('generate').
  --regenerate-test-data: Regenerate test data

* add tests for new cli options

* add documentation of new cli options

* fix: check that the data directory path is absolute and exists

* fix: add quotation marks to exported environment variables

Paths may contain spaces etc.
  • Loading branch information
ltalirz committed Apr 1, 2020
1 parent 9247c3d commit bea61f0
Show file tree
Hide file tree
Showing 10 changed files with 296 additions and 67 deletions.
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
2 changes: 1 addition & 1 deletion aiida_testing/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
"""A pytest plugin for testing AiiDA plugins."""

__version__ = '0.1.0.dev0'
__version__ = '0.1.0.dev1'
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)),
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

0 comments on commit bea61f0

Please sign in to comment.