Skip to content

Commit f4e2853

Browse files
authored
Fix bugs in cylc broadcast (#5933)
broadcast: fix issues with filepaths and hash characters * Fix traceback when using `cylc broadcast -F` with a relative filepath * Fix `#` char bug in `cylc broadcast`
1 parent df4e17d commit f4e2853

File tree

5 files changed

+137
-46
lines changed

5 files changed

+137
-46
lines changed

changes.d/5933.fix.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed bug in `cylc broadcast` (and the GUI Edit Runtime command) where everything after a `#` character in a setting would be stripped out.

cylc/flow/parsec/validate.py

+53-34
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import shlex
2727
from collections import deque
2828
from textwrap import dedent
29-
from typing import List, Dict, Any, Tuple
29+
from typing import List, Dict, Any, Optional, Tuple
3030

3131
from metomi.isodatetime.data import Duration, TimePoint
3232
from metomi.isodatetime.dumpers import TimePointDumper
@@ -373,7 +373,7 @@ def coerce_range(cls, value, keys):
373373
return Range((int(min_), int(max_)))
374374

375375
@classmethod
376-
def coerce_str(cls, value, keys):
376+
def coerce_str(cls, value, keys) -> str:
377377
"""Coerce value to a string.
378378
379379
Examples:
@@ -385,7 +385,7 @@ def coerce_str(cls, value, keys):
385385
"""
386386
if isinstance(value, list):
387387
# handle graph string merging
388-
vraw = []
388+
vraw: List[str] = []
389389
vals = [value]
390390
while vals:
391391
val = vals.pop()
@@ -512,41 +512,46 @@ def parse_int_range(cls, value):
512512
return None
513513

514514
@classmethod
515-
def strip_and_unquote(cls, keys, value):
515+
def _unquote(cls, keys: List[str], value: str) -> Optional[str]:
516+
"""Unquote value."""
517+
for substr, rec in (
518+
("'''", cls._REC_MULTI_LINE_SINGLE),
519+
('"""', cls._REC_MULTI_LINE_DOUBLE),
520+
('"', cls._REC_DQ_VALUE),
521+
("'", cls._REC_SQ_VALUE)
522+
):
523+
if value.startswith(substr):
524+
match = rec.match(value)
525+
if not match:
526+
raise IllegalValueError("string", keys, value)
527+
return match[1]
528+
return None
529+
530+
@classmethod
531+
def strip_and_unquote(cls, keys: List[str], value: str) -> str:
516532
"""Remove leading and trailing spaces and unquote value.
517533
518534
Args:
519-
keys (list):
535+
keys:
520536
Keys in nested dict that represents the raw configuration.
521-
value (str):
537+
value:
522538
String value in raw configuration.
523539
524-
Return (str):
540+
Return:
525541
Processed value.
526542
527543
Examples:
528544
>>> ParsecValidator.strip_and_unquote(None, '" foo "')
529545
'foo'
530546
531547
"""
532-
for substr, rec in [
533-
["'''", cls._REC_MULTI_LINE_SINGLE],
534-
['"""', cls._REC_MULTI_LINE_DOUBLE],
535-
['"', cls._REC_DQ_VALUE],
536-
["'", cls._REC_SQ_VALUE]]:
537-
if value.startswith(substr):
538-
match = rec.match(value)
539-
if not match:
540-
raise IllegalValueError("string", keys, value)
541-
value = match.groups()[0]
542-
break
543-
else:
544-
# unquoted
545-
value = value.split(r'#', 1)[0]
548+
val = cls._unquote(keys, value)
549+
if val is None:
550+
val = value.split(r'#', 1)[0]
546551

547552
# Note strip() removes leading and trailing whitespace, including
548553
# initial newlines on a multiline string:
549-
return dedent(value).strip()
554+
return dedent(val).strip()
550555

551556
@classmethod
552557
def strip_and_unquote_list(cls, keys, value):
@@ -1136,23 +1141,25 @@ def _coerce_type(cls, value):
11361141
return val
11371142

11381143

1139-
# BACK COMPAT: BroadcastConfigValidator
1140-
# The DB at 8.0.x stores Interval values as neither ISO8601 duration
1141-
# string or DurationFloat. This has been fixed at 8.1.0, and
1142-
# the following class acts as a bridge between fixed and broken.
1143-
# url:
1144-
# https://github.com/cylc/cylc-flow/pull/5138
1145-
# from:
1146-
# 8.0.x
1147-
# to:
1148-
# 8.1.x
1149-
# remove at:
1150-
# 8.x
11511144
class BroadcastConfigValidator(CylcConfigValidator):
11521145
"""Validate and Coerce DB loaded broadcast config to internal objects."""
11531146
def __init__(self):
11541147
CylcConfigValidator.__init__(self)
11551148

1149+
@classmethod
1150+
def coerce_str(cls, value, keys) -> str:
1151+
"""Coerce value to a string. Unquotes & strips lead/trail whitespace.
1152+
1153+
Prevents ParsecValidator from assuming '#' means comments;
1154+
'#' has valid uses in shell script such as parameter substitution.
1155+
1156+
Examples:
1157+
>>> BroadcastConfigValidator.coerce_str('echo "${FOO#*bar}"', None)
1158+
'echo "${FOO#*bar}"'
1159+
"""
1160+
val = ParsecValidator._unquote(keys, value) or value
1161+
return dedent(val).strip()
1162+
11561163
@classmethod
11571164
def strip_and_unquote_list(cls, keys, value):
11581165
"""Remove leading and trailing spaces and unquote list value.
@@ -1177,6 +1184,18 @@ def strip_and_unquote_list(cls, keys, value):
11771184
value = value.lstrip('[').rstrip(']')
11781185
return ParsecValidator.strip_and_unquote_list(keys, value)
11791186

1187+
# BACK COMPAT: BroadcastConfigValidator.coerce_interval
1188+
# The DB at 8.0.x stores Interval values as neither ISO8601 duration
1189+
# string or DurationFloat. This has been fixed at 8.1.0, and
1190+
# the following method acts as a bridge between fixed and broken.
1191+
# url:
1192+
# https://github.com/cylc/cylc-flow/pull/5138
1193+
# from:
1194+
# 8.0.x
1195+
# to:
1196+
# 8.1.x
1197+
# remove at:
1198+
# 8.x
11801199
@classmethod
11811200
def coerce_interval(cls, value, keys):
11821201
"""Coerce an ISO 8601 interval into seconds.

cylc/flow/scripts/broadcast.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
from ansimarkup import parse as cparse
8383
from copy import deepcopy
8484
from functools import partial
85+
import os.path
8586
import re
8687
import sys
8788
from tempfile import NamedTemporaryFile
@@ -203,7 +204,7 @@ def files_to_settings(settings, setting_files, cancel_mode=False):
203204
handle.seek(0, 0)
204205
cfg.loadcfg(handle.name)
205206
else:
206-
cfg.loadcfg(setting_file)
207+
cfg.loadcfg(os.path.abspath(setting_file))
207208
stack = [([], cfg.get(sparse=True))]
208209
while stack:
209210
keys, item = stack.pop()

tests/functional/broadcast/10-file-1/broadcast.cylc

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
script="""
22
printenv CYLC_FOOBAR
33
4+
# This hash char should not cause the rest of the script to be stripped out
5+
# - https://github.com/cylc/cylc-flow/pull/5933
6+
47
if (($CYLC_TASK_TRY_NUMBER < 2 )); then
58
false
69
fi

tests/unit/parsec/test_validate.py

+78-11
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@
1818
from typing import List
1919

2020
import pytest
21-
from pytest import approx
21+
from pytest import approx, param
2222

2323
from cylc.flow.parsec.config import ConfigNode as Conf
2424
from cylc.flow.parsec.OrderedDict import OrderedDictWithDefaults
2525
from cylc.flow.parsec.exceptions import IllegalValueError
2626
from cylc.flow.parsec.validate import (
27+
BroadcastConfigValidator,
2728
CylcConfigValidator as VDR,
2829
DurationFloat,
2930
ListValueError,
@@ -456,11 +457,9 @@ def test_coerce_int_list():
456457
validator.coerce_int_list(value, ['whatever'])
457458

458459

459-
def test_coerce_str():
460-
"""Test coerce_str."""
461-
validator = ParsecValidator()
462-
# The good
463-
for value, result in [
460+
@pytest.mark.parametrize(
461+
'value, expected',
462+
[
464463
('', ''),
465464
('Hello World!', 'Hello World!'),
466465
('"Hello World!"', 'Hello World!'),
@@ -474,9 +473,15 @@ def test_coerce_str():
474473
'Hello:\n foo\nGreet\n baz'),
475474
('False', 'False'),
476475
('None', 'None'),
477-
(['a', 'b'], 'a\nb')
478-
]:
479-
assert validator.coerce_str(value, ['whatever']) == result
476+
(['a', 'b'], 'a\nb'),
477+
('abc#def', 'abc'),
478+
]
479+
)
480+
def test_coerce_str(value: str, expected: str):
481+
"""Test coerce_str."""
482+
validator = ParsecValidator()
483+
# The good
484+
assert validator.coerce_str(value, ['whatever']) == expected
480485

481486

482487
def test_coerce_str_list():
@@ -498,9 +503,51 @@ def test_coerce_str_list():
498503
assert validator.coerce_str_list(value, ['whatever']) == results
499504

500505

501-
def test_strip_and_unquote():
506+
@pytest.mark.parametrize('value, expected', [
507+
param(
508+
"'a'",
509+
'a',
510+
id="single quotes"
511+
),
512+
param(
513+
'"\'a\'"',
514+
"'a'",
515+
id="single quotes inside double quotes"
516+
),
517+
param(
518+
'" a b" # comment',
519+
' a b',
520+
id="comment outside"
521+
),
522+
param(
523+
'"""bene\ngesserit"""',
524+
'bene\ngesserit',
525+
id="multiline double quotes"
526+
),
527+
param(
528+
"'''kwisatz\n haderach'''",
529+
'kwisatz\n haderach',
530+
id="multiline single quotes"
531+
),
532+
param(
533+
'"""a\nb""" # comment',
534+
'a\nb',
535+
id="multiline with comment outside"
536+
),
537+
])
538+
def test_unquote(value: str, expected: str):
539+
"""Test strip_and_unquote."""
540+
assert ParsecValidator._unquote(['a'], value) == expected
541+
542+
543+
@pytest.mark.parametrize('value', [
544+
'"""',
545+
"'''",
546+
"'don't do this'",
547+
])
548+
def test_strip_and_unquote__bad(value: str):
502549
with pytest.raises(IllegalValueError):
503-
ParsecValidator.strip_and_unquote(['a'], '"""')
550+
ParsecValidator.strip_and_unquote(['a'], value)
504551

505552

506553
def test_strip_and_unquote_list_parsec():
@@ -692,3 +739,23 @@ def test_type_help_examples():
692739
except Exception:
693740
raise Exception(
694741
f'Example "{example}" failed for type "{vdr}"')
742+
743+
744+
@pytest.mark.parametrize('value, expected', [
745+
param(
746+
"""
747+
a="don't have a cow"
748+
a=${a#*have}
749+
echo "$a" # let's see what happens
750+
""",
751+
"a=\"don't have a cow\"\na=${a#*have}\necho \"$a\" # let's see what happens",
752+
id="multiline"
753+
),
754+
param(
755+
'"sleep 30 # ja!" ',
756+
'sleep 30 # ja!',
757+
id="quoted"
758+
),
759+
])
760+
def test_broadcast_coerce_str(value: str, expected: str):
761+
assert BroadcastConfigValidator.coerce_str(value, ['whatever']) == expected

0 commit comments

Comments
 (0)