diff --git a/pyproject.toml b/pyproject.toml index de6e464..ffec046 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ keywords = ['aiida', 'pseudopotentials'] requires-python = '>=3.8' dependencies = [ - 'aiida-core~=2.0', + 'aiida-core~=2.1', 'click~=8.0', 'pint~=0.16.1', 'requests~=2.20', diff --git a/src/aiida_pseudo/cli/params/options.py b/src/aiida_pseudo/cli/params/options.py index f321a2f..e45ce4b 100644 --- a/src/aiida_pseudo/cli/params/options.py +++ b/src/aiida_pseudo/cli/params/options.py @@ -1,22 +1,33 @@ # -*- coding: utf-8 -*- """Reusable options for CLI commands.""" +import functools import shutil -from aiida.cmdline.params.options import OverridableOption +from aiida.cmdline.params import options as core_options +from aiida.cmdline.params import types as core_types import click from .types import PseudoPotentialFamilyTypeParam, PseudoPotentialTypeParam, UnitParamType __all__ = ( - 'VERSION', 'FUNCTIONAL', 'RELATIVISTIC', 'PROTOCOL', 'PSEUDO_FORMAT', 'STRINGENCY', 'DEFAULT_STRINGENCY', - 'TRACEBACK', 'FAMILY_TYPE', 'ARCHIVE_FORMAT', 'UNIT', 'DOWNLOAD_ONLY' + 'PROFILE', 'VERBOSITY', 'VERSION', 'FUNCTIONAL', 'RELATIVISTIC', 'PROTOCOL', 'PSEUDO_FORMAT', 'STRINGENCY', + 'DEFAULT_STRINGENCY', 'TRACEBACK', 'FAMILY_TYPE', 'ARCHIVE_FORMAT', 'UNIT', 'DOWNLOAD_ONLY' ) -VERSION = OverridableOption( +PROFILE = functools.partial( + core_options.PROFILE, type=core_types.ProfileParamType(load_profile=True), expose_value=False +) + +# Clone the ``VERBOSITY`` option from ``aiida-core`` so the ``-v`` short flag can be removed, since that overlaps with +# the flag of the ``VERSION`` option of this CLI. +VERBOSITY = core_options.VERBOSITY.clone() +VERBOSITY.args = ('--verbosity',) + +VERSION = core_options.OverridableOption( '-v', '--version', type=click.STRING, required=False, help='Select the version of the installed configuration.' ) -FUNCTIONAL = OverridableOption( +FUNCTIONAL = core_options.OverridableOption( '-x', '--functional', type=click.STRING, @@ -24,7 +35,7 @@ help='Select the functional of the installed configuration.' ) -RELATIVISTIC = OverridableOption( +RELATIVISTIC = core_options.OverridableOption( '-r', '--relativistic', type=click.STRING, @@ -32,11 +43,11 @@ help='Select the type of relativistic effects included in the installed configuration.' ) -PROTOCOL = OverridableOption( +PROTOCOL = core_options.OverridableOption( '-p', '--protocol', type=click.STRING, required=False, help='Select the protocol of the installed configuration.' ) -PSEUDO_FORMAT = OverridableOption( +PSEUDO_FORMAT = core_options.OverridableOption( '-f', '--pseudo-format', type=click.STRING, @@ -44,11 +55,11 @@ help='Select the pseudopotential file format of the installed configuration.' ) -STRINGENCY = OverridableOption( +STRINGENCY = core_options.OverridableOption( '-s', '--stringency', type=click.STRING, required=False, help='Stringency level for the recommended cutoffs.' ) -DEFAULT_STRINGENCY = OverridableOption( +DEFAULT_STRINGENCY = core_options.OverridableOption( '-s', '--default-stringency', type=click.STRING, @@ -56,11 +67,11 @@ help='Select the default stringency level for the installed configuration.' ) -TRACEBACK = OverridableOption( +TRACEBACK = core_options.OverridableOption( '-t', '--traceback', is_flag=True, help='Include the stacktrace if an exception is encountered.' ) -FAMILY_TYPE = OverridableOption( +FAMILY_TYPE = core_options.OverridableOption( '-F', '--family-type', type=PseudoPotentialFamilyTypeParam(), @@ -69,7 +80,7 @@ help='Choose the type of pseudo potential family to create.' ) -PSEUDO_TYPE = OverridableOption( +PSEUDO_TYPE = core_options.OverridableOption( '-P', '--pseudo-type', type=PseudoPotentialTypeParam(), @@ -81,11 +92,11 @@ ) ) -ARCHIVE_FORMAT = OverridableOption( +ARCHIVE_FORMAT = core_options.OverridableOption( '-f', '--archive-format', type=click.Choice([fmt[0] for fmt in shutil.get_archive_formats()]) ) -UNIT = OverridableOption( +UNIT = core_options.OverridableOption( '-u', '--unit', type=UnitParamType(quantity='energy'), @@ -95,7 +106,7 @@ help='Specify the energy unit of the cutoffs. Must be recognized by the ``UnitRegistry`` of the ``pint`` library.' ) -DOWNLOAD_ONLY = OverridableOption( +DOWNLOAD_ONLY = core_options.OverridableOption( '--download-only', is_flag=True, help=( diff --git a/src/aiida_pseudo/cli/root.py b/src/aiida_pseudo/cli/root.py index 9dadcb3..c6ae9f1 100644 --- a/src/aiida_pseudo/cli/root.py +++ b/src/aiida_pseudo/cli/root.py @@ -1,46 +1,29 @@ # -*- coding: utf-8 -*- """Command line interface `aiida-pseudo`.""" -from aiida.cmdline.params import options, types +from aiida.cmdline.groups.verdi import VerdiCommandGroup import click +from .params import options -class VerbosityGroup(click.Group): - """Custom command group that automatically adds the ``VERBOSITY`` option to all subcommands.""" + +class CustomVerdiCommandGroup(VerdiCommandGroup): + """Subclass of :class:`aiida.cmdline.groups.verdi.VerdiCommandGroup` for the CLI. + + This subclass overrides the verbosity option to use a custom one that removes the ``-v`` short version of the option + since that is used by other options in this CLI and so would clash. + """ @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]: + """Apply the ``verbosity`` option to the command, which is common to all subcommands.""" + if cmd is not None and '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-pseudo', context_settings={'help_option_names': ['-h', '--help']}) -@options.PROFILE(type=types.ProfileParamType(load_profile=True), expose_value=False) +@click.group('aiida-pseudo', cls=CustomVerdiCommandGroup, context_settings={'help_option_names': ['-h', '--help']}) @options.VERBOSITY() +@options.PROFILE() def cmd_root(): """CLI for the ``aiida-pseudo`` plugin.""" diff --git a/tests/cli/test_root.py b/tests/cli/test_root.py index 6bdf17c..173dc8e 100644 --- a/tests/cli/test_root.py +++ b/tests/cli/test_root.py @@ -1,12 +1,47 @@ # -*- coding: utf-8 -*- -"""Test the root command of the CLI.""" +"""Tests for CLI commands.""" +from __future__ import annotations + +import subprocess + +import click +import pytest + from aiida_pseudo.cli import cmd_root -def test_root(run_cli_command): - """Test the root command for the CLI is callable.""" - run_cli_command(cmd_root) +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] + + +@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. - for option in ['-h', '--help']: - result = run_cli_command(cmd_root, [option]) - assert cmd_root.__doc__ in result.output + 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