Formatting a list of items in Ansible/Jinja2

Viewed 877

Is it possible to convert a list of lists/dicts to a list of strings using a formatting string in Ansible / Jinja2?

I understand I can do something like:

{{["First: %d", "Second: %d"] | map("format", 1) | join(", ") }}

To get First: 1, Second 1.

Would it be possible to do something like

{{[[1, 1], [2, 2]] | map("format", "Num %d, %d") | join(", ") }}

and result in Num 1, 1, Num 2, 2?

3 Answers
  • No. It's not possible. The first parameter of the function format must be the format string. For example,
    - debug:
        msg: "{{ ['v1 %s', 'v2 %s']|map('format', 'XYZ')|list }}"

gives

msg:
  - v1 XYZ
  - v2 XYZ

  • Instead, it's possible to map join and regex_replace the items. For example, given the list
_list: [[1, 1], [2, 2]]

The expressions below

result: "{{ _list|
            map('join', ',')|
            map('regex_replace', _regex, _replace)|
            join(', ') }}"
_regex: '^(.*),(.*)$'
_replace: 'Num \1, \2'

gives

result: Num 1, 1, Num 2, 2

  • The next option is the filter product. The expression below gives the same result
result: "{{ ['Num']|
            product(_list|map('join', ', '))|
            map('join', ' ')|
            join(', ') }}"

  • The next option is Jinja. The expression below gives the same result
result: |-
  {% for i in _list %}
  Num {{ i|join(', ') }}{% if not loop.last %}, {% endif %}
  {%- endfor %}

Example of a complete playbook for testing

- hosts: localhost

  vars:
    _list: [[1, 1], [2, 2]]
    result1: "{{ ['Num']|
                product(_list|map('join', ', '))|
                map('join', ' ')|
                join(', ') }}"
    result2: "{{ _list|
                 map('join', ',')|
                 map('regex_replace', _regex, _replace)|
                 join(', ') }}"
    _regex: '^(.*),(.*)$'
    _replace: 'Num \1, \2'
    result3: |-
      {% for i in _list %}
      Num {{ i|join(', ') }}{% if not loop.last %}, {% endif %}
      {%- endfor %}

  tasks:
    - debug:
        msg: "{{ ['v1 %s', 'v2 %s']|map('format', 'XYZ')|list }}"
    - debug:
        var: result1
    - debug:
        var: result2
    - debug:
        var: result3
    - assert:
        that:
          - result1 == 'Num 1, 1, Num 2, 2'
          - result2 == 'Num 1, 1, Num 2, 2'
          - result3 == 'Num 1, 1, Num 2, 2'

It is not possible with the core format filter. But if you are willing to write a few lines of python, you can easily solve this with a custom filter. I went to the simplest for demo, you would probably have to harden the code and fix edge cases for a real life use. You may want to have a look at the plugin development documentation for more info.

For this example, I will save the custom file in the filter_plugins directory adjacent to my test playbook. You can save it in a collection or a role if you want to share across different projects.

In filter_plugins/my_format_filters.py


def reverse_format(param_list, format_string):
    """format format_sting using elements in param_list"""
    return format_string % tuple(param_list)


class FilterModule(object):
    """my format filters."""

    def filters(self):
        """Return the filter list."""
        return {
            'reverse_format': reverse_format
        }

Then the following example playbook:

---
- name: custom filter demo
  hosts: localhost
  gather_facts: false

  tasks:
    - name: map custom reverse_format filter
      debug:
        msg: '{{ item.replacements | map("reverse_format", item.format) | join(", ") }}'
      loop:
        - replacements:
            - [1, 1]
            - [2, 2]
          format: "Num %d, %d"
        - replacements:
            - ['Jack', 'John', 12]
            - ['Mary', 'Alicia', 34]
          format: "%s owes %s %d€"

gives:

PLAY [custom filter demo] **************************************************************************************************************************************************************************************************************

TASK [map custom reverse_format filter] ************************************************************************************************************************************************************************************************
ok: [localhost] => (item={'replacements': [[1, 1], [2, 2]], 'format': 'Num %d, %d'}) => {
    "msg": "Num 1, 1, Num 2, 2"
}
ok: [localhost] => (item={'replacements': [['Jack', 'John', 12], ['Mary', 'Alicia', 34]], 'format': '%s owes %s %d€'}) => {
    "msg": "Jack owes John 12€, Mary owes Alicia 34€"
}

PLAY RECAP *****************************************************************************************************************************************************************************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Building on the already accepted answer, here's a version that also supports being fed lists of dicts/objects:

class FilterModule(object):
    """my format filters."""

    def filters(self):
        """Return the filter list."""
        return {
            'reverse_format': self.reverse_format
        }

    def reverse_format(self, value, format_string):
        """format format_sting using elements in param_list"""
        return format_string.format(**value)

Usage example:

{{ hostvars.values() |
    map('reverse_format', 'This is inventory host {inventory_hostname}!' | 
    join('\n') }}
Related