Repository: ansible-collections/community.general

Issue:
[RFE] ansible filter to/from ini
### Summary

Business case:
The existing ansible filters[1] to_json/from_json and to_yaml/from_yaml are helpful for some conditions. However, a number of templates/transforms/etc could be radically simplified if ansible had a to_ini and from_ini filter.

Technical requirements:
Add a to_ini/from_ini filter to lib/ansible/plugins/filter/core.py using the configparser module shipping with python.

[1] https://docs.ansible.com/ansible/latest/user_guide/playbooks_filters.html#formatting-data-yaml-and-json

### Issue Type

Feature Idea

### Component Name

Core

### Additional Information

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
# This is a start, but there is design work the ansible folks will need to do:


try:
    from configparser import ConfigParser
except ImportError:
    from ConfigParser import SafeConfigParser as ConfigParser
import io

def to_ini(a, *args, **kw):
    '''Convert a dict into ini format'''
    if not isinstance(a, dict):
        raise AnsibleFilterError('type (%s) not permitted, only dict is supported' % type(a))
    config = ConfigParser(*args, **kw)
    try:
        config.read_dict(a)
    except AttributeError:
        try:
            for section in a.keys():
                config.add_section(section)
                # get keys in a determinstic order
                for option in sorted(a[section].keys()):
                    config.set(section, option, value)
        except Exception as e:
            raise AnsibleFilterError("to_ini - %s" % to_native(e), orig_exc=e)
    except Exception as e:
        raise AnsibleFilterError("to_ini - %s" % to_native(e), orig_exc=e)

    ini = io.StringIO()
    config.write(ini)

    return ini.getvalue()


def from_ini(data, *args, **kwargs):
    config = ConfigParser(*args, **kw)
    try:
        config.read_string(data)
    except AttributeError:
        try:
            ini = io.StringIO(data)
            config.readfp(ini)
        except Exception as e:
            raise AnsibleFilterError("from_ini - %s" % to_native(e), orig_exc=e)
    except Exception as e:
        raise AnsibleFilterError("from_ini - %s" % to_native(e), orig_exc=e)
    return config
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
None

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
For someone who wants to implement this feature - https://github.com/sivel/toiletwater/blob/master/plugins/filter/ini.py
cc @jatorcasso

Resolving patch:
diff --git a/plugins/filter/to_ini.py b/plugins/filter/to_ini.py
new file mode 100644
--- /dev/null
+++ b/plugins/filter/to_ini.py
@@ -0,0 +1,105 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2023, Steffen Scheib <steffen@scheib.me>
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from __future__ import absolute_import, division, print_function
+
+DOCUMENTATION = r'''
+  name: to_ini
+  short_description: Converts a dictionary to the INI file format
+  version_added: 8.2.0
+  author: Steffen Scheib (@sscheib)
+  description:
+    - Converts a dictionary to the INI file format.
+  options:
+    _input:
+      description: The dictionary that should be converted to the INI format.
+      type: dictionary
+      required: true
+'''
+
+EXAMPLES = r'''
+  - name: Define a dictionary
+    ansible.builtin.set_fact:
+      my_dict:
+        section_name:
+          key_name: 'key value'
+
+        another_section:
+          connection: 'ssh'
+
+  - name: Write dictionary to INI file
+    ansible.builtin.copy:
+      dest: /tmp/test.ini
+      content: '{{ my_dict | community.general.to_ini }}'
+
+  # /tmp/test.ini will look like this:
+  # [section_name]
+  # key_name = key value
+  #
+  # [another_section]
+  # connection = ssh
+'''
+
+RETURN = r'''
+  _value:
+    description: A string formatted as INI file.
+    type: string
+'''
+
+
+__metaclass__ = type
+
+from ansible.errors import AnsibleFilterError
+from ansible.module_utils.common._collections_compat import Mapping
+from ansible.module_utils.six.moves import StringIO
+from ansible.module_utils.six.moves.configparser import ConfigParser
+from ansible.module_utils.common.text.converters import to_native
+
+
+class IniParser(ConfigParser):
+    ''' Implements a configparser which sets the correct optionxform '''
+
+    def __init__(self):
+        super().__init__()
+        self.optionxform = str
+
+
+def to_ini(obj):
+    ''' Read the given dict and return an INI formatted string '''
+
+    if not isinstance(obj, Mapping):
+        raise AnsibleFilterError(f'to_ini requires a dict, got {type(obj)}')
+
+    ini_parser = IniParser()
+
+    try:
+        ini_parser.read_dict(obj)
+    except Exception as ex:
+        raise AnsibleFilterError('to_ini failed to parse given dict:'
+                                 f'{to_native(ex)}', orig_exc=ex)
+
+    # catching empty dicts
+    if obj == dict():
+        raise AnsibleFilterError('to_ini received an empty dict. '
+                                 'An empty dict cannot be converted.')
+
+    config = StringIO()
+    ini_parser.write(config)
+
+    # config.getvalue() returns two \n at the end
+    # with the below insanity, we remove the very last character of
+    # the resulting string
+    return ''.join(config.getvalue().rsplit(config.getvalue()[-1], 1))
+
+
+class FilterModule(object):
+    ''' Query filter '''
+
+    def filters(self):
+
+        return {
+            'to_ini': to_ini
+        }


Test patch:
diff --git a/tests/integration/targets/filter_to_ini/tasks/main.yml b/tests/integration/targets/filter_to_ini/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/filter_to_ini/tasks/main.yml
@@ -0,0 +1,58 @@
+---
+# Copyright (c) 2023, Steffen Scheib <steffen@scheib.me>
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+- name: >-
+    Write INI file that reflects using to_ini to {{ ini_test_file_filter }}
+  ansible.builtin.copy:
+    dest: '{{ ini_test_file_filter }}'
+    content: '{{ ini_test_dict | community.general.to_ini }}'
+  vars:
+    ini_test_dict:
+      section_name:
+        key_name: 'key value'
+
+      another_section:
+        connection: 'ssh'
+
+- name: 'Write INI file manually to {{ ini_test_file }}'
+  ansible.builtin.copy:
+    dest: '{{ ini_test_file }}'
+    content: |
+      [section_name]
+      key_name = key value
+
+      [another_section]
+      connection = ssh
+
+- name: 'Slurp the manually created test file: {{ ini_test_file }}'
+  ansible.builtin.slurp:
+    src: '{{ ini_test_file }}'
+  register: 'ini_file_content'
+
+- name: 'Slurp the test file created with to_ini: {{ ini_test_file_filter }}'
+  ansible.builtin.slurp:
+    src: '{{ ini_test_file_filter }}'
+  register: 'ini_file_filter_content'
+
+- name: >-
+    Ensure the manually created test file and the test file created with
+    to_ini are identical
+  ansible.builtin.assert:
+    that:
+      - 'ini_file_content.content | b64decode ==
+         ini_file_filter_content.content | b64decode'
+
+- name: 'Try to convert an empty dictionary with to_ini'
+  ansible.builtin.debug:
+    msg: '{{ {} | community.general.to_ini }}'
+  register: 'ini_empty_dict'
+  ignore_errors: true
+
+- name: 'Ensure the correct exception was raised'
+  ansible.builtin.assert:
+    that:
+      - "'to_ini received an empty dict. An empty dict cannot be converted.' in
+        ini_empty_dict.msg"
+...
diff --git a/tests/integration/targets/filter_to_ini/vars/main.yml b/tests/integration/targets/filter_to_ini/vars/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/filter_to_ini/vars/main.yml
@@ -0,0 +1,8 @@
+---
+# Copyright (c) 2023, Steffen Scheib <steffen@scheib.me>
+# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+ini_test_file: '/tmp/test.ini'
+ini_test_file_filter: '/tmp/test_filter.ini'
+...