Skip to content

Commit 677ac55

Browse files
committed
Use custom YAML emitter for CloudFormation dumps
1 parent a5b5362 commit 677ac55

File tree

4 files changed

+123
-3
lines changed

4 files changed

+123
-3
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"type": "bugfix",
3+
"category": "cloudformation package",
4+
"description": "Restore prior YAML dump behavior when breaking lines."
5+
}

awscli/customizations/cloudformation/yamlhelper.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from botocore.compat import OrderedDict, json
1717
from ruamel.yaml.resolver import ScalarNode, SequenceNode
1818

19-
from awscli.utils import dump_yaml_to_str
19+
from awscli.utils import SafeLineBreakEmitter, dump_yaml_to_str
2020

2121

2222
def intrinsics_multi_constructor(loader, tag_prefix, node):
@@ -87,6 +87,7 @@ def yaml_dump(dict_to_dump):
8787

8888
yaml = ruamel.yaml.YAML(typ="safe", pure=True)
8989
yaml.default_flow_style = False
90+
yaml.Emitter = SafeLineBreakEmitter
9091
yaml.Representer = FlattenAliasRepresenter
9192
_add_yaml_1_1_boolean_resolvers(yaml.Resolver)
9293
yaml.Representer.add_representer(OrderedDict, _dict_representer)

awscli/utils.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import sys
2222
from subprocess import PIPE, Popen
2323

24+
import ruamel.yaml
2425
from botocore.configprovider import BaseProvider
2526
from botocore.useragent import UserAgentComponent
2627
from botocore.utils import (
@@ -488,6 +489,95 @@ def dump_yaml_to_str(yaml, data):
488489
return stream.getvalue()
489490

490491

492+
class SafeLineBreakEmitter(ruamel.yaml.emitter.Emitter):
493+
"""Emitter that always uses backslash escapes at line breaks in
494+
double-quoted scalars.
495+
496+
Derived from ruamel.yaml's Emitter.write_double_quoted (MIT license).
497+
498+
ruamel.yaml >= 0.17.23 added a heuristic that sometimes omits the
499+
trailing backslash when wrapping long double-quoted strings, relying
500+
on YAML line-folding rules to preserve the value. Consumers that don't
501+
properly parse the value may experience failures.
502+
503+
This subclass restores the pre-0.17.23 behaviour of unconditionally
504+
emitting a backslash at every line break in double-quoted scalars.
505+
"""
506+
507+
def write_double_quoted(self, text, split=True):
508+
if self.root_context:
509+
if self.requested_indent is not None:
510+
self.write_line_break()
511+
if self.requested_indent != 0:
512+
self.write_indent()
513+
self.write_indicator('"', True)
514+
start = end = 0
515+
while end <= len(text):
516+
ch = None
517+
if end < len(text):
518+
ch = text[end]
519+
if (
520+
ch is None
521+
or ch in '"\\\x85\u2028\u2029\ufeff'
522+
or not (
523+
'\x20' <= ch <= '\x7e'
524+
or (
525+
self.allow_unicode
526+
and (
527+
('\xa0' <= ch <= '\ud7ff')
528+
or ('\ue000' <= ch <= '\ufffd')
529+
or ('\U00010000' <= ch <= '\U0010ffff')
530+
)
531+
)
532+
)
533+
):
534+
if start < end:
535+
data = text[start:end]
536+
self.column += len(data)
537+
if bool(self.encoding):
538+
data = data.encode(self.encoding)
539+
self.stream.write(data)
540+
start = end
541+
if ch is not None:
542+
if ch in self.ESCAPE_REPLACEMENTS:
543+
data = '\\' + self.ESCAPE_REPLACEMENTS[ch]
544+
elif ch <= '\xff':
545+
data = '\\x%02X' % ord(ch)
546+
elif ch <= '\uffff':
547+
data = '\\u%04X' % ord(ch)
548+
else:
549+
data = '\\U%08X' % ord(ch)
550+
self.column += len(data)
551+
if bool(self.encoding):
552+
data = data.encode(self.encoding)
553+
self.stream.write(data)
554+
start = end + 1
555+
if (
556+
0 < end < len(text) - 1
557+
and (ch == ' ' or start >= end)
558+
and self.column + (end - start) > self.best_width
559+
and split
560+
):
561+
data = text[start:end] + '\\'
562+
if start < end:
563+
start = end
564+
self.column += len(data)
565+
if bool(self.encoding):
566+
data = data.encode(self.encoding)
567+
self.stream.write(data)
568+
self.write_indent()
569+
self.whitespace = False
570+
self.indention = False
571+
if text[start] == ' ':
572+
data = '\\'
573+
self.column += len(data)
574+
if bool(self.encoding):
575+
data = data.encode(self.encoding)
576+
self.stream.write(data)
577+
end += 1
578+
self.write_indicator('"', False)
579+
580+
491581
class ShapeWalker:
492582
def walk(self, shape, visitor):
493583
"""Walk through and visit shapes for introspection

tests/unit/test_utils.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
InstanceMetadataRegionFetcher,
3333
LazyPager,
3434
OutputStreamFactory,
35+
SafeLineBreakEmitter,
3536
ShapeRecordingVisitor,
3637
ShapeWalker,
3738
add_command_lineage_to_user_agent_extra,
@@ -644,11 +645,12 @@ def test_can_set_imds_service_endpoint(self):
644645
provider.provide()
645646
args, _ = self._send.call_args
646647
self.assertIn('http://myendpoint/', args[0].url)
647-
648+
648649
def test_can_set_imds_service_endpoint_custom(self):
649650
driver = create_clidriver()
650651
driver.session.set_config_variable(
651-
'ec2_metadata_service_endpoint', 'http://myendpoint')
652+
'ec2_metadata_service_endpoint', 'http://myendpoint'
653+
)
652654
self.add_imds_token_response()
653655
self.add_get_region_imds_response()
654656
provider = IMDSRegionProvider(driver.session)
@@ -859,6 +861,28 @@ def test_dump_to_str(self):
859861
self.assertEqual(result, expected_result)
860862

861863

864+
@pytest.fixture
865+
def yaml_safe_emitter():
866+
yaml = ruamel.yaml.YAML(typ="safe", pure=True)
867+
yaml.default_flow_style = False
868+
yaml.Emitter = SafeLineBreakEmitter
869+
yaml.width = 25
870+
return yaml
871+
872+
873+
class TestSafeLineBreakEmitter:
874+
def test_backslash_at_every_line_break(self, yaml_safe_emitter):
875+
data = {"key": "hello\nworld foo bar baz"}
876+
output = dump_yaml_to_str(yaml_safe_emitter, data)
877+
assert output == 'key: "hello\\nworld foo bar\\\n \\ baz"\n'
878+
879+
def test_roundtrip_with_multiline_content(self, yaml_safe_emitter):
880+
original = "line1\nline2 with spaces\nline3"
881+
output = dump_yaml_to_str(yaml_safe_emitter, {"key": original})
882+
result = ruamel.yaml.YAML(typ="safe", pure=True).load(output)
883+
assert result["key"] == original
884+
885+
862886
class TestShapeWalker(BaseShapeTest):
863887
def setUp(self):
864888
super(TestShapeWalker, self).setUp()

0 commit comments

Comments
 (0)