Search code examples
dictionaryansibledata-manipulation

Ansible: split a dictionary with list values to a list of dictionaries with a single item from the list as value


I need to convert a dictionary with list values into a list of dictionaries.

Given:

my_dict:
  key1: ["111", "222"]
  key2: ["444", "555"]

Desired output:

my_list:
 - key1: "111"
   key2: "444"
 - key1: "222"
   key2: "555"

What I've tried:

  - set_fact:
      my_list: "{{ my_list | default([]) + [{item.0.key: item.1}] }}"
    loop: "{{ my_dict | dict2items | subelements('value') }}"

And what I've got:

[
            {
                "key1": "111"
            },
            {
                "key1": "222"
            },
            {
                "key2": "444"
            },
            {
                "key2": "555"
            } 
        ]

Thankful for any help and suggestions!


Solution

  • Get the keys and values of the dictionary first

    keys: "{{ my_dict.keys()|list }}"
    vals: "{{ my_dict.values()|list }}"
    

    gives

    keys: [key1, key2]
    vals:
      - ['111', '222']
      - ['444', '555']
    

    Transpose the values

        - set_fact:
            tvals: "{{ tvals|d(vals.0)|zip(item)|map('flatten') }}"
          loop: "{{ vals[1:] }}"
    

    gives

    tvals:
      - ['111', '444']
      - ['222', '555']
    

    Create the list of the dictionaries

    my_list: "{{ tvals|map('zip', keys)|
                       map('map', 'reverse')|
                       map('community.general.dict')|
                       list }}"
    

    gives

    my_list:
      - key1: '111'
        key2: '444'
      - key1: '222'
        key2: '555'
    

    Notes

    1. Example of a complete playbook
    - hosts: localhost
      vars:
        my_dict:
          key1: ["111", "222"]
          key2: ["444", "555"]
        keys: "{{ my_dict.keys()|list }}"
        vals: "{{ my_dict.values()|list }}"
        my_list: "{{ tvals|map('zip', keys)|
                           map('map', 'reverse')|
                           map('community.general.dict')|
                           list }}"
      tasks:
        - set_fact:
            tvals: "{{ tvals|d(vals.0)|zip(item)|map('flatten') }}"
          loop: "{{ vals[1:] }}"
        - debug:
            var: my_list
    
    1. You can use a custom filer to transpose the matrix. For example,
    shell> cat filter_plugins/numpy.py 
    # All rights reserved (c) 2022, Vladimir Botka <vbotka@gmail.com>
    # Simplified BSD License, https://opensource.org/licenses/BSD-2-Clause
    
    from __future__ import (absolute_import, division, print_function)
    __metaclass__ = type
    
    from ansible.errors import AnsibleFilterError
    from ansible.module_utils.common._collections_compat import Sequence
    
    import json
    import numpy
    
    
    def numpy_transpose(arr):
        if not isinstance(arr, Sequence):
            raise AnsibleFilterError('First argument for numpy_transpose must be list. %s is %s' %
                                     (arr, type(arr)))
        arr1 = numpy.array(arr)
        arr2 = arr1.transpose()
        return json.dumps(arr2.tolist())
    
    
    class FilterModule(object):
        ''' Ansible wrappers for Python NumPy methods '''
    
        def filters(self):
            return {
                'numpy_transpose': numpy_transpose,
            }
    

    Then you can avoid iteration. For example, the playbook below gives the same result

    - hosts: localhost
      vars:
        my_dict:
          key1: ["111", "222"]
          key2: ["444", "555"]
        keys: "{{ my_dict.keys()|list }}"
        vals: "{{ my_dict.values()|list }}"
        tvals: "{{ vals|numpy_transpose()|from_yaml }}"
        my_list: "{{ tvals|map('zip', keys)|
                           map('map', 'reverse')|
                           map('community.general.dict')|
                           list }}"
      tasks:
        - debug:
            var: my_list
    
    1. Transposing explained

    Let's start with the matrix 2x2

    vals:
      - ['111', '222']
      - ['444', '555']
    

    The task below

        - set_fact:
            tvals: "{{ tvals|d(vals.0)|zip(item) }}"
          loop: "{{ vals[1:] }}"
    

    gives step by step:

    a) Before the iteration starts the variable tvals is assigned the default value vals.0

    vals.0: ['111', '222']
    

    b) The task iterates the list vals[1:]. These are all lines in the array except the first one

    vals[1:]:
      - ['444', '555']
    

    c) The first, and the only one, iteration zip the first and the second line. This is the result

    vals.0|zip(vals.1):
      - ['111', '444']
      - ['222', '555']
    

    Let's proceed with matrix 3x3

    vals:
      - ['111', '222', '333']
      - ['444', '555', '666']
      - ['777', '888', '999']
    

    The task below

        - set_fact:
            tvals: "{{ tvals|d(vals.0)|zip(item)|map('flatten') }}"
          loop: "{{ vals[1:] }}"
    

    gives step by step:

    a) Before the iteration starts the variable tvals is assigned the default value vals.0

    vals.0: ['111', '222', '333']
    

    b) The task iterates the list vals[1:]

    vals[1:]:
      - ['444', '555', '666']
      - ['777', '888', '999']
    

    c) The first iteration zip the first and the second line, and assigns it to tvals. The filer flatten has no effect on the lines

    vals.0|zip(vals.1)|map('flatten'):
      - ['111', '444']
      - ['222', '555']
      - ['333', '666']
    

    d) The next iteration zip tvals and the third line

      tvals|zip(vals.2):
        - - ['111', '444']
          - '777'
        - - ['222', '555']
          - '888'
        - - ['333', '666']
          - '999
    

    e) The lines must be flattened. This is the result

    tvals|zip(vals.2)|map('flatten'):
      - ['111', '444', '777']
      - ['222', '555', '888']
      - ['333', '666', '999']