diff --git a/CHANGES.md b/CHANGES.md
index 7370fff01c7..905528ea71b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -12,6 +12,13 @@ ones in. -->
## __cylc-8.2.0 (Upcoming)__
+### Breaking Changes
+
+[#5600](https://github.com/cylc/cylc-flow/pull/5600) -
+The `CYLC_TASK_DEPENDENCIES` environment variable will no longer be exported
+in job environments if there are more than 50 dependencies. This avoids an
+issue which could cause jobs to fail if this variable became too long.
+
### Enhancements
[#5405](https://github.com/cylc/cylc-flow/pull/5405) - Improve scan command
@@ -34,17 +41,6 @@ in `share/bin` and Python modules in `share/lib/python`.
[#5328](https://github.com/cylc/cylc-flow/pull/5328) -
Efficiency improvements to reduce task management overheads on the Scheduler.
-## __cylc-8.1.5 (Upcoming)__
-
-### Breaking Changes
-
-[#5600](https://github.com/cylc/cylc-flow/pull/5600) -
-The `CYLC_TASK_DEPENDENCIES` environment variable will no longer be exported
-in job environments if there are more than 50 dependencies. This avoids an
-issue which could cause jobs to fail if this variable became too long.
-
-### Enhancements
-
[#5546](https://github.com/cylc/cylc-flow/pull/5546) -
`cylc lint` will provide a non-zero return code if any issues are identified.
This can be overridden using the new `--exit-zero` flag.
@@ -73,6 +69,10 @@ the UI whilst the workflow is in the process of shutting down.
[#5582](https://github.com/cylc/cylc-flow/pull/5582) - Set Cylc 7 compatibility
mode before running pre-configure plugins.
+[#5587](https://github.com/cylc/cylc-flow/pull/5587) -
+Permit commas in xtrigger arguments and fix minor issues with the parsing of
+xtrigger function signatures.
+
## __cylc-8.1.4 (Released 2023-05-04)__
### Fixes
diff --git a/cylc/flow/parsec/validate.py b/cylc/flow/parsec/validate.py
index f6e40e6a912..5055d01ab79 100644
--- a/cylc/flow/parsec/validate.py
+++ b/cylc/flow/parsec/validate.py
@@ -26,6 +26,7 @@
import shlex
from collections import deque
from textwrap import dedent
+from typing import List, Dict, Any, Tuple
from metomi.isodatetime.data import Duration, TimePoint
from metomi.isodatetime.dumpers import TimePointDumper
@@ -994,6 +995,66 @@ def coerce_parameter_list(cls, value, keys):
except ValueError:
return items
+ @staticmethod
+ def parse_xtrig_arglist(value: str) -> Tuple[List[Any], Dict[str, Any]]:
+ """Parse Pythonic-like arg/kwarg signatures.
+
+ A stateful parser treats all args/kwargs as strings with
+ implicit quoting.
+
+ Examples:
+ >>> parse = CylcConfigValidator.parse_xtrig_arglist
+
+ # Parse pythonic syntax
+ >>> parse('a, b, c, d=1, e=2,')
+ (['a', 'b', 'c'], {'d': '1', 'e': '2'})
+ >>> parse('a, "1,2,3", b=",=",')
+ (['a', '"1,2,3"'], {'b': '",="'})
+ >>> parse('a, "b c", '"'d=e '")
+ (['a', '"b c"', "'d=e '"], {})
+
+ # Parse implicit (i.e. unquoted) strings
+ >>> parse('%(cycle)s, %(task)s, output=succeeded')
+ (['%(cycle)s', '%(task)s'], {'output': 'succeeded'})
+
+ """
+ # results
+ args = []
+ kwargs = {}
+ # state
+ in_str = False # are we inside a quoted string
+ in_kwarg = False # are we after the = sign of a kwarg
+ buffer = '' # the current argument being parsed
+ kwarg_buffer = '' # the key of a kwarg if in_kwarg == True
+ # parser
+ for char in value:
+ if char in {'"', "'"}:
+ in_str = not in_str
+ buffer += char
+ elif not in_str and char == ',':
+ if in_kwarg:
+ kwargs[kwarg_buffer.strip()] = buffer.strip()
+ in_kwarg = False
+ kwarg_buffer = ''
+ else:
+ args.append(buffer.strip())
+ buffer = ''
+ elif char == '=' and not in_str and not in_kwarg:
+ in_kwarg = True
+ kwarg_buffer = buffer
+ buffer = ''
+ else:
+ buffer += char
+
+ # reached the end of the string
+ if buffer:
+ if in_kwarg:
+ kwargs[kwarg_buffer.strip()] = buffer.strip()
+ else:
+ args.append(buffer.strip())
+
+ return args, kwargs
+
@classmethod
def coerce_xtrigger(cls, value, keys):
"""Coerce a string into an xtrigger function context object.
@@ -1002,17 +1063,26 @@ def coerce_xtrigger(cls, value, keys):
Checks for legal string templates in arg values too.
Examples:
- >>> CylcConfigValidator.coerce_xtrigger('a(b, c):PT1M', [None])
+ >>> xtrig = CylcConfigValidator.coerce_xtrigger
+
+ # parse xtrig function signatures
+ >>> xtrig('a(b, c):PT1M', [None])
a(b, c):60.0
+ >>> xtrig('a(x, "1,2,3", y):PT1S', [None])
+ a(x, 1,2,3, y):1.0
- """
+ # cast types
+ >>> x = xtrig('a(1, 1.1, True, abc, x=True, y=1.1)', [None])
+ >>> x.func_args
+ [1, 1.1, True, 'abc']
+ >>> x.func_kwargs
+ {'x': True, 'y': 1.1}
+ """
label = keys[-1]
value = cls.strip_and_unquote(keys, value)
if not value:
raise IllegalValueError("xtrigger", keys, value)
- args = []
- kwargs = {}
match = cls._REC_TRIG_FUNC.match(value)
if match is None:
raise IllegalValueError("xtrigger", keys, value)
@@ -1020,15 +1090,18 @@ def coerce_xtrigger(cls, value, keys):
if intvl:
intvl = cls.coerce_interval(intvl, keys)
- if fargs:
- # Extract function args and kwargs.
- for farg in fargs.split(r','):
- try:
- key, val = farg.strip().split(r'=', 1)
- except ValueError:
- args.append(cls._coerce_type(farg.strip()))
- else:
- kwargs[key.strip()] = cls._coerce_type(val.strip())
+ # parse args
+ args, kwargs = CylcConfigValidator.parse_xtrig_arglist(fargs or '')
+
+ # cast types
+ args = [
+ CylcConfigValidator._coerce_type(arg)
+ for arg in args
+ ]
+ kwargs = {
+ key: CylcConfigValidator._coerce_type(value)
+ for key, value in kwargs.items()
+ }
return SubFuncContext(label, fname, args, kwargs, intvl)
diff --git a/cylc/flow/subprocctx.py b/cylc/flow/subprocctx.py
index 030bedaced6..85bbc284503 100644
--- a/cylc/flow/subprocctx.py
+++ b/cylc/flow/subprocctx.py
@@ -162,7 +162,7 @@ def get_signature(self):
def __str__(self):
return (
f'{self.func_name}('
- f'{", ".join(self.func_args + list(self.func_kwargs))}'
+ f'{", ".join(map(str, self.func_args + list(self.func_kwargs)))}'
f'):{self.intvl}'
)