|  | @@ -1,6 +1,8 @@
 | 
	
		
			
				|  |  |  import functools
 | 
	
		
			
				|  |  | +import itertools
 | 
	
		
			
				|  |  |  import json
 | 
	
		
			
				|  |  |  import logging
 | 
	
		
			
				|  |  | +import operator
 | 
	
		
			
				|  |  |  import os
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  import ruamel.yaml
 | 
	
	
		
			
				|  | @@ -8,34 +10,61 @@ import ruamel.yaml
 | 
	
		
			
				|  |  |  logger = logging.getLogger(__name__)
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +def probe_and_include_file(filename, include_directories):
 | 
	
		
			
				|  |  | +    '''
 | 
	
		
			
				|  |  | +    Given a filename to include and a list of include directories to search for matching files,
 | 
	
		
			
				|  |  | +    probe for the file, load it, and return the loaded configuration as a data structure of nested
 | 
	
		
			
				|  |  | +    dicts, lists, etc.
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    Raise FileNotFoundError if the included file was not found.
 | 
	
		
			
				|  |  | +    '''
 | 
	
		
			
				|  |  | +    expanded_filename = os.path.expanduser(filename)
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    if os.path.isabs(expanded_filename):
 | 
	
		
			
				|  |  | +        return load_configuration(expanded_filename)
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    candidate_filenames = {
 | 
	
		
			
				|  |  | +        os.path.join(directory, expanded_filename) for directory in include_directories
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    for candidate_filename in candidate_filenames:
 | 
	
		
			
				|  |  | +        if os.path.exists(candidate_filename):
 | 
	
		
			
				|  |  | +            return load_configuration(candidate_filename)
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    raise FileNotFoundError(
 | 
	
		
			
				|  |  | +        f'Could not find include {filename} at {" or ".join(candidate_filenames)}'
 | 
	
		
			
				|  |  | +    )
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  |  def include_configuration(loader, filename_node, include_directory):
 | 
	
		
			
				|  |  |      '''
 | 
	
		
			
				|  |  | -    Given a ruamel.yaml.loader.Loader, a ruamel.yaml.serializer.ScalarNode containing the included
 | 
	
		
			
				|  |  | -    filename, and an include directory path to search for matching files, load the given YAML
 | 
	
		
			
				|  |  | -    filename (ignoring the given loader so we can use our own) and return its contents as a data
 | 
	
		
			
				|  |  | -    structure of nested dicts and lists. If the filename is relative, probe for it within 1. the
 | 
	
		
			
				|  |  | -    current working directory and 2. the given include directory.
 | 
	
		
			
				|  |  | +    Given a ruamel.yaml.loader.Loader, a ruamel.yaml.nodes.ScalarNode containing the included
 | 
	
		
			
				|  |  | +    filename (or a list containing multiple such filenames), and an include directory path to search
 | 
	
		
			
				|  |  | +    for matching files, load the given YAML filenames (ignoring the given loader so we can use our
 | 
	
		
			
				|  |  | +    own) and return their contents as data structure of nested dicts, lists, etc. If the given
 | 
	
		
			
				|  |  | +    filename node's value is a scalar string, then the return value will be a single value. But if
 | 
	
		
			
				|  |  | +    the given node value is a list, then the return value will be a list of values, one per loaded
 | 
	
		
			
				|  |  | +    configuration file.
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    If a filename is relative, probe for it within 1. the current working directory and 2. the given
 | 
	
		
			
				|  |  | +    include directory.
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      Raise FileNotFoundError if an included file was not found.
 | 
	
		
			
				|  |  |      '''
 | 
	
		
			
				|  |  |      include_directories = [os.getcwd(), os.path.abspath(include_directory)]
 | 
	
		
			
				|  |  | -    include_filename = os.path.expanduser(filename_node.value)
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    if not os.path.isabs(include_filename):
 | 
	
		
			
				|  |  | -        candidate_filenames = [
 | 
	
		
			
				|  |  | -            os.path.join(directory, include_filename) for directory in include_directories
 | 
	
		
			
				|  |  | -        ]
 | 
	
		
			
				|  |  | +    if isinstance(filename_node.value, str):
 | 
	
		
			
				|  |  | +        return probe_and_include_file(filename_node.value, include_directories)
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -        for candidate_filename in candidate_filenames:
 | 
	
		
			
				|  |  | -            if os.path.exists(candidate_filename):
 | 
	
		
			
				|  |  | -                include_filename = candidate_filename
 | 
	
		
			
				|  |  | -                break
 | 
	
		
			
				|  |  | -        else:
 | 
	
		
			
				|  |  | -            raise FileNotFoundError(
 | 
	
		
			
				|  |  | -                f'Could not find include {filename_node.value} at {" or ".join(candidate_filenames)}'
 | 
	
		
			
				|  |  | -            )
 | 
	
		
			
				|  |  | +    if isinstance(filename_node.value, list):
 | 
	
		
			
				|  |  | +        return [
 | 
	
		
			
				|  |  | +            probe_and_include_file(node.value, include_directories)
 | 
	
		
			
				|  |  | +            for node in reversed(filename_node.value)
 | 
	
		
			
				|  |  | +        ]
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    return load_configuration(include_filename)
 | 
	
		
			
				|  |  | +    raise ValueError(
 | 
	
		
			
				|  |  | +        f'!include value type ({type(filename_node.value)}) is not supported; use a single filename or a list of filenames'
 | 
	
		
			
				|  |  | +    )
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  def raise_retain_node_error(loader, node):
 | 
	
	
		
			
				|  | @@ -53,7 +82,7 @@ def raise_retain_node_error(loader, node):
 | 
	
		
			
				|  |  |              'The !retain tag may only be used within a configuration file containing a merged !include tag.'
 | 
	
		
			
				|  |  |          )
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    raise ValueError('The !retain tag may only be used on a YAML mapping or sequence.')
 | 
	
		
			
				|  |  | +    raise ValueError('The !retain tag may only be used on a mapping or list.')
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  def raise_omit_node_error(loader, node):
 | 
	
	
		
			
				|  | @@ -65,14 +94,14 @@ def raise_omit_node_error(loader, node):
 | 
	
		
			
				|  |  |      tags are handled by deep_merge_nodes() below.
 | 
	
		
			
				|  |  |      '''
 | 
	
		
			
				|  |  |      raise ValueError(
 | 
	
		
			
				|  |  | -        'The !omit tag may only be used on a scalar (e.g., string) list element within a configuration file containing a merged !include tag.'
 | 
	
		
			
				|  |  | +        'The !omit tag may only be used on a scalar (e.g., string) or list element within a configuration file containing a merged !include tag.'
 | 
	
		
			
				|  |  |      )
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  class Include_constructor(ruamel.yaml.SafeConstructor):
 | 
	
		
			
				|  |  |      '''
 | 
	
		
			
				|  |  |      A YAML "constructor" (a ruamel.yaml concept) that supports a custom "!include" tag for including
 | 
	
		
			
				|  |  | -    separate YAML configuration files. Example syntax: `retention: !include common.yaml`
 | 
	
		
			
				|  |  | +    separate YAML configuration files. Example syntax: `option: !include common.yaml`
 | 
	
		
			
				|  |  |      '''
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      def __init__(self, preserve_quotes=None, loader=None, include_directory=None):
 | 
	
	
		
			
				|  | @@ -81,6 +110,9 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
 | 
	
		
			
				|  |  |              '!include',
 | 
	
		
			
				|  |  |              functools.partial(include_configuration, include_directory=include_directory),
 | 
	
		
			
				|  |  |          )
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        # These are catch-all error handlers for tags that don't get applied and removed by
 | 
	
		
			
				|  |  | +        # deep_merge_nodes() below.
 | 
	
		
			
				|  |  |          self.add_constructor('!retain', raise_retain_node_error)
 | 
	
		
			
				|  |  |          self.add_constructor('!omit', raise_omit_node_error)
 | 
	
		
			
				|  |  |  
 | 
	
	
		
			
				|  | @@ -90,8 +122,8 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
 | 
	
		
			
				|  |  |          using the YAML '<<' merge key. Example syntax:
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          ```
 | 
	
		
			
				|  |  | -        retention:
 | 
	
		
			
				|  |  | -            keep_daily: 1
 | 
	
		
			
				|  |  | +        option:
 | 
	
		
			
				|  |  | +            sub_option: 1
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          <<: !include common.yaml
 | 
	
		
			
				|  |  |          ```
 | 
	
	
		
			
				|  | @@ -104,9 +136,15 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          for index, (key_node, value_node) in enumerate(node.value):
 | 
	
		
			
				|  |  |              if key_node.tag == u'tag:yaml.org,2002:merge' and value_node.tag == '!include':
 | 
	
		
			
				|  |  | -                included_value = representer.represent_data(self.construct_object(value_node))
 | 
	
		
			
				|  |  | -                node.value[index] = (key_node, included_value)
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | +                # Replace the merge include with a sequence of included configuration nodes ready
 | 
	
		
			
				|  |  | +                # for merging. The construct_object() call here triggers include_configuration()
 | 
	
		
			
				|  |  | +                # among other constructors.
 | 
	
		
			
				|  |  | +                node.value[index] = (
 | 
	
		
			
				|  |  | +                    key_node,
 | 
	
		
			
				|  |  | +                    representer.represent_data(self.construct_object(value_node)),
 | 
	
		
			
				|  |  | +                )
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        # This super().flatten_mapping() call actually performs "<<" merges.
 | 
	
		
			
				|  |  |          super(Include_constructor, self).flatten_mapping(node)
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          node.value = deep_merge_nodes(node.value)
 | 
	
	
		
			
				|  | @@ -138,7 +176,12 @@ def load_configuration(filename):
 | 
	
		
			
				|  |  |          file_contents = file.read()
 | 
	
		
			
				|  |  |          config = yaml.load(file_contents)
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -        if config and 'constants' in config:
 | 
	
		
			
				|  |  | +        try:
 | 
	
		
			
				|  |  | +            has_constants = bool(config and 'constants' in config)
 | 
	
		
			
				|  |  | +        except TypeError:
 | 
	
		
			
				|  |  | +            has_constants = False
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        if has_constants:
 | 
	
		
			
				|  |  |              for key, value in config['constants'].items():
 | 
	
		
			
				|  |  |                  value = json.dumps(value)
 | 
	
		
			
				|  |  |                  file_contents = file_contents.replace(f'{{{key}}}', value.strip('"'))
 | 
	
	
		
			
				|  | @@ -149,53 +192,92 @@ def load_configuration(filename):
 | 
	
		
			
				|  |  |          return config
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -def filter_omitted_nodes(nodes):
 | 
	
		
			
				|  |  | +def filter_omitted_nodes(nodes, values):
 | 
	
		
			
				|  |  |      '''
 | 
	
		
			
				|  |  | -    Given a list of nodes, return a filtered list omitting any nodes with an "!omit" tag or with a
 | 
	
		
			
				|  |  | -    value matching such nodes.
 | 
	
		
			
				|  |  | +    Given a nested borgmatic configuration data structure as a list of tuples in the form of:
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    [
 | 
	
		
			
				|  |  | +        (
 | 
	
		
			
				|  |  | +            ruamel.yaml.nodes.ScalarNode as a key,
 | 
	
		
			
				|  |  | +            ruamel.yaml.nodes.MappingNode or other Node as a value,
 | 
	
		
			
				|  |  | +        ),
 | 
	
		
			
				|  |  | +        ...
 | 
	
		
			
				|  |  | +    ]
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    ... and a combined list of all values for those nodes, return a filtered list of the values,
 | 
	
		
			
				|  |  | +    omitting any that have an "!omit" tag (or with a value matching such nodes).
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    But if only a single node is given, bail and return the given values unfiltered, as "!omit" only
 | 
	
		
			
				|  |  | +    applies when there are merge includes (and therefore multiple nodes).
 | 
	
		
			
				|  |  |      '''
 | 
	
		
			
				|  |  | -    omitted_values = tuple(node.value for node in nodes if node.tag == '!omit')
 | 
	
		
			
				|  |  | +    if len(nodes) <= 1:
 | 
	
		
			
				|  |  | +        return values
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    return [node for node in nodes if node.value not in omitted_values]
 | 
	
		
			
				|  |  | +    omitted_values = tuple(node.value for node in values if node.tag == '!omit')
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +    return [node for node in values if node.value not in omitted_values]
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -DELETED_NODE = object()
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +def merge_values(nodes):
 | 
	
		
			
				|  |  | +    '''
 | 
	
		
			
				|  |  | +    Given a nested borgmatic configuration data structure as a list of tuples in the form of:
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    [
 | 
	
		
			
				|  |  | +        (
 | 
	
		
			
				|  |  | +            ruamel.yaml.nodes.ScalarNode as a key,
 | 
	
		
			
				|  |  | +            ruamel.yaml.nodes.MappingNode or other Node as a value,
 | 
	
		
			
				|  |  | +        ),
 | 
	
		
			
				|  |  | +        ...
 | 
	
		
			
				|  |  | +    ]
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    ... merge its sequence or mapping node values and return the result. For sequence nodes, this
 | 
	
		
			
				|  |  | +    means appending together its contained lists. For mapping nodes, it means merging its contained
 | 
	
		
			
				|  |  | +    dicts.
 | 
	
		
			
				|  |  | +    '''
 | 
	
		
			
				|  |  | +    return functools.reduce(operator.add, (value.value for key, value in nodes))
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  def deep_merge_nodes(nodes):
 | 
	
		
			
				|  |  |      '''
 | 
	
		
			
				|  |  |      Given a nested borgmatic configuration data structure as a list of tuples in the form of:
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +    [
 | 
	
		
			
				|  |  |          (
 | 
	
		
			
				|  |  |              ruamel.yaml.nodes.ScalarNode as a key,
 | 
	
		
			
				|  |  |              ruamel.yaml.nodes.MappingNode or other Node as a value,
 | 
	
		
			
				|  |  |          ),
 | 
	
		
			
				|  |  | +        ...
 | 
	
		
			
				|  |  | +    ]
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    ... deep merge any node values corresponding to duplicate keys and return the result. If
 | 
	
		
			
				|  |  | -    there are colliding keys with non-MappingNode values (e.g., integers or strings), the last
 | 
	
		
			
				|  |  | -    of the values wins.
 | 
	
		
			
				|  |  | +    ... deep merge any node values corresponding to duplicate keys and return the result. The
 | 
	
		
			
				|  |  | +    purpose of merging like this is to support, for instance, merging one borgmatic configuration
 | 
	
		
			
				|  |  | +    file into another for reuse, such that a configuration option with sub-options does not
 | 
	
		
			
				|  |  | +    completely replace the corresponding option in a merged file.
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    If there are colliding keys with scalar values (e.g., integers or strings), the last of the
 | 
	
		
			
				|  |  | +    values wins.
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      For instance, given node values of:
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          [
 | 
	
		
			
				|  |  |              (
 | 
	
		
			
				|  |  | -                ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
 | 
	
		
			
				|  |  | +                ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
 | 
	
		
			
				|  |  |                  MappingNode(tag='tag:yaml.org,2002:map', value=[
 | 
	
		
			
				|  |  |                      (
 | 
	
		
			
				|  |  | -                        ScalarNode(tag='tag:yaml.org,2002:str', value='keep_hourly'),
 | 
	
		
			
				|  |  | -                        ScalarNode(tag='tag:yaml.org,2002:int', value='24')
 | 
	
		
			
				|  |  | +                        ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option1'),
 | 
	
		
			
				|  |  | +                        ScalarNode(tag='tag:yaml.org,2002:int', value='1')
 | 
	
		
			
				|  |  |                      ),
 | 
	
		
			
				|  |  |                      (
 | 
	
		
			
				|  |  | -                        ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
 | 
	
		
			
				|  |  | -                        ScalarNode(tag='tag:yaml.org,2002:int', value='7')
 | 
	
		
			
				|  |  | +                        ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'),
 | 
	
		
			
				|  |  | +                        ScalarNode(tag='tag:yaml.org,2002:int', value='2')
 | 
	
		
			
				|  |  |                      ),
 | 
	
		
			
				|  |  |                  ]),
 | 
	
		
			
				|  |  |              ),
 | 
	
		
			
				|  |  |              (
 | 
	
		
			
				|  |  | -                ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
 | 
	
		
			
				|  |  | +                ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
 | 
	
		
			
				|  |  |                  MappingNode(tag='tag:yaml.org,2002:map', value=[
 | 
	
		
			
				|  |  |                      (
 | 
	
		
			
				|  |  | -                        ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
 | 
	
		
			
				|  |  | +                        ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'),
 | 
	
		
			
				|  |  |                          ScalarNode(tag='tag:yaml.org,2002:int', value='5')
 | 
	
		
			
				|  |  |                      ),
 | 
	
		
			
				|  |  |                  ]),
 | 
	
	
		
			
				|  | @@ -206,88 +288,95 @@ def deep_merge_nodes(nodes):
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |          [
 | 
	
		
			
				|  |  |              (
 | 
	
		
			
				|  |  | -                ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
 | 
	
		
			
				|  |  | +                ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
 | 
	
		
			
				|  |  |                  MappingNode(tag='tag:yaml.org,2002:map', value=[
 | 
	
		
			
				|  |  |                      (
 | 
	
		
			
				|  |  | -                        ScalarNode(tag='tag:yaml.org,2002:str', value='keep_hourly'),
 | 
	
		
			
				|  |  | -                        ScalarNode(tag='tag:yaml.org,2002:int', value='24')
 | 
	
		
			
				|  |  | +                        ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option1'),
 | 
	
		
			
				|  |  | +                        ScalarNode(tag='tag:yaml.org,2002:int', value='1')
 | 
	
		
			
				|  |  |                      ),
 | 
	
		
			
				|  |  |                      (
 | 
	
		
			
				|  |  | -                        ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
 | 
	
		
			
				|  |  | +                        ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'),
 | 
	
		
			
				|  |  |                          ScalarNode(tag='tag:yaml.org,2002:int', value='5')
 | 
	
		
			
				|  |  |                      ),
 | 
	
		
			
				|  |  |                  ]),
 | 
	
		
			
				|  |  |              ),
 | 
	
		
			
				|  |  |          ]
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    If a mapping or sequence node has a YAML "!retain" tag, then that node is not merged.
 | 
	
		
			
				|  |  | +    This function supports multi-way merging, meaning that if the same option name exists three or
 | 
	
		
			
				|  |  | +    more times (at the same scope level), all of those instances get merged together.
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    The purpose of deep merging like this is to support, for instance, merging one borgmatic
 | 
	
		
			
				|  |  | -    configuration file into another for reuse, such that a configuration option with sub-options
 | 
	
		
			
				|  |  | -    does not completely replace the corresponding option in a merged file.
 | 
	
		
			
				|  |  | +    If a mapping or sequence node has a YAML "!retain" tag, then that node is not merged.
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    Raise ValueError if a merge is implied using two incompatible types.
 | 
	
		
			
				|  |  | +    Raise ValueError if a merge is implied using multiple incompatible types.
 | 
	
		
			
				|  |  |      '''
 | 
	
		
			
				|  |  | -    # Map from original node key/value to the replacement merged node. DELETED_NODE as a replacement
 | 
	
		
			
				|  |  | -    # node indications deletion.
 | 
	
		
			
				|  |  | -    replaced_nodes = {}
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -    # To find nodes that require merging, compare each node with each other node.
 | 
	
		
			
				|  |  | -    for a_key, a_value in nodes:
 | 
	
		
			
				|  |  | -        for b_key, b_value in nodes:
 | 
	
		
			
				|  |  | -            # If we've already considered one of the nodes for merging, skip it.
 | 
	
		
			
				|  |  | -            if (a_key, a_value) in replaced_nodes or (b_key, b_value) in replaced_nodes:
 | 
	
		
			
				|  |  | -                continue
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -            # If the keys match and the values are different, we need to merge these two A and B nodes.
 | 
	
		
			
				|  |  | -            if a_key.tag == b_key.tag and a_key.value == b_key.value and a_value != b_value:
 | 
	
		
			
				|  |  | -                if not type(a_value) is type(b_value):
 | 
	
		
			
				|  |  | -                    raise ValueError(
 | 
	
		
			
				|  |  | -                        f'Incompatible types found when trying to merge "{a_key.value}:" values across configuration files: {type(a_value).id} and {type(b_value).id}'
 | 
	
		
			
				|  |  | +    merged_nodes = []
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    def get_node_key_name(node):
 | 
	
		
			
				|  |  | +        return node[0].value
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    # Bucket the nodes by their keys. Then merge all of the values sharing the same key.
 | 
	
		
			
				|  |  | +    for key_name, grouped_nodes in itertools.groupby(
 | 
	
		
			
				|  |  | +        sorted(nodes, key=get_node_key_name), get_node_key_name
 | 
	
		
			
				|  |  | +    ):
 | 
	
		
			
				|  |  | +        grouped_nodes = list(grouped_nodes)
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        # The merged node inherits its attributes from the final node in the group.
 | 
	
		
			
				|  |  | +        (last_node_key, last_node_value) = grouped_nodes[-1]
 | 
	
		
			
				|  |  | +        value_types = set(type(value) for (_, value) in grouped_nodes)
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        if len(value_types) > 1:
 | 
	
		
			
				|  |  | +            raise ValueError(
 | 
	
		
			
				|  |  | +                f'Incompatible types found when trying to merge "{key_name}:" values across configuration files: {", ".join(value_type.id for value_type in value_types)}'
 | 
	
		
			
				|  |  | +            )
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        # If we're dealing with MappingNodes, recurse and merge its values as well.
 | 
	
		
			
				|  |  | +        if ruamel.yaml.nodes.MappingNode in value_types:
 | 
	
		
			
				|  |  | +            # A "!retain" tag says to skip deep merging for this node. Replace the tag so
 | 
	
		
			
				|  |  | +            # downstream schema validation doesn't break on our application-specific tag.
 | 
	
		
			
				|  |  | +            if last_node_value.tag == '!retain' and len(grouped_nodes) > 1:
 | 
	
		
			
				|  |  | +                last_node_value.tag = 'tag:yaml.org,2002:map'
 | 
	
		
			
				|  |  | +                merged_nodes.append((last_node_key, last_node_value))
 | 
	
		
			
				|  |  | +            else:
 | 
	
		
			
				|  |  | +                merged_nodes.append(
 | 
	
		
			
				|  |  | +                    (
 | 
	
		
			
				|  |  | +                        last_node_key,
 | 
	
		
			
				|  |  | +                        ruamel.yaml.nodes.MappingNode(
 | 
	
		
			
				|  |  | +                            tag=last_node_value.tag,
 | 
	
		
			
				|  |  | +                            value=deep_merge_nodes(merge_values(grouped_nodes)),
 | 
	
		
			
				|  |  | +                            start_mark=last_node_value.start_mark,
 | 
	
		
			
				|  |  | +                            end_mark=last_node_value.end_mark,
 | 
	
		
			
				|  |  | +                            flow_style=last_node_value.flow_style,
 | 
	
		
			
				|  |  | +                            comment=last_node_value.comment,
 | 
	
		
			
				|  |  | +                            anchor=last_node_value.anchor,
 | 
	
		
			
				|  |  | +                        ),
 | 
	
		
			
				|  |  |                      )
 | 
	
		
			
				|  |  | +                )
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -                # Since we're merging into the B node, consider the A node a duplicate and remove it.
 | 
	
		
			
				|  |  | -                replaced_nodes[(a_key, a_value)] = DELETED_NODE
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -                # If we're dealing with MappingNodes, recurse and merge its values as well.
 | 
	
		
			
				|  |  | -                if isinstance(b_value, ruamel.yaml.nodes.MappingNode):
 | 
	
		
			
				|  |  | -                    # A "!retain" tag says to skip deep merging for this node. Replace the tag so
 | 
	
		
			
				|  |  | -                    # downstream schema validation doesn't break on our application-specific tag.
 | 
	
		
			
				|  |  | -                    if b_value.tag == '!retain':
 | 
	
		
			
				|  |  | -                        b_value.tag = 'tag:yaml.org,2002:map'
 | 
	
		
			
				|  |  | -                    else:
 | 
	
		
			
				|  |  | -                        replaced_nodes[(b_key, b_value)] = (
 | 
	
		
			
				|  |  | -                            b_key,
 | 
	
		
			
				|  |  | -                            ruamel.yaml.nodes.MappingNode(
 | 
	
		
			
				|  |  | -                                tag=b_value.tag,
 | 
	
		
			
				|  |  | -                                value=deep_merge_nodes(a_value.value + b_value.value),
 | 
	
		
			
				|  |  | -                                start_mark=b_value.start_mark,
 | 
	
		
			
				|  |  | -                                end_mark=b_value.end_mark,
 | 
	
		
			
				|  |  | -                                flow_style=b_value.flow_style,
 | 
	
		
			
				|  |  | -                                comment=b_value.comment,
 | 
	
		
			
				|  |  | -                                anchor=b_value.anchor,
 | 
	
		
			
				|  |  | -                            ),
 | 
	
		
			
				|  |  | -                        )
 | 
	
		
			
				|  |  | -                # If we're dealing with SequenceNodes, merge by appending one sequence to the other.
 | 
	
		
			
				|  |  | -                elif isinstance(b_value, ruamel.yaml.nodes.SequenceNode):
 | 
	
		
			
				|  |  | -                    # A "!retain" tag says to skip deep merging for this node. Replace the tag so
 | 
	
		
			
				|  |  | -                    # downstream schema validation doesn't break on our application-specific tag.
 | 
	
		
			
				|  |  | -                    if b_value.tag == '!retain':
 | 
	
		
			
				|  |  | -                        b_value.tag = 'tag:yaml.org,2002:seq'
 | 
	
		
			
				|  |  | -                    else:
 | 
	
		
			
				|  |  | -                        replaced_nodes[(b_key, b_value)] = (
 | 
	
		
			
				|  |  | -                            b_key,
 | 
	
		
			
				|  |  | -                            ruamel.yaml.nodes.SequenceNode(
 | 
	
		
			
				|  |  | -                                tag=b_value.tag,
 | 
	
		
			
				|  |  | -                                value=filter_omitted_nodes(a_value.value + b_value.value),
 | 
	
		
			
				|  |  | -                                start_mark=b_value.start_mark,
 | 
	
		
			
				|  |  | -                                end_mark=b_value.end_mark,
 | 
	
		
			
				|  |  | -                                flow_style=b_value.flow_style,
 | 
	
		
			
				|  |  | -                                comment=b_value.comment,
 | 
	
		
			
				|  |  | -                                anchor=b_value.anchor,
 | 
	
		
			
				|  |  | -                            ),
 | 
	
		
			
				|  |  | -                        )
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -    return [
 | 
	
		
			
				|  |  | -        replaced_nodes.get(node, node) for node in nodes if replaced_nodes.get(node) != DELETED_NODE
 | 
	
		
			
				|  |  | -    ]
 | 
	
		
			
				|  |  | +            continue
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        # If we're dealing with SequenceNodes, merge by appending sequences together.
 | 
	
		
			
				|  |  | +        if ruamel.yaml.nodes.SequenceNode in value_types:
 | 
	
		
			
				|  |  | +            if last_node_value.tag == '!retain' and len(grouped_nodes) > 1:
 | 
	
		
			
				|  |  | +                last_node_value.tag = 'tag:yaml.org,2002:seq'
 | 
	
		
			
				|  |  | +                merged_nodes.append((last_node_key, last_node_value))
 | 
	
		
			
				|  |  | +            else:
 | 
	
		
			
				|  |  | +                merged_nodes.append(
 | 
	
		
			
				|  |  | +                    (
 | 
	
		
			
				|  |  | +                        last_node_key,
 | 
	
		
			
				|  |  | +                        ruamel.yaml.nodes.SequenceNode(
 | 
	
		
			
				|  |  | +                            tag=last_node_value.tag,
 | 
	
		
			
				|  |  | +                            value=filter_omitted_nodes(grouped_nodes, merge_values(grouped_nodes)),
 | 
	
		
			
				|  |  | +                            start_mark=last_node_value.start_mark,
 | 
	
		
			
				|  |  | +                            end_mark=last_node_value.end_mark,
 | 
	
		
			
				|  |  | +                            flow_style=last_node_value.flow_style,
 | 
	
		
			
				|  |  | +                            comment=last_node_value.comment,
 | 
	
		
			
				|  |  | +                            anchor=last_node_value.anchor,
 | 
	
		
			
				|  |  | +                        ),
 | 
	
		
			
				|  |  | +                    )
 | 
	
		
			
				|  |  | +                )
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +            continue
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        merged_nodes.append((last_node_key, last_node_value))
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    return merged_nodes
 |