From ec12422faecfa3a429aac3003804052a4c8d576d Mon Sep 17 00:00:00 2001 From: Steffen Scheib <37306894+sscheib@users.noreply.github.com> Date: Thu, 28 Dec 2023 08:32:57 +0100 Subject: [PATCH] Adding a new filter: to_ini, which allows conversion of a dictionary to an INI formatted string (#7744) * Adding a new filter: to_ini, which allows conversion of a dictionary to an INI formatted string * Adding to_ini maintainers into BOTMETA * Correcting filter suffix * Moving filter to correct path * Adding error handling; Removing quotes from examples; Fixing RETURN documentation * Removing the last newline char; Adding error handling for an empty dict * Adding integration tests for to_ini * Fixing F-String usage * Fixing formatting * Fixing whitespace * Moving import statements below documentation; Adding a more generic Exception handling; Removing unused imports * Removing not needed set_fact and replacing it with using vars: * Replacing MutableMapping with Mapping --- .github/BOTMETA.yml | 2 + plugins/filter/to_ini.py | 105 ++++++++++++++++++ .../targets/filter_to_ini/tasks/main.yml | 58 ++++++++++ .../targets/filter_to_ini/vars/main.yml | 8 ++ 4 files changed, 173 insertions(+) create mode 100644 plugins/filter/to_ini.py create mode 100644 tests/integration/targets/filter_to_ini/tasks/main.yml create mode 100644 tests/integration/targets/filter_to_ini/vars/main.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 13f1e69e2e..bfb428dea4 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -158,6 +158,8 @@ files: maintainers: resmo $filters/to_hours.yml: maintainers: resmo + $filters/to_ini.py: + maintainers: sscheib $filters/to_milliseconds.yml: maintainers: resmo $filters/to_minutes.yml: diff --git a/plugins/filter/to_ini.py b/plugins/filter/to_ini.py new file mode 100644 index 0000000000..22ef16d722 --- /dev/null +++ b/plugins/filter/to_ini.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2023, Steffen Scheib +# 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 + } 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 index 0000000000..877d4471d8 --- /dev/null +++ b/tests/integration/targets/filter_to_ini/tasks/main.yml @@ -0,0 +1,58 @@ +--- +# Copyright (c) 2023, Steffen Scheib +# 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 index 0000000000..9c950726b4 --- /dev/null +++ b/tests/integration/targets/filter_to_ini/vars/main.yml @@ -0,0 +1,8 @@ +--- +# Copyright (c) 2023, Steffen Scheib +# 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' +...