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

First implementation of CLI #68

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ classifiers = [
keywords = ['aiida', 'workflows']
requires-python = '>=3.8'
dependencies = [
"aiida-core>=2.2.2,<3.0.0",
"aiida-quantumespresso>=4.3.0",
"aiida-phonopy>=1.1.3",
"phonopy>=2.19.0,<3.0.0",
'aiida-core>=2.2.2,<3.0.0',
'aiida-quantumespresso>=4.3.0',
'aiida-phonopy>=1.1.3',
'phonopy>=2.19.0,<3.0.0',
'click~=8.0'
]

[project.urls]
Expand All @@ -54,13 +55,17 @@ docs = [
'sphinx~=6.2.1',
'sphinx-copybutton~=0.5.2',
'sphinx-book-theme~=1.0.1',
'sphinx-click~=4.4.0',
'sphinx-design~=0.4.1',
'sphinxcontrib-details-directive~=0.1.0',
'sphinx-autoapi~=3.0.0',
'myst_parser~=1.0.0',
'sphinx-togglebutton',
]

[project.scripts]
aiida-vibroscopy = 'aiida_vibroscopy.cli:cmd_root'

[project.entry-points.'aiida.data']
"vibroscopy.fp" = "aiida_vibroscopy.data.vibro_fp:VibrationalFrozenPhononData"
"vibroscopy.vibrational" = "aiida_vibroscopy.data.vibro_lr:VibrationalData"
Expand Down
15 changes: 15 additions & 0 deletions src/aiida_vibroscopy/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# -*- 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


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


from .workflows import cmd_launch
3 changes: 3 additions & 0 deletions src/aiida_vibroscopy/cli/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
"""Utilities for the command line interface."""
# pylint: disable=cyclic-import,unused-import,wrong-import-position,import-error
44 changes: 44 additions & 0 deletions src/aiida_vibroscopy/cli/utils/defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""Module with utitlies for the CLI to generate default values."""


def get_structure():
"""Return a `StructureData` representing bulk silicon.

The database will first be queried for the existence of a bulk silicon crystal. If this is not the case, one is
created and stored. This function should be used as a default for CLI options that require a `StructureData` node.
This way new users can launch the command without having to construct or import a structure first. This is the
reason that we hardcode a bulk silicon crystal to be returned. More flexibility is not required for this purpose.

:return: a `StructureData` representing bulk silicon
"""
from aiida.orm import QueryBuilder, StructureData
from ase.spacegroup import crystal

# Filters that will match any elemental Silicon structure with 2 or less sites in total
filters = {
'attributes.sites': {
'of_length': 2
},
'attributes.kinds': {
'of_length': 1
},
'attributes.kinds.0.symbols.0': 'Si'
}

builder = QueryBuilder().append(StructureData, filters=filters)
structure = builder.first(flat=True)

if not structure:
alat = 5.43
ase_structure = crystal(
'Si',
[(0, 0, 0)],
spacegroup=227,
cellpar=[alat, alat, alat, 90, 90, 90],
primitive_cell=True,
)
structure = StructureData(ase=ase_structure)
structure.store()

return structure.uuid
42 changes: 42 additions & 0 deletions src/aiida_vibroscopy/cli/utils/display.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""Module with display utitlies for the CLI."""
import os

import click


def echo_process_results(node):
"""Display a formatted table of the outputs registered for the given process node.

:param node: the `ProcessNode` of a terminated process
"""
from aiida.common.links import LinkType

class_name = node.process_class.__name__
outputs = node.base.links.get_outgoing(link_type=(LinkType.CREATE, LinkType.RETURN)).all()

if hasattr(node, 'dry_run_info'):
# It is a dry-run: get the information and print it
rel_path = os.path.relpath(node.dry_run_info['folder'])
click.echo(f"-> Files created in folder '{rel_path}'")
click.echo(f"-> Submission script filename: '{node.dry_run_info['script_filename']}'")
return

if node.is_finished and node.exit_message:
state = f'{node.process_state.value} [{node.exit_status}] `{node.exit_message}`'
elif node.is_finished:
state = f'{node.process_state.value} [{node.exit_status}]'
else:
state = node.process_state.value

click.echo(f'{class_name}<{node.pk}> terminated with state: {state}')

if not outputs:
click.echo(f'{class_name}<{node.pk}> registered no outputs')
return

click.echo(f"\n{'Output link':25s} Node pk and type")
click.echo(f"{'-' * 60}")

for triple in sorted(outputs, key=lambda triple: triple.link_label):
click.echo(f'{triple.link_label:25s} {triple.node.__class__.__name__}<{triple.node.pk}> ')
43 changes: 43 additions & 0 deletions src/aiida_vibroscopy/cli/utils/launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
"""Module with launch utitlies for the CLI."""
import click

from .display import echo_process_results


def launch_process(process, daemon, **inputs):
"""Launch a process with the given inputs.

If not sent to the daemon, the results will be displayed after the calculation finishes.

:param process: the process class
:param daemon: boolean, if True will submit to the daemon instead of running in current interpreter
:param inputs: inputs for the process
"""
from aiida.engine import Process, ProcessBuilder, launch

if isinstance(process, ProcessBuilder):
process_name = process.process_class.__name__
elif issubclass(process, Process):
process_name = process.__name__
else:
raise TypeError(f'invalid type for process: {process}')

if daemon:
node = launch.submit(process, **inputs)
click.echo(
f"""
Submitted {process_name} to the daemon.
Information of the launched process:
* PK :\t{node.pk}>
* UUID:\t{node.uuid}>
Record this information for later usage, or put this node in a Group.
"""
)
else:
if inputs.get('metadata', {}).get('dry_run', False):
click.echo(f'Running a dry run for {process_name}...')
else:
click.echo(f'Running a {process_name}...')
_, node = launch.run_get_node(process, **inputs)
echo_process_results(node)
126 changes: 126 additions & 0 deletions src/aiida_vibroscopy/cli/utils/options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
"""Pre-defined overridable options for commonly used command line interface parameters."""
# pylint: disable=too-few-public-methods,import-error
from aiida.cmdline.params import types
from aiida.cmdline.params.options import OverridableOption
from aiida.cmdline.utils import decorators
from aiida.common import exceptions
import click

from . import validate


class PseudoFamilyType(types.GroupParamType):
"""Subclass of `GroupParamType` in order to be able to print warning with instructions."""

def __init__(self, pseudo_types=None, **kwargs):
"""Construct a new instance."""
super().__init__(**kwargs)
self._pseudo_types = pseudo_types

@decorators.with_dbenv()
def convert(self, value, param, ctx):
"""Convert the value to actual pseudo family instance."""
try:
group = super().convert(value, param, ctx)
except click.BadParameter:
try:
from aiida.orm import load_group
load_group(value)
except exceptions.NotExistent: # pylint: disable=try-except-raise
raise

raise click.BadParameter( # pylint: disable=raise-missing-from
f'`{value}` is not of a supported pseudopotential family type.\nTo install a supported '
'pseudofamily, use the `aiida-pseudo` plugin. See the following link for detailed instructions:\n\n'
' https://github.com/aiidateam/aiida-quantumespresso#pseudopotentials'
)

if self._pseudo_types is not None and group.pseudo_type not in self._pseudo_types:
pseudo_types = ', '.join(self._pseudo_types)
raise click.BadParameter(
f'family `{group.label}` contains pseudopotentials of the wrong type `{group.pseudo_type}`.\nOnly the '
f'following types are supported: {pseudo_types}'
)

return group


PW_CODE = OverridableOption(
'--pw',
'pw_code',
type=types.CodeParamType(entry_point='quantumespresso.pw'),
required=True,
help='The code to use for the pw.x executable.'
)

PHONOPY_CODE = OverridableOption(
'--phonopy',
'phonopy_code',
type=types.CodeParamType(entry_point='phonopy.phonopy'),
required=True,
help='The code to use for the phonopy executable.'
)

PSEUDO_FAMILY = OverridableOption(
'-F',
'--pseudo-family',
type=PseudoFamilyType(sub_classes=('aiida.groups:pseudo.family',), pseudo_types=('pseudo.upf',)),
required=False,
help='Select a pseudopotential family, identified by its label.'
)

STRUCTURE = OverridableOption(
'-S',
'--structure',
type=types.DataParamType(sub_classes=('aiida.data:core.structure',)),
help='A StructureData node identified by its ID or UUID.'
)

KPOINTS_MESH = OverridableOption(
'-k',
'--kpoints-mesh',
'kpoints_mesh',
nargs=6,
type=click.Tuple([int, int, int, float, float, float]),
show_default=True,
callback=validate.validate_kpoints_mesh,
help='The number of points in the kpoint mesh along each basis vector and the offset. '
'Example: `-k 2 2 2 0 0 0`. Specify `0.5 0.5 0.5` for the offset if you want to result '
'in the equivalent Quantum ESPRESSO pw.x `1 1 1` shift.'
)

PARENT_FOLDER = OverridableOption(
'-P',
'--parent-folder',
'parent_folder',
type=types.DataParamType(sub_classes=('aiida.data:core.remote',)),
show_default=True,
required=False,
help='A parent remote folder node identified by its ID or UUID.'
)

DAEMON = OverridableOption(
'-D',
'--daemon',
is_flag=True,
default=True,
show_default=True,
help='Submit the process to the daemon instead of running it and waiting for it to finish.'
)

OVERRIDES = OverridableOption(
'-o',
'--overrides',
type=click.File('r'),
required=False,
help='The filename or filepath containing the overrides, in YAML format.'
)

PROTOCOL = OverridableOption(
'-p',
'--protocol',
type=click.STRING,
required=False,
help='Select the protocol that defines the accuracy of the calculation.'
)
34 changes: 34 additions & 0 deletions src/aiida_vibroscopy/cli/utils/validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
"""Utility functions for validation of command line interface parameter inputs."""
from aiida.cmdline.utils import decorators
import click


@decorators.with_dbenv()
def validate_kpoints_mesh(ctx, param, value):
"""Command line option validator for a kpoints mesh tuple.

The value should be a tuple of three positive integers out of which a KpointsData object will be created with a mesh
equal to the tuple.

:param ctx: internal context of the click.command
:param param: the click Parameter, i.e. either the Option or Argument to which the validator is hooked up
:param value: a tuple of three positive integers
:returns: a KpointsData instance
"""
# pylint: disable=unused-argument
from aiida.orm import KpointsData

if not value:
return None

if any(not isinstance(integer, int) for integer in value[:3]) or any(int(i) <= 0 for i in value[:3]):
raise click.BadParameter('all values of the tuple should be positive greater than zero integers')

try:
kpoints = KpointsData()
kpoints.set_kpoints_mesh(value[:3], value[3:])
except ValueError as exception:
raise click.BadParameter(f'failed to create a KpointsData mesh out of {value}\n{exception}')

return kpoints
16 changes: 16 additions & 0 deletions src/aiida_vibroscopy/cli/workflows/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# pylint: disable=cyclic-import,reimported,unused-import,wrong-import-position,import-error
"""Module with CLI commands for the various work chain implementations."""
from .. import cmd_root


@cmd_root.group('launch')
def cmd_launch():
"""Launch workflows."""


from .dielectric.base import launch_workflow
from .phonons.base import launch_workflow
from .phonons.harmonic import launch_workflow
# Import the sub commands to register them with the CLI
from .spectra.iraman import launch_workflow
3 changes: 3 additions & 0 deletions src/aiida_vibroscopy/cli/workflows/dielectric/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
# pylint: disable=cyclic-import,unused-import,wrong-import-position,import-error
"""Module with CLI commands for various dielectric workflows."""
Loading
Loading