diff --git a/samcli/yamlhelper.py b/samcli/yamlhelper.py index c4d3365361..6e21bba56c 100644 --- a/samcli/yamlhelper.py +++ b/samcli/yamlhelper.py @@ -1,13 +1,30 @@ +# Copyright 2012-2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. """ -Helper to be able to parse/dump YAML files +YAML helper, sourced from the AWS CLI + +https://github.com/aws/aws-cli/blob/develop/awscli/customizations/cloudformation/yamlhelper.py """ +# pylint: disable=too-many-ancestors import json -import six +from botocore.compat import OrderedDict import yaml from yaml.resolver import ScalarNode, SequenceNode +import six + def intrinsics_multi_constructor(loader, tag_prefix, node): """ @@ -46,13 +63,28 @@ def intrinsics_multi_constructor(loader, tag_prefix, node): return {cfntag: value} +def _dict_representer(dumper, data): + return dumper.represent_dict(data.items()) + + def yaml_dump(dict_to_dump): """ Dumps the dictionary as a YAML document :param dict_to_dump: :return: """ - return yaml.safe_dump(dict_to_dump, default_flow_style=False) + FlattenAliasDumper.add_representer(OrderedDict, _dict_representer) + return yaml.dump( + dict_to_dump, + default_flow_style=False, + Dumper=FlattenAliasDumper, + ) + + +def _dict_constructor(loader, node): + # Necessary in order to make yaml merge tags work + loader.flatten_mapping(node) + return OrderedDict(loader.construct_pairs(node)) def yaml_parse(yamlstr): @@ -61,7 +93,14 @@ def yaml_parse(yamlstr): # PyYAML doesn't support json as well as it should, so if the input # is actually just json it is better to parse it with the standard # json parser. - return json.loads(yamlstr) + return json.loads(yamlstr, object_pairs_hook=OrderedDict) except ValueError: - yaml.SafeLoader.add_multi_constructor("!", intrinsics_multi_constructor) + yaml.SafeLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _dict_constructor) + yaml.SafeLoader.add_multi_constructor( + "!", intrinsics_multi_constructor) return yaml.safe_load(yamlstr) + + +class FlattenAliasDumper(yaml.SafeDumper): + def ignore_aliases(self, data): + return True diff --git a/tests/unit/test_yamlhelper.py b/tests/unit/test_yamlhelper.py index 32adb6c72a..ece07db684 100644 --- a/tests/unit/test_yamlhelper.py +++ b/tests/unit/test_yamlhelper.py @@ -1,6 +1,16 @@ -""" -Helper to be able to parse/dump YAML files -""" +# Copyright 2014 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0e +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +from botocore.compat import OrderedDict from unittest import TestCase from samcli.yamlhelper import yaml_parse, yaml_dump @@ -48,17 +58,18 @@ class TestYaml(TestCase): def test_yaml_with_tags(self): output = yaml_parse(self.yaml_with_tags) - self.assertEquals(self.parsed_yaml_dict, output) + self.assertEqual(self.parsed_yaml_dict, output) # Make sure formatter and parser work well with each other formatted_str = yaml_dump(output) output_again = yaml_parse(formatted_str) - self.assertEquals(output, output_again) + self.assertEqual(output, output_again) def test_yaml_getatt(self): - # This is an invalid syntax for !GetAtt. But make sure the code does not crash when we encouter this syntax - # Let CloudFormation interpret this value at runtime - input = """ + # This is an invalid syntax for !GetAtt. But make sure the code does + # not crash when we encounter this syntax. Let CloudFormation + # interpret this value at runtime + yaml_input = """ Resource: Key: !GetAtt ["a", "b"] """ @@ -68,13 +79,102 @@ def test_yaml_getatt(self): "Key": { "Fn::GetAtt": ["a", "b"] } + } } - actual_output = yaml_parse(input) - self.assertEquals(actual_output, output) + actual_output = yaml_parse(yaml_input) + self.assertEqual(actual_output, output) def test_parse_json_with_tabs(self): template = '{\n\t"foo": "bar"\n}' output = yaml_parse(template) self.assertEqual(output, {'foo': 'bar'}) + + def test_parse_json_preserve_elements_order(self): + input_template = """ + { + "B_Resource": { + "Key2": { + "Name": "name2" + }, + "Key1": { + "Name": "name1" + } + }, + "A_Resource": { + "Key2": { + "Name": "name2" + }, + "Key1": { + "Name": "name1" + } + } + } + """ + expected_dict = OrderedDict([ + ('B_Resource', OrderedDict([('Key2', {'Name': 'name2'}), ('Key1', {'Name': 'name1'})])), + ('A_Resource', OrderedDict([('Key2', {'Name': 'name2'}), ('Key1', {'Name': 'name1'})])) + ]) + output_dict = yaml_parse(input_template) + self.assertEqual(expected_dict, output_dict) + + def test_parse_yaml_preserve_elements_order(self): + input_template = ( + 'B_Resource:\n' + ' Key2:\n' + ' Name: name2\n' + ' Key1:\n' + ' Name: name1\n' + 'A_Resource:\n' + ' Key2:\n' + ' Name: name2\n' + ' Key1:\n' + ' Name: name1\n' + ) + output_dict = yaml_parse(input_template) + expected_dict = OrderedDict([ + ('B_Resource', OrderedDict([('Key2', {'Name': 'name2'}), ('Key1', {'Name': 'name1'})])), + ('A_Resource', OrderedDict([('Key2', {'Name': 'name2'}), ('Key1', {'Name': 'name1'})])) + ]) + self.assertEqual(expected_dict, output_dict) + + output_template = yaml_dump(output_dict) + self.assertEqual(input_template, output_template) + + def test_yaml_merge_tag(self): + test_yaml = """ + base: &base + property: value + test: + <<: *base + """ + output = yaml_parse(test_yaml) + self.assertTrue(isinstance(output, OrderedDict)) + self.assertEqual(output.get('test').get('property'), 'value') + + def test_unroll_yaml_anchors(self): + properties = { + "Foo": "bar", + "Spam": "eggs", + } + template = { + "Resources": { + "Resource1": {"Properties": properties}, + "Resource2": {"Properties": properties} + } + } + + expected = ( + 'Resources:\n' + ' Resource1:\n' + ' Properties:\n' + ' Foo: bar\n' + ' Spam: eggs\n' + ' Resource2:\n' + ' Properties:\n' + ' Foo: bar\n' + ' Spam: eggs\n' + ) + actual = yaml_dump(template) + self.assertEqual(actual, expected)