diff --git a/samcli/cli/types.py b/samcli/cli/types.py index dc37420047..26819121c0 100644 --- a/samcli/cli/types.py +++ b/samcli/cli/types.py @@ -17,6 +17,31 @@ LOG = logging.getLogger(__name__) +def _generate_quoted_match_regex(match_pattern): + """ + Creates a regex on a quoted string based on a match pattern (also a regex) that is to be + run on a string (which may contain escaped quotes) that is separated by delimiters. + + Parameters + ---------- + match_pattern: (str) regex pattern to match + delim: (str) delimiter that is respected when identifying matching groups with generated regex. + + Returns + ------- + str: regex expression + + Examples + ------- + match_pattern: [A-Za-z0-9\\"_:\\.\\/\\+-\\@=] + input: createdBy=\"Test user\" ProjectName='Test App' + output: ['"Test user"', "'Test App'"] + + """ + + return f"""(\\"(?:\\\\{match_pattern}|[^\\"\\\\]+)*\\"|""" + f"""\'(?:\\\\{match_pattern}|[^\'\\\\]+)*\')""" + + def _generate_match_regex(match_pattern, delim): """ Creates a regex string based on a match pattern (also a regex) that is to be @@ -194,6 +219,7 @@ def __init__(self, multiple_values_per_key=False): TAG_REGEX = '[A-Za-z0-9\\"_:\\.\\/\\+-\\@=]' _pattern = r"{tag}={tag}".format(tag=_generate_match_regex(match_pattern=TAG_REGEX, delim=" ")) + _quoted_pattern = _generate_quoted_match_regex(match_pattern=TAG_REGEX) name = "string,list" @@ -222,13 +248,38 @@ def convert(self, value, param, ctx): for k in tags: self._add_value(result, _unquote_wrapped_quotes(k), _unquote_wrapped_quotes(tags[k])) else: - groups = re.findall(self._pattern, val) - - if not groups: - fail = True - for group in groups: - key, v = group - self._add_value(result, _unquote_wrapped_quotes(key), _unquote_wrapped_quotes(v)) + # Instead of parsing a full {tag}={tag} pattern, we will try to look for quoted string with spaces, + # remove all the spaces and start over again. + + # First, we need to unquote a full string + modified_val = _unquote_wrapped_quotes(val) + + # Next, looking for a quote strings that contain spaces and proceed to replace them + quoted_strings_with_spaces = re.findall(self._quoted_pattern, modified_val) + quoted_strings_with_spaces_objects = [ + TextWithSpaces(str_with_spaces) for str_with_spaces in quoted_strings_with_spaces + ] + for s, replacement in zip(quoted_strings_with_spaces, quoted_strings_with_spaces_objects): + modified_val = modified_val.replace(s, replacement.replace_spaces()) + + # Finally, restart the parsing with key=value separated by (multiple) spaces. + tags = self._multiple_space_separated_key_value_parser(modified_val) + if tags is not None: + for key, value in tags.items(): + new_value = value + text_objects = [obj for obj in quoted_strings_with_spaces_objects if obj.modified_text == value] + if len(text_objects) > 0: + new_value = text_objects[0].restore_spaces() + self._add_value(result, _unquote_wrapped_quotes(key), _unquote_wrapped_quotes(new_value)) + else: + # Otherwise, fall back to the original mechanism. + groups = re.findall(self._pattern, val) + + if not groups: + fail = True + for group in groups: + key, v = group + self._add_value(result, _unquote_wrapped_quotes(key), _unquote_wrapped_quotes(v)) if fail: return self.fail( @@ -286,6 +337,48 @@ def _space_separated_key_value_parser(tag_value): tags_dict = {**tags_dict, **parsed_tag} return True, tags_dict + @staticmethod + def _multiple_space_separated_key_value_parser(tag_value): + """ + Method to parse space separated `Key1=Value1 Key2=Value2` type tags without using regex. + Parameters + ---------- + tag_value + """ + tags_dict = {} + for value in tag_value.split(): + parsed, parsed_tag = CfnTags._standard_key_value_parser(value) + if not parsed: + return None + tags_dict.update(parsed_tag) + return tags_dict + + @staticmethod + def _replace_spaces(text, replacement="_"): + """ + Replace spaces in a text with a replacement together with its original locations. + Input: "test 1" + Output: "test_1" [4] + """ + space_positions = [i for i, char in enumerate(text) if char == " "] + modified = text.replace(" ", replacement) + + return modified, space_positions + + @staticmethod + def _restore_spaces(modified_text, space_positions, replacement="_"): + """ + Restore spaces in a text from a original space locations. + Input: "test_1" [4] + Output: "test 1" + """ + text_list = list(modified_text) + + for pos in space_positions: + text_list[pos] = " " + + return "".join(text_list) + class SigningProfilesOptionType(click.ParamType): """ @@ -560,3 +653,34 @@ def convert( ) return {resource_id: [excluded_path]} + + +class TextWithSpaces: + def __init__(self, text) -> None: + self.text = text + self.modified_text = text + self.space_positions = [] # type: List[int] + + def replace_spaces(self, replacement="_"): + """ + Replace spaces in a text with a replacement together with its original locations. + Input: "test 1" + Output: "test_1" [4] + """ + self.space_positions = [i for i, char in enumerate(self.text) if char == " "] + self.modified_text = self.text.replace(" ", replacement) + + return self.modified_text + + def restore_spaces(self): + """ + Restore spaces in a text from a original space locations. + Input: "test_1" [4] + Output: "test 1" + """ + text_list = list(self.modified_text) + + for pos in self.space_positions: + text_list[pos] = " " + + return "".join(text_list) diff --git a/tests/unit/cli/test_types.py b/tests/unit/cli/test_types.py index 8edab7cb9b..f6943107f6 100644 --- a/tests/unit/cli/test_types.py +++ b/tests/unit/cli/test_types.py @@ -238,6 +238,14 @@ def test_must_fail_on_invalid_format(self, input): ["stage=int", "company:application=awesome-service", "company:department=engineering"], {"stage": "int", "company:application": "awesome-service", "company:department": "engineering"}, ), + # input as string with multiple key-values including spaces + (('tag1="son of anton" tag2="company abc"',), {"tag1": "son of anton", "tag2": "company abc"}), + (('tag1="son of anton" tag2="company abc"',), {"tag1": "son of anton", "tag2": "company abc"}), + (('\'tag1="son of anton" tag2="company abc"\'',), {"tag1": "son of anton", "tag2": "company abc"}), + ( + ('tag1="son of anton" tag2="company abc" tag:3="dummy tag"',), + {"tag1": "son of anton", "tag2": "company abc", "tag:3": "dummy tag"}, + ), ] ) def test_successful_parsing(self, input, expected):