diff --git a/docs/source/reference/command_line.rst b/docs/source/reference/command_line.rst index 3982f75c53..c42c331ac9 100644 --- a/docs/source/reference/command_line.rst +++ b/docs/source/reference/command_line.rst @@ -220,7 +220,7 @@ Below is a list with all available subcommands. add-nodes Add nodes to a group. copy Duplicate a group. create Create an empty group with a given label. - delete Delete a group and (optionally) the nodes it contains. + delete Delete groups and (optionally) the nodes they contain. description Change the description of a group. list Show a list of existing groups. move-nodes Move the specified NODES from one group to another. diff --git a/src/aiida/cmdline/commands/cmd_group.py b/src/aiida/cmdline/commands/cmd_group.py index 092c21b8f0..4f340b386a 100644 --- a/src/aiida/cmdline/commands/cmd_group.py +++ b/src/aiida/cmdline/commands/cmd_group.py @@ -145,7 +145,33 @@ def group_move_nodes(source_group, target_group, force, nodes, all_entries): @verdi_group.command('delete') -@arguments.GROUP() +@arguments.GROUPS() +@options.ALL_USERS(help='Filter and delete groups for all users, rather than only for the current user.') +@options.USER(help='Add a filter to delete groups belonging to a specific user.') +@options.TYPE_STRING(help='Filter to only include groups of this type string.') +@options.PAST_DAYS(help='Add a filter to delete only groups created in the past N days.', default=None) +@click.option( + '-s', + '--startswith', + type=click.STRING, + default=None, + help='Add a filter to delete only groups for which the label begins with STRING.', +) +@click.option( + '-e', + '--endswith', + type=click.STRING, + default=None, + help='Add a filter to delete only groups for which the label ends with STRING.', +) +@click.option( + '-c', + '--contains', + type=click.STRING, + default=None, + help='Add a filter to delete only groups for which the label contains STRING.', +) +@options.NODE(help='Delete only the groups that contain a node.') @options.FORCE() @click.option( '--delete-nodes', is_flag=True, default=False, help='Delete all nodes in the group along with the group itself.' @@ -153,33 +179,138 @@ def group_move_nodes(source_group, target_group, force, nodes, all_entries): @options.graph_traversal_rules(GraphTraversalRules.DELETE.value) @options.DRY_RUN() @with_dbenv() -def group_delete(group, delete_nodes, dry_run, force, **traversal_rules): - """Delete a group and (optionally) the nodes it contains.""" +def group_delete( + groups, + delete_nodes, + dry_run, + force, + all_users, + user, + type_string, + past_days, + startswith, + endswith, + contains, + node, + **traversal_rules, +): + """Delete groups and (optionally) the nodes they contain.""" + from tabulate import tabulate + from aiida import orm from aiida.tools import delete_group_nodes - if not (force or dry_run): - click.confirm(f'Are you sure you want to delete {group}?', abort=True) - elif dry_run: - echo.echo_report(f'Would have deleted {group}.') + filters_provided = any( + [all_users or user or past_days or startswith or endswith or contains or node or type_string] + ) + + if groups and filters_provided: + echo.echo_critical('Cannot specify both GROUPS and any of the other filters.') + + if not groups and filters_provided: + import datetime + + from aiida.common import timezone + from aiida.common.escaping import escape_for_sql_like + + builder = orm.QueryBuilder() + filters = {} - if delete_nodes: + # Note: we could have set 'core' as a default value for type_string, + # but for the sake of uniform interface, we decided to keep the default value of None. + # Otherwise `verdi group delete 123 -T core` would have worked, but we say + # 'Cannot specify both GROUPS and any of the other filters'. + if type_string is None: + type_string = 'core' - def _dry_run_callback(pks): - if not pks or force: - return False - echo.echo_warning(f'YOU ARE ABOUT TO DELETE {len(pks)} NODES! THIS CANNOT BE UNDONE!') - return not click.confirm('Do you want to continue?', abort=True) + if '%' in type_string or '_' in type_string: + filters['type_string'] = {'like': type_string} + else: + filters['type_string'] = type_string + + # Creation time + if past_days: + filters['time'] = {'>': timezone.now() - datetime.timedelta(days=past_days)} + + # Query for specific group labels + filters['or'] = [] + if startswith: + filters['or'].append({'label': {'like': f'{escape_for_sql_like(startswith)}%'}}) + if endswith: + filters['or'].append({'label': {'like': f'%{escape_for_sql_like(endswith)}'}}) + if contains: + filters['or'].append({'label': {'like': f'%{escape_for_sql_like(contains)}%'}}) + + builder.append(orm.Group, filters=filters, tag='group', project='*') + + # Query groups that belong to specific user + if user: + user_email = user.email + else: + # By default: only groups of this user + user_email = orm.User.collection.get_default().email - _, nodes_deleted = delete_group_nodes([group.pk], dry_run=dry_run or _dry_run_callback, **traversal_rules) - if not nodes_deleted: - # don't delete the group if the nodes were not deleted + # Query groups that belong to all users + if not all_users: + builder.append(orm.User, filters={'email': user_email}, with_group='group') + + # Query groups that contain a particular node + if node: + builder.append(orm.Node, filters={'id': node.pk}, with_group='group') + + groups = builder.all(flat=True) + if not groups: + echo.echo_report('No groups found matching the specified criteria.') return - if not dry_run: + elif not groups and not filters_provided: + echo.echo_report('Nothing happened. Please specify at least one group or provide filters to query groups.') + return + + projection_lambdas = { + 'pk': lambda group: str(group.pk), + 'label': lambda group: group.label, + 'type_string': lambda group: group.type_string, + 'count': lambda group: group.count(), + 'user': lambda group: group.user.email.strip(), + 'description': lambda group: group.description, + } + + table = [] + projection_header = ['PK', 'Label', 'Type string', 'User'] + projection_fields = ['pk', 'label', 'type_string', 'user'] + for group in groups: + table.append([projection_lambdas[field](group) for field in projection_fields]) + + if not (force or dry_run): + echo.echo_report('The following groups will be deleted:') + echo.echo(tabulate(table, headers=projection_header)) + click.confirm('Are you sure you want to continue?', abort=True) + elif dry_run: + echo.echo_report('Would have deleted:') + echo.echo(tabulate(table, headers=projection_header)) + + for group in groups: group_str = str(group) - orm.Group.collection.delete(group.pk) - echo.echo_success(f'{group_str} deleted.') + + if delete_nodes: + + def _dry_run_callback(pks): + if not pks or force: + return False + echo.echo_warning( + f'YOU ARE ABOUT TO DELETE {len(pks)} NODES ASSOCIATED WITH {group_str}! THIS CANNOT BE UNDONE!' + ) + return not click.confirm('Do you want to continue?', abort=True) + + _, nodes_deleted = delete_group_nodes([group.pk], dry_run=dry_run or _dry_run_callback, **traversal_rules) + if not nodes_deleted: + # don't delete the group if the nodes were not deleted + return + + if not dry_run: + orm.Group.collection.delete(group.pk) + echo.echo_success(f'{group_str} deleted.') @verdi_group.command('relabel') @@ -273,7 +404,7 @@ def group_show(group, raw, limit, uuid): @options.ALL_USERS(help='Show groups for all users, rather than only for the current user.') @options.USER(help='Add a filter to show only groups belonging to a specific user.') @options.ALL(help='Show groups of all types.') -@options.TYPE_STRING() +@options.TYPE_STRING(default='core', help='Filter to only include groups of this type string.') @click.option( '-d', '--with-description', 'with_description', is_flag=True, default=False, help='Show also the group description.' ) @@ -302,7 +433,7 @@ def group_show(group, raw, limit, uuid): ) @options.ORDER_BY(type=click.Choice(['id', 'label', 'ctime']), default='label') @options.ORDER_DIRECTION() -@options.NODE(help='Show only the groups that contain the node.') +@options.NODE(help='Show only the groups that contain this node.') @with_dbenv() def group_list( all_users, @@ -331,12 +462,6 @@ def group_list( builder = orm.QueryBuilder() filters = {} - # Have to specify the default for `type_string` here instead of directly in the option otherwise it will always - # raise above if the user specifies just the `--group-type` option. Once that option is removed, the default can - # be moved to the option itself. - if type_string is None: - type_string = 'core' - if not all_entries: if '%' in type_string or '_' in type_string: filters['type_string'] = {'like': type_string} @@ -367,11 +492,11 @@ def group_list( # Query groups that belong to all users if not all_users: - builder.append(orm.User, filters={'email': {'==': user_email}}, with_group='group') + builder.append(orm.User, filters={'email': user_email}, with_group='group') # Query groups that contain a particular node if node: - builder.append(orm.Node, filters={'id': {'==': node.pk}}, with_group='group') + builder.append(orm.Node, filters={'id': node.pk}, with_group='group') builder.order_by({orm.Group: {order_by: order_dir}}) diff --git a/tests/cmdline/commands/test_group.py b/tests/cmdline/commands/test_group.py index b88bf6db91..fa319276f1 100644 --- a/tests/cmdline/commands/test_group.py +++ b/tests/cmdline/commands/test_group.py @@ -109,49 +109,218 @@ def test_delete(self, run_cli_command): orm.Group(label='group_test_delete_01').store() orm.Group(label='group_test_delete_02').store() orm.Group(label='group_test_delete_03').store() + do_not_delete_user = orm.User(email='user0@example.com') + do_not_delete_group = orm.Group(label='do_not_delete_group', user=do_not_delete_user).store() + do_not_delete_node = orm.CalculationNode().store() + do_not_delete_group.add_nodes(do_not_delete_node) + do_not_delete_user.store() - # dry run - result = run_cli_command(cmd_group.group_delete, ['--dry-run', 'group_test_delete_01'], use_subprocess=True) + # 0) do nothing if no groups or no filters are passed + result = run_cli_command(cmd_group.group_delete, ['--force']) + assert 'Nothing happened' in result.output + + # 1) dry run + result = run_cli_command( + cmd_group.group_delete, + ['--dry-run', 'group_test_delete_01'], + ) orm.load_group(label='group_test_delete_01') - result = run_cli_command(cmd_group.group_delete, ['--force', 'group_test_delete_01'], use_subprocess=True) + # 2) Delete group, basic test + result = run_cli_command( + cmd_group.group_delete, + ['--force', 'group_test_delete_01'], + ) + assert 'do_not_delete_group' not in result.output - # Verify that removed group is not present in list - result = run_cli_command(cmd_group.group_list, use_subprocess=True) + result = run_cli_command( + cmd_group.group_list, + ) assert 'group_test_delete_01' not in result.output + # 3) Add some nodes and then use `verdi group delete` to delete a group that contains nodes node_01 = orm.CalculationNode().store() node_02 = orm.CalculationNode().store() node_pks = {node_01.pk, node_02.pk} - # Add some nodes and then use `verdi group delete` to delete a group that contains nodes group = orm.load_group(label='group_test_delete_02') group.add_nodes([node_01, node_02]) assert group.count() == 2 - result = run_cli_command(cmd_group.group_delete, ['--force', 'group_test_delete_02'], use_subprocess=True) + result = run_cli_command( + cmd_group.group_delete, + ['--force', 'group_test_delete_02'], + ) with pytest.raises(exceptions.NotExistent): orm.load_group(label='group_test_delete_02') - # check nodes still exist for pk in node_pks: orm.load_node(pk) - # delete the group and the nodes it contains + # 4) Delete the group and the nodes it contains group = orm.load_group(label='group_test_delete_03') group.add_nodes([node_01, node_02]) result = run_cli_command( - cmd_group.group_delete, ['--force', '--delete-nodes', 'group_test_delete_03'], use_subprocess=True + cmd_group.group_delete, + ['--force', '--delete-nodes', 'group_test_delete_03'], ) - # check group and nodes no longer exist with pytest.raises(exceptions.NotExistent): orm.load_group(label='group_test_delete_03') for pk in node_pks: with pytest.raises(exceptions.NotExistent): orm.load_node(pk) + # 5) Should delete an empty group even if --delete-nodes option is passed + group = orm.Group(label='group_test_delete_04').store() + result = run_cli_command(cmd_group.group_delete, ['--force', '--delete-nodes', 'group_test_delete_04']) + with pytest.raises(exceptions.NotExistent): + orm.load_group(label='group_test_delete_04') + + # 6) Should raise if a group does not exist + result = run_cli_command(cmd_group.group_delete, ['--force', 'non_existent_group'], raises=True) + assert b'no Group found with LABEL' in result.stderr_bytes + + # 7) Should delete multiple groups + orm.Group(label='group_test_delete_05').store() + orm.Group(label='group_test_delete_06').store() + result = run_cli_command( + cmd_group.group_delete, + ['--force', 'group_test_delete_05', 'group_test_delete_06'], + ) + with pytest.raises(exceptions.NotExistent): + orm.load_group(label='group_test_delete_05') + with pytest.raises(exceptions.NotExistent): + orm.load_group(label='group_test_delete_06') + assert 'do_not_delete_group' not in result.output + + # 8) Should raise if both groups and query options are passed + result = run_cli_command( + cmd_group.group_delete, + ['--force', 'do_not_delete_group', '--all-users'], + raises=True, + ) + assert b'Cannot specify both GROUPS and any of the other filters' in result.stderr_bytes + result = run_cli_command( + cmd_group.group_delete, + ['--force', 'do_not_delete_group', '--user', do_not_delete_user.email], + raises=True, + ) + assert b'Cannot specify both GROUPS and any of the other filters' in result.stderr_bytes + result = run_cli_command( + cmd_group.group_delete, + ['--force', 'do_not_delete_group', '--type-string', 'non_existent'], + raises=True, + ) + assert b'Cannot specify both GROUPS and any of the other filters' in result.stderr_bytes + result = run_cli_command( + cmd_group.group_delete, + ['--force', 'do_not_delete_group', '--past-days', '1'], + raises=True, + ) + assert b'Cannot specify both GROUPS and any of the other filters' in result.stderr_bytes + result = run_cli_command( + cmd_group.group_delete, + ['--force', 'do_not_delete_group', '--startswith', 'non_existent'], + raises=True, + ) + assert b'Cannot specify both GROUPS and any of the other filters' in result.stderr_bytes + result = run_cli_command( + cmd_group.group_delete, + ['--force', 'do_not_delete_group', '--endswith', 'non_existent'], + raises=True, + ) + assert b'Cannot specify both GROUPS and any of the other filters' in result.stderr_bytes + result = run_cli_command( + cmd_group.group_delete, + ['--force', 'do_not_delete_group', '--contains', 'non_existent'], + raises=True, + ) + assert b'Cannot specify both GROUPS and any of the other filters' in result.stderr_bytes + result = run_cli_command( + cmd_group.group_delete, + ['--force', 'do_not_delete_group', '--node', do_not_delete_node.pk], + raises=True, + ) + assert b'Cannot specify both GROUPS and any of the other filters' in result.stderr_bytes + + # 9) --user should delete groups for a specific user + # --all-users should delete groups for all users + user1 = orm.User(email='user1@example.com') + user2 = orm.User(email='user2@example.com') + user3 = orm.User(email='user3@example.com') + user1.store() + user2.store() + user3.store() + + orm.Group(label='group_test_delete_08', user=user1).store() + orm.Group(label='group_test_delete_09', user=user2).store() + orm.Group(label='group_test_delete_10', user=user3).store() + + result = run_cli_command( + cmd_group.group_delete, + ['--force', '--user', user1.email], + ) + with pytest.raises(exceptions.NotExistent): + orm.load_group(label='group_test_delete_08') + assert 'group_test_delete_09' not in result.output + assert 'group_test_delete_10' not in result.output + + result = run_cli_command( + cmd_group.group_delete, + ['--force', '--all-users'], + ) + with pytest.raises(exceptions.NotExistent): + orm.load_group(label='group_test_delete_09') + with pytest.raises(exceptions.NotExistent): + orm.load_group(label='group_test_delete_10') + + # 10) --startswith, --endswith, --contains should delete groups with labels that match the filter + orm.Group(label='START_13').store() + orm.Group(label='14_END').store() + orm.Group(label='contains_SOMETHING_').store() + + result = run_cli_command( + cmd_group.group_delete, + ['--force', '--startswith', 'START'], + ) + with pytest.raises(exceptions.NotExistent): + orm.load_group(label='START_13') + assert '14_END' not in result.output + assert 'contains_SOMETHING_' not in result.output + assert 'do_not_delete_group' not in result.output + + result = run_cli_command( + cmd_group.group_delete, + ['--force', '--endswith', 'END'], + ) + with pytest.raises(exceptions.NotExistent): + orm.load_group(label='14_END') + assert 'contains_SOMETHING_' not in result.output + assert 'do_not_delete_group' not in result.output + + result = run_cli_command( + cmd_group.group_delete, + ['--force', '--contains', 'SOMETHING'], + ) + with pytest.raises(exceptions.NotExistent): + orm.load_group(label='contains_SOMETHING_') + assert 'do_not_delete_group' not in result.output + + # 11) --node should delete only groups that contain a specific node + node = orm.CalculationNode().store() + group = orm.Group(label='group_test_delete_15').store() + group.add_nodes(node) + + result = run_cli_command( + cmd_group.group_delete, + ['--force', '--node', node.uuid], + ) + with pytest.raises(exceptions.NotExistent): + orm.load_group(label='group_test_delete_15') + assert 'do_not_delete_group' not in result.output + def test_show(self, run_cli_command): """Test `verdi group show` command.""" result = run_cli_command(cmd_group.group_show, ['dummygroup1'], use_subprocess=True)