Skip to content

Commit

Permalink
CLI: allow setting options for config without profiles (#5544)
Browse files Browse the repository at this point in the history
The `verdi config set` command would except if the config had no
configured profiles. The reason is that the command would attempt to
retrieve the `Config` instance from the `ctx.obj` object. The problem is
however that this is created and set on the context in the `convert`
method of the `ProfileParamType`, which is used for the `--profile`
option.

The option specifies a default, which is the default profile, but if
that is not defined, it doesn't provide anything and so the `convert`
method is never called, resulting in the `ctx.obj` never being
initialized and the `config` not being set.

Since the config should always be present for all `verdi` comments, we
should ensure that it is always loaded and set on `ctx.obj`. Therefore
it is removed from `ProfileParamType.convert` and added to the
constructor of a custom implementation of the `click.Context` class,
called `VerdiContext`. The `VerdiCommandGroup` is updated to set the
`context_class` attribute to this `VerdiContext` which will ensure that
all commands will be invoked using a context that has the user defined
object constructed with the config loaded and added to it. Since all
commands in `verdi` will implement this class, this should guarantee
that the config will always be initialized.
  • Loading branch information
sphuber authored May 30, 2022
1 parent 7b8c61d commit c934efd
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 111 deletions.
21 changes: 21 additions & 0 deletions aiida/cmdline/groups/verdi.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

import click

from aiida.common.exceptions import ConfigurationError
from aiida.common.extendeddicts import AttributeDict
from aiida.manage.configuration import get_config

from ..params import options

__all__ = ('VerdiCommandGroup',)
Expand All @@ -28,13 +32,30 @@
)


class VerdiContext(click.Context):
"""Custom context implementation that defines the ``obj`` user object and adds the ``Config`` instance."""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

if self.obj is None:
self.obj = AttributeDict()

try:
self.obj.config = get_config(create=True)
except ConfigurationError as exception:
self.fail(str(exception))


class VerdiCommandGroup(click.Group):
"""Subclass of :class:`click.Group` for the ``verdi`` CLI.
The class automatically adds the verbosity option to all commands in the interface. It also adds some functionality
to provide suggestions of commands in case the user provided command name does not exist.
"""

context_class = VerdiContext

@staticmethod
def add_verbosity_option(cmd):
"""Apply the ``verbosity`` option to the command, which is common to all ``verdi`` commands."""
Expand Down
23 changes: 15 additions & 8 deletions aiida/cmdline/params/types/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@


class ProfileParamType(LabelStringType):
"""The profile parameter type for click."""
"""The profile parameter type for click.
This parameter type requires the command that uses it to define the ``context_class`` class attribute to be the
:class:`aiida.cmdline.groups.verdi.VerdiContext` class, as that is responsible for creating the user defined object
``obj`` on the context and loads the instance config.
"""

name = 'profile'

Expand All @@ -31,9 +36,16 @@ def deconvert_default(value):

def convert(self, value, param, ctx):
"""Attempt to match the given value to a valid profile."""
from aiida.common import extendeddicts
from aiida.common.exceptions import MissingConfigurationError, ProfileConfigurationError
from aiida.manage.configuration import Profile, get_config, load_profile
from aiida.manage.configuration import Profile, load_profile

try:
config = ctx.obj.config
except AttributeError:
raise RuntimeError(
'The context does not contain a user defined object with the loaded AiiDA configuration. '
'Is your click command setting `context_class` to :class:`aiida.cmdline.groups.verdi.VerdiContext`?'
)

# If the value is already of the expected return type, simply return it. This behavior is new in `click==8.0`:
# https://click.palletsprojects.com/en/8.0.x/parameters/#implementing-custom-types
Expand All @@ -43,7 +55,6 @@ def convert(self, value, param, ctx):
value = super().convert(value, param, ctx)

try:
config = get_config(create=True)
profile = config.get_profile(value)
except (MissingConfigurationError, ProfileConfigurationError) as exception:
if not self._cannot_exist:
Expand All @@ -58,10 +69,6 @@ def convert(self, value, param, ctx):
if self._load_profile:
load_profile(profile.name)

if ctx.obj is None:
ctx.obj = extendeddicts.AttributeDict()

ctx.obj.config = config
ctx.obj.profile = profile

return profile
Expand Down
3 changes: 2 additions & 1 deletion aiida/storage/psql_dos/alembic_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from sqlalchemy.util.compat import nullcontext

from aiida.cmdline import is_verbose
from aiida.cmdline.groups.verdi import VerdiCommandGroup
from aiida.cmdline.params import options
from aiida.storage.psql_dos.migrator import PsqlDostoreMigrator

Expand Down Expand Up @@ -44,7 +45,7 @@ def execute_alembic_command(self, command_name, connect=True, **kwargs):
pass_runner = click.make_pass_decorator(AlembicRunner, ensure=True)


@click.group()
@click.group(cls=VerdiCommandGroup)
@options.PROFILE(required=True)
@pass_runner
def alembic_cli(runner, profile):
Expand Down
223 changes: 121 additions & 102 deletions tests/cmdline/commands/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,143 +7,162 @@
# For further information on the license, see the LICENSE.txt file #
# For further information please visit http://www.aiida.net #
###########################################################################
# pylint: disable=no-self-use
"""Tests for `verdi config`."""
import pytest

"""Tests for ``verdi config``."""
from aiida import get_profile
from aiida.cmdline.commands import cmd_verdi
from aiida.manage.configuration import get_config


class TestVerdiConfig:
"""Tests for `verdi config`."""
def test_config_set_option_no_profile(run_cli_command, empty_config):
"""Test the `verdi config set` command when no profile is present in the config."""
config = empty_config

@pytest.fixture(autouse=True)
def setup_fixture(self, config_with_profile_factory):
config_with_profile_factory()
option_name = 'daemon.timeout'
option_value = str(10)

def test_config_set_option(self, run_cli_command):
"""Test the `verdi config set` command when setting an option."""
config = get_config()
options = ['config', 'set', option_name, str(option_value)]
run_cli_command(cmd_verdi.verdi, options)
assert str(config.get_option(option_name, scope=None)) == option_value

option_name = 'daemon.timeout'
option_values = [str(10), str(20)]

for option_value in option_values:
options = ['config', 'set', option_name, str(option_value)]
run_cli_command(cmd_verdi.verdi, options)
assert str(config.get_option(option_name, scope=get_profile().name)) == option_value
def test_config_set_option(run_cli_command, config_with_profile_factory):
"""Test the `verdi config set` command when setting an option."""
config = config_with_profile_factory()

def test_config_append_option(self, run_cli_command):
"""Test the `verdi config set --append` command when appending an option value."""
config = get_config()
option_name = 'caching.enabled_for'
for value in ['x', 'y']:
options = ['config', 'set', '--append', option_name, value]
run_cli_command(cmd_verdi.verdi, options)
assert config.get_option(option_name, scope=get_profile().name) == ['x', 'y']
option_name = 'daemon.timeout'
option_values = [str(10), str(20)]

def test_config_remove_option(self, run_cli_command):
"""Test the `verdi config set --remove` command when removing an option value."""
config = get_config()
for option_value in option_values:
options = ['config', 'set', option_name, option_value]
run_cli_command(cmd_verdi.verdi, options)
assert str(config.get_option(option_name, scope=get_profile().name)) == option_value

option_name = 'caching.disabled_for'
config.set_option(option_name, ['x', 'y'], scope=get_profile().name)

options = ['config', 'set', '--remove', option_name, 'x']
def test_config_append_option(run_cli_command, config_with_profile_factory):
"""Test the `verdi config set --append` command when appending an option value."""
config = config_with_profile_factory()
option_name = 'caching.enabled_for'
for value in ['x', 'y']:
options = ['config', 'set', '--append', option_name, value]
run_cli_command(cmd_verdi.verdi, options)
assert config.get_option(option_name, scope=get_profile().name) == ['y']
assert config.get_option(option_name, scope=get_profile().name) == ['x', 'y']

def test_config_get_option(self, run_cli_command):
"""Test the `verdi config show` command when getting an option."""
option_name = 'daemon.timeout'
option_value = str(30)

options = ['config', 'set', option_name, option_value]
result = run_cli_command(cmd_verdi.verdi, options)
def test_config_remove_option(run_cli_command, config_with_profile_factory):
"""Test the `verdi config set --remove` command when removing an option value."""
config = config_with_profile_factory()

options = ['config', 'get', option_name]
result = run_cli_command(cmd_verdi.verdi, options)
assert option_value in result.output.strip()
option_name = 'caching.disabled_for'
config.set_option(option_name, ['x', 'y'], scope=get_profile().name)

def test_config_unset_option(self, run_cli_command):
"""Test the `verdi config` command when unsetting an option."""
option_name = 'daemon.timeout'
option_value = str(30)
options = ['config', 'set', '--remove', option_name, 'x']
run_cli_command(cmd_verdi.verdi, options)
assert config.get_option(option_name, scope=get_profile().name) == ['y']

options = ['config', 'set', option_name, str(option_value)]
result = run_cli_command(cmd_verdi.verdi, options)

options = ['config', 'get', option_name]
result = run_cli_command(cmd_verdi.verdi, options)
assert option_value in result.output.strip()
def test_config_get_option(run_cli_command, config_with_profile_factory):
"""Test the `verdi config show` command when getting an option."""
config_with_profile_factory()
option_name = 'daemon.timeout'
option_value = str(30)

options = ['config', 'unset', option_name]
result = run_cli_command(cmd_verdi.verdi, options)
assert f"'{option_name}' unset" in result.output.strip()
options = ['config', 'set', option_name, option_value]
result = run_cli_command(cmd_verdi.verdi, options)

options = ['config', 'get', option_name]
result = run_cli_command(cmd_verdi.verdi, options)
assert result.output.strip() == str(20) # back to the default
options = ['config', 'get', option_name]
result = run_cli_command(cmd_verdi.verdi, options)
assert option_value in result.output.strip()

def test_config_set_option_global_only(self, run_cli_command):
"""Test that `global_only` options are only set globally even if the `--global` flag is not set."""
option_name = 'autofill.user.email'
option_value = 'some@email.com'

options = ['config', 'set', option_name, str(option_value)]
result = run_cli_command(cmd_verdi.verdi, options)
def test_config_unset_option(run_cli_command, config_with_profile_factory):
"""Test the `verdi config` command when unsetting an option."""
config_with_profile_factory()
option_name = 'daemon.timeout'
option_value = str(30)

options = ['config', 'get', option_name]
result = run_cli_command(cmd_verdi.verdi, options)
options = ['config', 'set', option_name, str(option_value)]
result = run_cli_command(cmd_verdi.verdi, options)

options = ['config', 'get', option_name]
result = run_cli_command(cmd_verdi.verdi, options)
assert option_value in result.output.strip()

options = ['config', 'unset', option_name]
result = run_cli_command(cmd_verdi.verdi, options)
assert f"'{option_name}' unset" in result.output.strip()

# Check that the current profile name is not in the output
assert option_value in result.output.strip()
assert get_profile().name not in result.output.strip()
options = ['config', 'get', option_name]
result = run_cli_command(cmd_verdi.verdi, options)
assert result.output.strip() == str(20) # back to the default

def test_config_list(self, run_cli_command):
"""Test `verdi config list`"""
options = ['config', 'list']

def test_config_set_option_global_only(run_cli_command, config_with_profile_factory):
"""Test that `global_only` options are only set globally even if the `--global` flag is not set."""
config_with_profile_factory()
option_name = 'autofill.user.email'
option_value = 'some@email.com'

options = ['config', 'set', option_name, str(option_value)]
result = run_cli_command(cmd_verdi.verdi, options)

options = ['config', 'get', option_name]
result = run_cli_command(cmd_verdi.verdi, options)

# Check that the current profile name is not in the output
assert option_value in result.output.strip()
assert get_profile().name not in result.output.strip()


def test_config_list(run_cli_command, config_with_profile_factory):
"""Test `verdi config list`"""
config_with_profile_factory()
options = ['config', 'list']
result = run_cli_command(cmd_verdi.verdi, options)

assert 'daemon.timeout' in result.output
assert 'Timeout in seconds' not in result.output


def test_config_list_description(run_cli_command, config_with_profile_factory):
"""Test `verdi config list --description`"""
config_with_profile_factory()
for flag in ['-d', '--description']:
options = ['config', 'list', flag]
result = run_cli_command(cmd_verdi.verdi, options)

assert 'daemon.timeout' in result.output
assert 'Timeout in seconds' not in result.output
assert 'Timeout in seconds' in result.output

def test_config_list_description(self, run_cli_command):
"""Test `verdi config list --description`"""
for flag in ['-d', '--description']:
options = ['config', 'list', flag]
result = run_cli_command(cmd_verdi.verdi, options)

assert 'daemon.timeout' in result.output
assert 'Timeout in seconds' in result.output
def test_config_show(run_cli_command, config_with_profile_factory):
"""Test `verdi config show`"""
config_with_profile_factory()
options = ['config', 'show', 'daemon.timeout']
result = run_cli_command(cmd_verdi.verdi, options)
assert 'schema' in result.output

def test_config_show(self, run_cli_command):
"""Test `verdi config show`"""
options = ['config', 'show', 'daemon.timeout']
result = run_cli_command(cmd_verdi.verdi, options)
assert 'schema' in result.output

def test_config_caching(self, run_cli_command):
"""Test `verdi config caching`"""
result = run_cli_command(cmd_verdi.verdi, ['config', 'caching'])
assert result.output.strip() == ''
def test_config_caching(run_cli_command, config_with_profile_factory):
"""Test `verdi config caching`"""
config = config_with_profile_factory()

result = run_cli_command(cmd_verdi.verdi, ['config', 'caching', '--disabled'])
assert 'core.arithmetic.add' in result.output.strip()
result = run_cli_command(cmd_verdi.verdi, ['config', 'caching'])
assert result.output.strip() == ''

config = get_config()
config.set_option('caching.default_enabled', True, scope=get_profile().name)
result = run_cli_command(cmd_verdi.verdi, ['config', 'caching', '--disabled'])
assert 'core.arithmetic.add' in result.output.strip()

result = run_cli_command(cmd_verdi.verdi, ['config', 'caching'])
assert 'core.arithmetic.add' in result.output.strip()
config.set_option('caching.default_enabled', True, scope=get_profile().name)

result = run_cli_command(cmd_verdi.verdi, ['config', 'caching', '--disabled'])
assert result.output.strip() == ''
result = run_cli_command(cmd_verdi.verdi, ['config', 'caching'])
assert 'core.arithmetic.add' in result.output.strip()

def test_config_downgrade(self, run_cli_command):
"""Test `verdi config downgrade`"""
options = ['config', 'downgrade', '1']
result = run_cli_command(cmd_verdi.verdi, options)
assert 'Success: Downgraded' in result.output.strip()
result = run_cli_command(cmd_verdi.verdi, ['config', 'caching', '--disabled'])
assert result.output.strip() == ''


def test_config_downgrade(run_cli_command, config_with_profile_factory):
"""Test `verdi config downgrade`"""
config_with_profile_factory()
options = ['config', 'downgrade', '1']
result = run_cli_command(cmd_verdi.verdi, options)
assert 'Success: Downgraded' in result.output.strip()
10 changes: 10 additions & 0 deletions tests/manage/configuration/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,16 @@ def test_set_option_override(config_with_profile):
assert config.get_option(option_name, scope=None, default=False) == option_value_two


def test_option_empty_config(empty_config):
"""Test setting an option on a config without any profiles."""
config = empty_config
option_name = 'autofill.user.email'
option_value = 'first@email.com'

config.set_option(option_name, option_value)
assert config.get_option(option_name, scope=None, default=False) == option_value


def test_store(config_with_profile):
"""Test that the store method writes the configuration properly to disk."""
config = config_with_profile
Expand Down

0 comments on commit c934efd

Please sign in to comment.