Skip to content

Commit b5bb6c2

Browse files
authored
Merge pull request #6920 from oliver-sanders/command-task-matching
id_match: extend task matching to inactive tasks for globs and families
2 parents 8615c7f + e5f8cc7 commit b5bb6c2

30 files changed

+1352
-1127
lines changed

changes.d/6920.feat.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Cylc commands (e.g. `cylc trigger` and `cylc hold`) can now operate on tasks
2+
which are not "active" using globs and family IDs. E.g,
3+
`cylc trigger workflow//cycle/family` will now trigger all tasks in the family,
4+
not just the ones that are presently active.

cylc/flow/command_validation.py

Lines changed: 78 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,18 @@
1616

1717
"""Cylc command argument validation logic."""
1818

19-
2019
from typing import (
20+
TYPE_CHECKING,
21+
Dict,
2122
Iterable,
2223
List,
2324
Optional,
25+
Set,
26+
cast,
2427
)
2528

26-
from cylc.flow.exceptions import InputError
29+
from cylc.flow.cycling.loader import standardise_point_string
30+
from cylc.flow.exceptions import InputError, PointParsingError
2731
from cylc.flow.flow_mgr import (
2832
FLOW_NEW,
2933
FLOW_NONE,
@@ -32,8 +36,13 @@
3236
IDTokens,
3337
Tokens,
3438
)
35-
from cylc.flow.task_outputs import TASK_OUTPUT_SUCCEEDED
39+
from cylc.flow.id_cli import contains_fnmatch
3640
from cylc.flow.scripts.set import XTRIGGER_PREREQ_PREFIX
41+
from cylc.flow.task_outputs import TASK_OUTPUT_SUCCEEDED
42+
43+
44+
if TYPE_CHECKING:
45+
from cylc.flow.id import TaskTokens
3746

3847

3948
ERR_OPT_FLOW_VAL_INT_NEW_NONE = ( # for set and trigger commands
@@ -49,7 +58,7 @@
4958
def flow_opts(
5059
flows: List[str],
5160
flow_wait: bool,
52-
allow_new_or_none: bool = True
61+
allow_new_or_none: bool = True,
5362
) -> None:
5463
"""Check validity of flow-related CLI options.
5564
@@ -235,9 +244,7 @@ def prereq(prereq: str) -> Optional[str]:
235244
return None
236245

237246
if tokens["cycle"] == XTRIGGER_PREREQ_PREFIX:
238-
if (
239-
tokens["task_sel"] not in {None, TASK_OUTPUT_SUCCEEDED}
240-
):
247+
if tokens["task_sel"] not in {None, TASK_OUTPUT_SUCCEEDED}:
241248
# Error: xtrigger status must be default or succeeded.
242249
return None
243250
if tokens["task_sel"] is None:
@@ -291,7 +298,7 @@ def outputs(outputs: Optional[List[str]]):
291298

292299
def consistency(
293300
outputs: Optional[List[str]],
294-
prereqs: Optional[List[str]]
301+
prereqs: Optional[List[str]],
295302
) -> None:
296303
"""Check global option consistency
297304
@@ -309,40 +316,73 @@ def consistency(
309316
raise InputError("Use --prerequisite or --output, not both.")
310317

311318

312-
def is_tasks(tasks: Iterable[str]):
313-
"""All tasks in a list of tasks are task ID's without trailing job ID.
319+
def is_tasks(ids: Iterable[str]) -> 'Set[TaskTokens]':
320+
"""Ensure all IDs are task IDs and standardise them.
314321
315-
Examples:
316-
# All legal
317-
>>> is_tasks(['1/foo', '1/bar', '*/baz', '*/*'])
322+
* Parses IDs.
323+
* Filters out job ids and ensures at least the cycle point is provided.
324+
* Standardises the cycle point format.
325+
* Defaults the namespace to "root" unless provided.
318326
319-
# Some legal
320-
>>> is_tasks(['1/foo/NN', '1/bar', '*/baz', '*/*/42'])
321-
Traceback (most recent call last):
322-
...
323-
cylc.flow.exceptions.InputError: This command does not take job ids:
324-
* 1/foo/NN
325-
* */*/42
327+
Args:
328+
ids: The strings to parse.
326329
327-
# None legal
328-
>>> is_tasks(['*/baz/12'])
329-
Traceback (most recent call last):
330-
...
331-
cylc.flow.exceptions.InputError: This command does not take job ids:
332-
* */baz/12
330+
Returns:
331+
The parsed IDs as TaskTokens objects.
332+
333+
Raises:
334+
InputError: If any of the IDs cannot be parsed or formatted.
333335
334-
>>> is_tasks([])
335-
Traceback (most recent call last):
336-
...
337-
cylc.flow.exceptions.InputError: No tasks specified
338336
"""
339-
if not tasks:
337+
if not ids:
340338
raise InputError("No tasks specified")
341-
bad_tasks: List[str] = []
342-
for task in tasks:
343-
tokens = Tokens('//' + task)
339+
ret: 'Set[TaskTokens]' = set()
340+
errors: Dict[str, List[str]] = {}
341+
342+
for id_ in ids:
343+
# parse id
344+
try:
345+
tokens = Tokens(id_, relative=True)
346+
except ValueError:
347+
errors.setdefault('Invalid ID', []).append(id_)
348+
continue
349+
350+
# filter out job IDs
344351
if tokens.lowest_token == IDTokens.Job.value:
345-
bad_tasks.append(task)
346-
if bad_tasks:
347-
msg = 'This command does not take job ids:\n * '
348-
raise InputError(msg + '\n * '.join(bad_tasks))
352+
errors.setdefault('This command does not take job IDs', []).append(
353+
id_
354+
)
355+
continue
356+
357+
# if the task is not specified, default to "root"
358+
if tokens['task'] is None:
359+
tokens = tokens.duplicate(task='root')
360+
361+
# if the cycle is not a glob or reference, standardise it
362+
if (
363+
# cycle point is a glob
364+
not contains_fnmatch(cast('str', tokens['cycle']))
365+
# cycle point is a reference to the ICP/FCP
366+
and tokens['cycle'] not in {'^', '$'}
367+
):
368+
try:
369+
cycle = standardise_point_string(tokens['cycle'])
370+
except PointParsingError:
371+
errors.setdefault('Invalid cycle point', []).append(id_)
372+
continue
373+
else:
374+
if cycle != tokens['cycle']:
375+
tokens = tokens.duplicate(cycle=cycle)
376+
377+
# we have confirmed that both cycle and task have been provided
378+
ret.add(cast('TaskTokens', tokens))
379+
380+
if errors:
381+
raise InputError(
382+
'\n'.join(
383+
f'{message}: {", ".join(sorted(_ids))}'
384+
for message, _ids in sorted(errors.items())
385+
)
386+
)
387+
388+
return ret

0 commit comments

Comments
 (0)