Skip to content

Commit 72a6b18

Browse files
authored
CLI: Add filters to verdi group delete. (aiidateam#6556)
This commit copies the behavior of `verdi group list`, simply by setting a filter, one can get rid of all matching groups at once.
1 parent 655da5a commit 72a6b18

File tree

3 files changed

+335
-41
lines changed

3 files changed

+335
-41
lines changed

docs/source/reference/command_line.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ Below is a list with all available subcommands.
220220
add-nodes Add nodes to a group.
221221
copy Duplicate a group.
222222
create Create an empty group with a given label.
223-
delete Delete a group and (optionally) the nodes it contains.
223+
delete Delete groups and (optionally) the nodes they contain.
224224
description Change the description of a group.
225225
list Show a list of existing groups.
226226
move-nodes Move the specified NODES from one group to another.

src/aiida/cmdline/commands/cmd_group.py

Lines changed: 154 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -145,41 +145,172 @@ def group_move_nodes(source_group, target_group, force, nodes, all_entries):
145145

146146

147147
@verdi_group.command('delete')
148-
@arguments.GROUP()
148+
@arguments.GROUPS()
149+
@options.ALL_USERS(help='Filter and delete groups for all users, rather than only for the current user.')
150+
@options.USER(help='Add a filter to delete groups belonging to a specific user.')
151+
@options.TYPE_STRING(help='Filter to only include groups of this type string.')
152+
@options.PAST_DAYS(help='Add a filter to delete only groups created in the past N days.', default=None)
153+
@click.option(
154+
'-s',
155+
'--startswith',
156+
type=click.STRING,
157+
default=None,
158+
help='Add a filter to delete only groups for which the label begins with STRING.',
159+
)
160+
@click.option(
161+
'-e',
162+
'--endswith',
163+
type=click.STRING,
164+
default=None,
165+
help='Add a filter to delete only groups for which the label ends with STRING.',
166+
)
167+
@click.option(
168+
'-c',
169+
'--contains',
170+
type=click.STRING,
171+
default=None,
172+
help='Add a filter to delete only groups for which the label contains STRING.',
173+
)
174+
@options.NODE(help='Delete only the groups that contain a node.')
149175
@options.FORCE()
150176
@click.option(
151177
'--delete-nodes', is_flag=True, default=False, help='Delete all nodes in the group along with the group itself.'
152178
)
153179
@options.graph_traversal_rules(GraphTraversalRules.DELETE.value)
154180
@options.DRY_RUN()
155181
@with_dbenv()
156-
def group_delete(group, delete_nodes, dry_run, force, **traversal_rules):
157-
"""Delete a group and (optionally) the nodes it contains."""
182+
def group_delete(
183+
groups,
184+
delete_nodes,
185+
dry_run,
186+
force,
187+
all_users,
188+
user,
189+
type_string,
190+
past_days,
191+
startswith,
192+
endswith,
193+
contains,
194+
node,
195+
**traversal_rules,
196+
):
197+
"""Delete groups and (optionally) the nodes they contain."""
198+
from tabulate import tabulate
199+
158200
from aiida import orm
159201
from aiida.tools import delete_group_nodes
160202

161-
if not (force or dry_run):
162-
click.confirm(f'Are you sure you want to delete {group}?', abort=True)
163-
elif dry_run:
164-
echo.echo_report(f'Would have deleted {group}.')
203+
filters_provided = any(
204+
[all_users or user or past_days or startswith or endswith or contains or node or type_string]
205+
)
206+
207+
if groups and filters_provided:
208+
echo.echo_critical('Cannot specify both GROUPS and any of the other filters.')
209+
210+
if not groups and filters_provided:
211+
import datetime
212+
213+
from aiida.common import timezone
214+
from aiida.common.escaping import escape_for_sql_like
215+
216+
builder = orm.QueryBuilder()
217+
filters = {}
165218

166-
if delete_nodes:
219+
# Note: we could have set 'core' as a default value for type_string,
220+
# but for the sake of uniform interface, we decided to keep the default value of None.
221+
# Otherwise `verdi group delete 123 -T core` would have worked, but we say
222+
# 'Cannot specify both GROUPS and any of the other filters'.
223+
if type_string is None:
224+
type_string = 'core'
167225

168-
def _dry_run_callback(pks):
169-
if not pks or force:
170-
return False
171-
echo.echo_warning(f'YOU ARE ABOUT TO DELETE {len(pks)} NODES! THIS CANNOT BE UNDONE!')
172-
return not click.confirm('Do you want to continue?', abort=True)
226+
if '%' in type_string or '_' in type_string:
227+
filters['type_string'] = {'like': type_string}
228+
else:
229+
filters['type_string'] = type_string
230+
231+
# Creation time
232+
if past_days:
233+
filters['time'] = {'>': timezone.now() - datetime.timedelta(days=past_days)}
234+
235+
# Query for specific group labels
236+
filters['or'] = []
237+
if startswith:
238+
filters['or'].append({'label': {'like': f'{escape_for_sql_like(startswith)}%'}})
239+
if endswith:
240+
filters['or'].append({'label': {'like': f'%{escape_for_sql_like(endswith)}'}})
241+
if contains:
242+
filters['or'].append({'label': {'like': f'%{escape_for_sql_like(contains)}%'}})
243+
244+
builder.append(orm.Group, filters=filters, tag='group', project='*')
245+
246+
# Query groups that belong to specific user
247+
if user:
248+
user_email = user.email
249+
else:
250+
# By default: only groups of this user
251+
user_email = orm.User.collection.get_default().email
173252

174-
_, nodes_deleted = delete_group_nodes([group.pk], dry_run=dry_run or _dry_run_callback, **traversal_rules)
175-
if not nodes_deleted:
176-
# don't delete the group if the nodes were not deleted
253+
# Query groups that belong to all users
254+
if not all_users:
255+
builder.append(orm.User, filters={'email': user_email}, with_group='group')
256+
257+
# Query groups that contain a particular node
258+
if node:
259+
builder.append(orm.Node, filters={'id': node.pk}, with_group='group')
260+
261+
groups = builder.all(flat=True)
262+
if not groups:
263+
echo.echo_report('No groups found matching the specified criteria.')
177264
return
178265

179-
if not dry_run:
266+
elif not groups and not filters_provided:
267+
echo.echo_report('Nothing happened. Please specify at least one group or provide filters to query groups.')
268+
return
269+
270+
projection_lambdas = {
271+
'pk': lambda group: str(group.pk),
272+
'label': lambda group: group.label,
273+
'type_string': lambda group: group.type_string,
274+
'count': lambda group: group.count(),
275+
'user': lambda group: group.user.email.strip(),
276+
'description': lambda group: group.description,
277+
}
278+
279+
table = []
280+
projection_header = ['PK', 'Label', 'Type string', 'User']
281+
projection_fields = ['pk', 'label', 'type_string', 'user']
282+
for group in groups:
283+
table.append([projection_lambdas[field](group) for field in projection_fields])
284+
285+
if not (force or dry_run):
286+
echo.echo_report('The following groups will be deleted:')
287+
echo.echo(tabulate(table, headers=projection_header))
288+
click.confirm('Are you sure you want to continue?', abort=True)
289+
elif dry_run:
290+
echo.echo_report('Would have deleted:')
291+
echo.echo(tabulate(table, headers=projection_header))
292+
293+
for group in groups:
180294
group_str = str(group)
181-
orm.Group.collection.delete(group.pk)
182-
echo.echo_success(f'{group_str} deleted.')
295+
296+
if delete_nodes:
297+
298+
def _dry_run_callback(pks):
299+
if not pks or force:
300+
return False
301+
echo.echo_warning(
302+
f'YOU ARE ABOUT TO DELETE {len(pks)} NODES ASSOCIATED WITH {group_str}! THIS CANNOT BE UNDONE!'
303+
)
304+
return not click.confirm('Do you want to continue?', abort=True)
305+
306+
_, nodes_deleted = delete_group_nodes([group.pk], dry_run=dry_run or _dry_run_callback, **traversal_rules)
307+
if not nodes_deleted:
308+
# don't delete the group if the nodes were not deleted
309+
return
310+
311+
if not dry_run:
312+
orm.Group.collection.delete(group.pk)
313+
echo.echo_success(f'{group_str} deleted.')
183314

184315

185316
@verdi_group.command('relabel')
@@ -273,7 +404,7 @@ def group_show(group, raw, limit, uuid):
273404
@options.ALL_USERS(help='Show groups for all users, rather than only for the current user.')
274405
@options.USER(help='Add a filter to show only groups belonging to a specific user.')
275406
@options.ALL(help='Show groups of all types.')
276-
@options.TYPE_STRING()
407+
@options.TYPE_STRING(default='core', help='Filter to only include groups of this type string.')
277408
@click.option(
278409
'-d', '--with-description', 'with_description', is_flag=True, default=False, help='Show also the group description.'
279410
)
@@ -302,7 +433,7 @@ def group_show(group, raw, limit, uuid):
302433
)
303434
@options.ORDER_BY(type=click.Choice(['id', 'label', 'ctime']), default='label')
304435
@options.ORDER_DIRECTION()
305-
@options.NODE(help='Show only the groups that contain the node.')
436+
@options.NODE(help='Show only the groups that contain this node.')
306437
@with_dbenv()
307438
def group_list(
308439
all_users,
@@ -331,12 +462,6 @@ def group_list(
331462
builder = orm.QueryBuilder()
332463
filters = {}
333464

334-
# Have to specify the default for `type_string` here instead of directly in the option otherwise it will always
335-
# raise above if the user specifies just the `--group-type` option. Once that option is removed, the default can
336-
# be moved to the option itself.
337-
if type_string is None:
338-
type_string = 'core'
339-
340465
if not all_entries:
341466
if '%' in type_string or '_' in type_string:
342467
filters['type_string'] = {'like': type_string}
@@ -367,11 +492,11 @@ def group_list(
367492

368493
# Query groups that belong to all users
369494
if not all_users:
370-
builder.append(orm.User, filters={'email': {'==': user_email}}, with_group='group')
495+
builder.append(orm.User, filters={'email': user_email}, with_group='group')
371496

372497
# Query groups that contain a particular node
373498
if node:
374-
builder.append(orm.Node, filters={'id': {'==': node.pk}}, with_group='group')
499+
builder.append(orm.Node, filters={'id': node.pk}, with_group='group')
375500

376501
builder.order_by({orm.Group: {order_by: order_dir}})
377502

0 commit comments

Comments
 (0)