Skip to content

Commit

Permalink
CLI: update to be compatible with aiida-core==2.1 (#855)
Browse files Browse the repository at this point in the history
Currently the CLI is broken with `aiida-core==2.1`. The reason is that
we are using the `PROFILE` option, which relies on the command that it
is attached to configure certain things, such as the context. In v2.1,
the behavior was changed causing the commands to except because the
`PROFILE` option cannot find certain attributes it expects in the
context.

For the `PROFILE` option to work, the root command should use the class
`aiida.cmdline.groups.VerdiCommandGroup` as its `cls`. This class defines
a custom context class that is necessary. It also automatically provides
the verbosity option for all subcommands, so the explicit declaration
can now be removed.

The problem was not detected in the unit tests because they only show up
when the root command is invoked. The unit tests call the subcommands
directly though, and this circumvents the root command. This is
documented behavior of `click` and there is no way around it. The only
solution is to call the commands through the command  line interface
exactly as they would be called normally.

This is now done in the `test_commands.py` file. The test parametrizes
over all existing (sub)commands and directly calls it as a subprocess.
This guarantees that the root command is called including its specific
context that needs to be setup.
  • Loading branch information
sphuber authored Nov 8, 2022
1 parent a6fb502 commit 6f86390
Show file tree
Hide file tree
Showing 4 changed files with 43 additions and 54 deletions.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ classifiers = [
keywords = ['aiida', 'workflows']
requires-python = '>=3.8'
dependencies = [
'aiida_core[atomic_tools]~=2.0,>=2.0.4',
'aiida-pseudo~=0.7.0',
'aiida_core[atomic_tools]~=2.1',
'aiida-pseudo~=0.8.0',
'click~=8.0',
'importlib_resources',
'jsonschema',
Expand Down
39 changes: 2 additions & 37 deletions src/aiida_quantumespresso/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,13 @@
# -*- coding: utf-8 -*-
# pylint: disable=wrong-import-position
"""Module for the command line interface."""
from aiida.cmdline.groups import VerdiCommandGroup
from aiida.cmdline.params import options, types
import click


class VerbosityGroup(click.Group):
"""Custom command group that automatically adds the ``VERBOSITY`` option to all subcommands."""

@staticmethod
def add_verbosity_option(cmd):
"""Apply the ``verbosity`` option to the command, which is common to all ``verdi`` commands."""
if 'verbosity' not in [param.name for param in cmd.params]:
cmd = options.VERBOSITY()(cmd)

return cmd

def group(self, *args, **kwargs):
"""Ensure that sub command groups use the same class but do not override an explicitly set value."""
kwargs.setdefault('cls', self.__class__)
return super().group(*args, **kwargs)

def get_command(self, ctx, cmd_name):
"""Return the command that corresponds to the requested ``cmd_name``.
This method is overridden from the base class in order to automatically add the verbosity option.
Note that if the command is not found and ``resilient_parsing`` is set to True on the context, then the latter
feature is disabled because most likely we are operating in tab-completion mode.
"""
cmd = super().get_command(ctx, cmd_name)

if cmd is not None:
return self.add_verbosity_option(cmd)

if ctx.resilient_parsing:
return None

return ctx.fail(f'`{cmd_name}` is not a {self.name} command.')


@click.group('aiida-quantumespresso', context_settings={'help_option_names': ['-h', '--help']})
@click.group('aiida-quantumespresso', cls=VerdiCommandGroup, context_settings={'help_option_names': ['-h', '--help']})
@options.PROFILE(type=types.ProfileParamType(load_profile=True), expose_value=False)
@options.VERBOSITY()
def cmd_root():
"""CLI for the `aiida-quantumespresso` plugin."""

Expand Down
50 changes: 36 additions & 14 deletions tests/cli/test_commands.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,46 @@
# -*- coding: utf-8 -*-
"""Tests for CLI commands."""
from click import Context, Group
from __future__ import annotations

from aiida_quantumespresso.cli import cmd_root
import subprocess

from aiida_pseudo.cli import cmd_root
import click
import pytest

def test_commands():
"""Test that all commands in ``cmd_root`` are reachable and can print the help message.

This doesn't guarantee that the command works but at least that it can be successfully called and there are no
import errors or other basic problems.
def recurse_commands(command: click.Command, parents: list[str] = None):
"""Recursively return all subcommands that are part of ``command``.
:param command: The click command to start with.
:param parents: A list of strings that represent the parent commands leading up to the current command.
:returns: A list of strings denoting the full path to the current command.
"""
if isinstance(command, click.Group):
for command_name in command.commands:
subcommand = command.get_command(None, command_name)
if parents is not None:
subparents = parents + [command.name]
else:
subparents = [command.name]
yield from recurse_commands(subcommand, subparents)

if parents is not None:
yield parents + [command.name]
else:
yield [command.name]

def recursively_print_help(ctx):
assert isinstance(ctx.get_help(), str)

if isinstance(ctx.command, Group):
for subcommand in ctx.command.commands.values():
ctx.command = subcommand
recursively_print_help(ctx)
@pytest.mark.parametrize('command', recurse_commands(cmd_root))
@pytest.mark.parametrize('help_option', ('--help', '-h'))
def test_commands_help_option(command, help_option):
"""Test the help options for all subcommands of the CLI.
ctx = Context(cmd_root)
recursively_print_help(ctx)
The usage of ``subprocess.run`` is on purpose because using :meth:`click.Context.invoke`, which is used by the
``run_cli_command`` fixture that should usually be used in testing CLI commands, does not behave exactly the same
compared to a direct invocation on the command line. The invocation through ``invoke`` does not go through all the
parent commands and so might not get all the necessary initializations.
"""
result = subprocess.run(command + [help_option], check=False, capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert 'Usage:' in result.stdout
4 changes: 3 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from collections.abc import Mapping
import io
import os
import pathlib
import shutil
import tempfile

Expand Down Expand Up @@ -133,7 +134,8 @@ def sssp(aiida_profile, generate_upf_data):
continue

upf = generate_upf_data(element)
filename = os.path.join(dirpath, f'{element}.upf')
dirpath = pathlib.Path(dirpath)
filename = dirpath / f'{element}.upf'

with open(filename, 'w+b') as handle:
with upf.open(mode='rb') as source:
Expand Down

0 comments on commit 6f86390

Please sign in to comment.