diff --git a/docs/source/reference/command_line.rst b/docs/source/reference/command_line.rst index 90403221bb..2f9a10e12e 100644 --- a/docs/source/reference/command_line.rst +++ b/docs/source/reference/command_line.rst @@ -289,6 +289,7 @@ Below is a list with all available subcommands. extras Show the extras of one or more nodes. graph Create visual representations of the provenance graph. label View or set the label of one or more nodes. + list Query all nodes with optional filtering and ordering. rehash Recompute the hash for nodes in the database. repo Inspect the content of a node repository folder. show Show generic information on one or more nodes. diff --git a/src/aiida/cmdline/commands/cmd_node.py b/src/aiida/cmdline/commands/cmd_node.py index af415e068d..bfb5557e0e 100644 --- a/src/aiida/cmdline/commands/cmd_node.py +++ b/src/aiida/cmdline/commands/cmd_node.py @@ -7,6 +7,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """`verdi node` command.""" +import datetime import pathlib import click @@ -25,6 +26,46 @@ def verdi_node(): """Inspect, create and manage nodes.""" +@verdi_node.command('list') +@click.option('-e', '--entry-point', type=str, required=False) +@click.option( + '--subclassing/--no-subclassing', + default=True, + help='Pass `--no-subclassing` to disable matching subclasses of the specified `--entry-point`.', +) +@options.PROJECT( + type=click.Choice( + ('id', 'uuid', 'node_type', 'process_type', 'label', 'description', 'ctime', 'mtime', 'attributes', 'extras') + ), + default=('id', 'uuid', 'node_type'), +) +@options.PAST_DAYS() +@options.ORDER_BY() +@options.ORDER_DIRECTION() +@options.LIMIT() +@options.RAW() +def node_list(entry_point, subclassing, project, past_days, order_by, order_dir, limit, raw): + """Query all nodes with optional filtering and ordering.""" + from aiida.orm import Node + from aiida.plugins.factories import DataFactory + + node_class = DataFactory(entry_point) if entry_point else Node + + if past_days is not None: + filters = {'ctime': {'>': timezone.now() - datetime.timedelta(days=past_days)}} + else: + filters = {} + + query = node_class.collection.query( + filters, + project=list(project), + limit=limit, + subclassing=subclassing, + order_by=[{order_by: order_dir}], + ) + echo_tabulate(query.all(), headers=project if not raw else [], tablefmt='plain' if raw else None) + + @verdi_node.group('repo') def verdi_node_repo(): """Inspect the content of a node repository folder.""" diff --git a/src/aiida/orm/entities.py b/src/aiida/orm/entities.py index 46e156672b..e25c8c59be 100644 --- a/src/aiida/orm/entities.py +++ b/src/aiida/orm/entities.py @@ -12,7 +12,7 @@ import abc from enum import Enum from functools import lru_cache -from typing import TYPE_CHECKING, Any, Generic, List, Optional, Type, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, List, Optional, Type, TypeVar, Union, cast from plumpy.base.utils import call_with_super_check, super_check @@ -99,15 +99,19 @@ def query( self, filters: Optional['FilterType'] = None, order_by: Optional['OrderByType'] = None, + project: Optional[Union[list[str], str]] = None, limit: Optional[int] = None, offset: Optional[int] = None, + subclassing: bool = True, ) -> 'QueryBuilder': """Get a query builder for the objects of this collection. :param filters: the keyword value pair filters to match :param order_by: a list of (key, direction) pairs specifying the sort order + :param project: Optional projections. :param limit: the maximum number of results to return :param offset: number of initial results to be skipped + :param subclassing: whether to match subclasses of the type as well. """ from . import querybuilder @@ -115,7 +119,7 @@ def query( order_by = {self.entity_type: order_by} if order_by else {} query = querybuilder.QueryBuilder(backend=self._backend, limit=limit, offset=offset) - query.append(self.entity_type, project='*', filters=filters) + query.append(self.entity_type, project=project, filters=filters, subclassing=subclassing) query.order_by([order_by]) return query diff --git a/tests/cmdline/commands/test_node.py b/tests/cmdline/commands/test_node.py index a3bbf2e0a1..3d2a4cdafb 100644 --- a/tests/cmdline/commands/test_node.py +++ b/tests/cmdline/commands/test_node.py @@ -7,6 +7,7 @@ # For further information please visit http://www.aiida.net # ########################################################################### """Tests for verdi node""" +import datetime import errno import gzip import io @@ -17,6 +18,7 @@ from aiida import orm from aiida.cmdline.commands import cmd_node from aiida.cmdline.utils.echo import ExitCode +from aiida.common import timezone def get_result_lines(result): @@ -589,3 +591,46 @@ def test_node_delete_basics(run_cli_command, options): def test_node_delete_missing_pk(run_cli_command): """Check that no exception is raised when a non-existent pk is given (just warns).""" run_cli_command(cmd_node.node_delete, ['999']) + + +@pytest.fixture(scope='class') +def create_nodes_verdi_node_list(aiida_profile_clean_class): + return ( + orm.Data().store(), + orm.Int(0).store(), + orm.Int(1).store(), + orm.Int(2).store(), + orm.ArrayData().store(), + orm.KpointsData().store(), + orm.WorkflowNode(ctime=timezone.now() - datetime.timedelta(days=3)).store(), + ) + + +@pytest.mark.usefixtures('create_nodes_verdi_node_list') +class TestNodeList: + """Tests for the ``verdi node rehash`` command.""" + + @pytest.mark.parametrize( + 'options, expected_nodes', + ( + ([], [6, 0, 1, 2, 3, 4, 5]), + (['-e', 'core.int'], [1, 2, 3]), + (['-e', 'core.int', '--limit', '1'], [1]), + (['-e', 'core.int', '--order-direction', 'desc'], [3, 2, 1]), + (['-e', 'core.int', '--order-by', 'id'], [1, 2, 3]), + (['-e', 'core.array', '--no-subclassing'], [4]), + (['-e', 'core.int', '-P', 'uuid'], [1, 2, 3]), + (['-p', '1'], [0, 1, 2, 3, 4, 5]), + ), + ) + def test_node_list(self, run_cli_command, options, expected_nodes): + """Test the ``verdi node list`` command.""" + nodes = orm.QueryBuilder().append(orm.Node).order_by({orm.Node: ['id']}).all(flat=True) + + if set(['-P', 'uuid']).issubset(set(options)): + expected_projections = [nodes[index].uuid for index in expected_nodes] + else: + expected_projections = [str(nodes[index].pk) for index in expected_nodes] + + result = run_cli_command(cmd_node.node_list, ['--project', 'id', '--raw'] + options) + assert result.output.strip() == '\n'.join(expected_projections) diff --git a/tests/cmdline/commands/test_process.py b/tests/cmdline/commands/test_process.py index 0812190b8b..3ab717f20f 100644 --- a/tests/cmdline/commands/test_process.py +++ b/tests/cmdline/commands/test_process.py @@ -46,6 +46,7 @@ def test_list_non_raw(self, run_cli_command): assert 'Total results:' in result.output assert 'Last time an entry changed state' in result.output + @pytest.mark.usefixtures('aiida_profile_clean') def test_list(self, run_cli_command): """Test the list command.""" calcs = []