Skip to content

Commit

Permalink
First implementation of CLI
Browse files Browse the repository at this point in the history
  • Loading branch information
bastonero committed Jun 15, 2024
1 parent 3ee9e7c commit 360863e
Show file tree
Hide file tree
Showing 33 changed files with 895 additions and 6 deletions.
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

0 comments on commit 360863e

Please sign in to comment.