Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix bugs in cylc broadcast #5933

Merged
merged 4 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Fix issues with broadcast string coercion; address code review
  • Loading branch information
MetRonnie committed Jan 22, 2024
commit aacd390ab5e39e7a180650364c1ddfcd7282aa91
56 changes: 30 additions & 26 deletions cylc/flow/parsec/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import shlex
from collections import deque
from textwrap import dedent
from typing import List, Dict, Any, Tuple
from typing import List, Dict, Any, Optional, Tuple

from metomi.isodatetime.data import Duration, TimePoint
from metomi.isodatetime.dumpers import TimePointDumper
Expand Down Expand Up @@ -511,6 +511,22 @@ def parse_int_range(cls, value):
else:
return None

@classmethod
def _unquote(cls, keys: List[str], value: str) -> Optional[str]:
"""Unquote value."""
for substr, rec in (
("'''", cls._REC_MULTI_LINE_SINGLE),
('"""', cls._REC_MULTI_LINE_DOUBLE),
('"', cls._REC_DQ_VALUE),
("'", cls._REC_SQ_VALUE)
):
if value.startswith(substr):
match = rec.match(value)
if not match:
raise IllegalValueError("string", keys, value)
return match[1]
return None

@classmethod
def strip_and_unquote(cls, keys: List[str], value: str) -> str:
"""Remove leading and trailing spaces and unquote value.
Expand All @@ -529,25 +545,13 @@ def strip_and_unquote(cls, keys: List[str], value: str) -> str:
'foo'

"""
for substr, rec in (
("'''", cls._REC_MULTI_LINE_SINGLE),
('"""', cls._REC_MULTI_LINE_DOUBLE),
('"', cls._REC_DQ_VALUE),
("'", cls._REC_SQ_VALUE)
):
if value.startswith(substr):
match = rec.match(value)
if not match:
raise IllegalValueError("string", keys, value)
value = match.groups()[0]
break
else: # no break
# unquoted
value = value.split(r'#', 1)[0]
val = cls._unquote(keys, value)
if val is None:
val = value.split(r'#', 1)[0]

# Note strip() removes leading and trailing whitespace, including
# initial newlines on a multiline string:
return dedent(value).strip()
return dedent(val).strip()

@classmethod
def strip_and_unquote_list(cls, keys, value):
Expand Down Expand Up @@ -1144,17 +1148,17 @@ def __init__(self):

@classmethod
def coerce_str(cls, value, keys) -> str:
"""Coerce value to a string.
"""Coerce value to a string. Unquotes & strips lead/trail whitespace.

Prevents ParsecValidator from assuming '#' means comments;
'#' has valid uses in shell script such as parameter substitution.

Examples:
>>> BroadcastConfigValidator.coerce_str('abc#def', None)
'abc#def'
>>> BroadcastConfigValidator.coerce_str('echo "${FOO#*bar}"', None)
'echo "${FOO#*bar}"'
"""
# Prevent ParsecValidator from assuming '#' means comments;
# '#' has valid uses in shell script such as parameter substitution
if isinstance(value, str) and '#' in value:
value = f'"{value}"'
return ParsecValidator.coerce_str(value, keys)
val = ParsecValidator._unquote(keys, value) or value
return dedent(val).strip()
Comment on lines +1160 to +1161
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the dedent/strip happen before the _unquote?

If not, wouldn't 'foo' end up as " 'foo' "?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stripping is for the stuff inside the quotes, and this is the same as it was before this PR. I've tested

  • cylc broadcast -s 'script = "foo" '
  • cylc broadcast -s 'script = foo '

and both set script=foo


@classmethod
def strip_and_unquote_list(cls, keys, value):
Expand Down Expand Up @@ -1183,7 +1187,7 @@ def strip_and_unquote_list(cls, keys, value):
# BACK COMPAT: BroadcastConfigValidator.coerce_interval
# The DB at 8.0.x stores Interval values as neither ISO8601 duration
# string or DurationFloat. This has been fixed at 8.1.0, and
# the following class acts as a bridge between fixed and broken.
# the following method acts as a bridge between fixed and broken.
# url:
# https://github.com/cylc/cylc-flow/pull/5138
# from:
Expand Down
3 changes: 2 additions & 1 deletion tests/functional/broadcast/10-file-1/broadcast.cylc
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
script="""
printenv CYLC_FOOBAR

# (This hash char should not cause the rest of the script to be stripped out)
# This hash char should not cause the rest of the script to be stripped out
# - https://github.com/cylc/cylc-flow/pull/5933

if (($CYLC_TASK_TRY_NUMBER < 2 )); then
false
Expand Down
69 changes: 66 additions & 3 deletions tests/unit/parsec/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@
from typing import List

import pytest
from pytest import approx
from pytest import approx, param

from cylc.flow.parsec.config import ConfigNode as Conf
from cylc.flow.parsec.OrderedDict import OrderedDictWithDefaults
from cylc.flow.parsec.exceptions import IllegalValueError
from cylc.flow.parsec.validate import (
BroadcastConfigValidator,
CylcConfigValidator as VDR,
DurationFloat,
ListValueError,
Expand Down Expand Up @@ -502,9 +503,51 @@ def test_coerce_str_list():
assert validator.coerce_str_list(value, ['whatever']) == results


def test_strip_and_unquote():
@pytest.mark.parametrize('value, expected', [
param(
"'a'",
'a',
id="single quotes"
),
param(
'"\'a\'"',
"'a'",
id="single quotes inside double quotes"
),
param(
'" a b" # comment',
' a b',
id="comment outside"
),
param(
'"""bene\ngesserit"""',
'bene\ngesserit',
id="multiline double quotes"
),
param(
"'''kwisatz\n haderach'''",
'kwisatz\n haderach',
id="multiline single quotes"
),
param(
'"""a\nb""" # comment',
'a\nb',
id="multiline with comment outside"
),
])
def test_unquote(value: str, expected: str):
"""Test strip_and_unquote."""
assert ParsecValidator._unquote(['a'], value) == expected


@pytest.mark.parametrize('value', [
'"""',
"'''",
"'don't do this'",
])
def test_strip_and_unquote__bad(value: str):
with pytest.raises(IllegalValueError):
ParsecValidator.strip_and_unquote(['a'], '"""')
ParsecValidator.strip_and_unquote(['a'], value)


def test_strip_and_unquote_list_parsec():
Expand Down Expand Up @@ -696,3 +739,23 @@ def test_type_help_examples():
except Exception:
raise Exception(
f'Example "{example}" failed for type "{vdr}"')


@pytest.mark.parametrize('value, expected', [
param(
"""
a="don't have a cow"
a=${a#*have}
echo "$a" # let's see what happens
""",
"a=\"don't have a cow\"\na=${a#*have}\necho \"$a\" # let's see what happens",
id="multiline"
),
param(
'"sleep 30 # ja!" ',
'sleep 30 # ja!',
id="quoted"
),
])
def test_broadcast_coerce_str(value: str, expected: str):
assert BroadcastConfigValidator.coerce_str(value, ['whatever']) == expected
Loading