Repository: ansible-collections/community.general

Issue:
ini_file should have option to ignore spaces around =, not just one to control whether to add them
### Summary

I want `ini_file` to add or update a setting only if the setting is missing or the actual value is different, not if it's in the file already with the correct value but there are more or fewer spaces on either side of the equal sign than `ini_file` expects.

Right now if the setting is already there with the correct value but there are no spaces when `no_extra_spaces` is false or there are spaces when `no_extra_spaces` is true, then `ini_file` modifies the file and indicates that the task resulted in a change, when in fact the change is irrelevant.

In fact, I believe this should have been the default value of `ini_file` all along, but it's probably too late for that, so it should at least be an option moving forward.


### Issue Type

Feature Idea

### Component Name

ini_file

### Additional Information

_No response_

### Code of Conduct

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


Discussion:
Files identified in the description:
* [`plugins/modules/ini_file.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/ini_file.py)

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 --->
cc @jpmens @noseka1
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
While it might be slightly annoying to see a change on first play run (if the file wasn't created by `ini_file`), I don't consider this a bug, and I'm not interested in changing the module's behavior.

Somebody else might want to.
Look, I'm happy to submit a PR with a test if that's what it'll take to get it fixed, but I'm not going to waste my time doing that if you're going to reject the PR because you "don't consider this a bug."
If you wish to submit a PR I will give it a +1, but that's all I as contributor can do. :-) 

Resolving patch:
diff --git a/plugins/modules/ini_file.py b/plugins/modules/ini_file.py
--- a/plugins/modules/ini_file.py
+++ b/plugins/modules/ini_file.py
@@ -4,6 +4,7 @@
 # Copyright (c) 2012, Jan-Piet Mens <jpmens () gmail.com>
 # Copyright (c) 2015, Ales Nosek <anosek.nosek () gmail.com>
 # Copyright (c) 2017, Ansible Project
+# Copyright (c) 2023, Ansible Project
 # 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
 
@@ -98,6 +99,12 @@
       - Do not insert spaces before and after '=' symbol.
     type: bool
     default: false
+  ignore_spaces:
+    description:
+      - Do not change a line if doing so would only add or remove spaces before or after the V(=) symbol.
+    type: bool
+    default: false
+    version_added: 7.5.0
   create:
     description:
       - If set to V(false), the module will fail if the file does not already exist.
@@ -178,7 +185,7 @@
 
 def match_opt(option, line):
     option = re.escape(option)
-    return re.match('[#;]?( |\t)*(%s)( |\t)*(=|$)( |\t)*(.*)' % option, line)
+    return re.match('([#;]?)( |\t)*(%s)( |\t)*(=|$)( |\t)*(.*)' % option, line)
 
 
 def match_active_opt(option, line):
@@ -186,19 +193,27 @@ def match_active_opt(option, line):
     return re.match('( |\t)*(%s)( |\t)*(=|$)( |\t)*(.*)' % option, line)
 
 
-def update_section_line(changed, section_lines, index, changed_lines, newline, msg):
-    option_changed = section_lines[index] != newline
+def update_section_line(option, changed, section_lines, index, changed_lines, ignore_spaces, newline, msg):
+    option_changed = None
+    if ignore_spaces:
+        old_match = match_opt(option, section_lines[index])
+        if not old_match.group(1):
+            new_match = match_opt(option, newline)
+            option_changed = old_match.group(7) != new_match.group(7)
+    if option_changed is None:
+        option_changed = section_lines[index] != newline
+    if option_changed:
+        section_lines[index] = newline
     changed = changed or option_changed
     if option_changed:
         msg = 'option changed'
-    section_lines[index] = newline
     changed_lines[index] = 1
     return (changed, msg)
 
 
 def do_ini(module, filename, section=None, option=None, values=None,
            state='present', exclusive=True, backup=False, no_extra_spaces=False,
-           create=True, allow_no_value=False, follow=False):
+           ignore_spaces=False, create=True, allow_no_value=False, follow=False):
 
     if section is not None:
         section = to_text(section)
@@ -306,8 +321,8 @@ def do_ini(module, filename, section=None, option=None, values=None,
         for index, line in enumerate(section_lines):
             if match_opt(option, line):
                 match = match_opt(option, line)
-                if values and match.group(6) in values:
-                    matched_value = match.group(6)
+                if values and match.group(7) in values:
+                    matched_value = match.group(7)
                     if not matched_value and allow_no_value:
                         # replace existing option with no value line(s)
                         newline = u'%s\n' % option
@@ -315,12 +330,12 @@ def do_ini(module, filename, section=None, option=None, values=None,
                     else:
                         # replace existing option=value line(s)
                         newline = assignment_format % (option, matched_value)
-                    (changed, msg) = update_section_line(changed, section_lines, index, changed_lines, newline, msg)
+                    (changed, msg) = update_section_line(option, changed, section_lines, index, changed_lines, ignore_spaces, newline, msg)
                     values.remove(matched_value)
                 elif not values and allow_no_value:
                     # replace existing option with no value line(s)
                     newline = u'%s\n' % option
-                    (changed, msg) = update_section_line(changed, section_lines, index, changed_lines, newline, msg)
+                    (changed, msg) = update_section_line(option, changed, section_lines, index, changed_lines, ignore_spaces, newline, msg)
                     option_no_value_present = True
                     break
 
@@ -330,7 +345,7 @@ def do_ini(module, filename, section=None, option=None, values=None,
             for index, line in enumerate(section_lines):
                 if not changed_lines[index] and match_opt(option, line):
                     newline = assignment_format % (option, values.pop(0))
-                    (changed, msg) = update_section_line(changed, section_lines, index, changed_lines, newline, msg)
+                    (changed, msg) = update_section_line(option, changed, section_lines, index, changed_lines, ignore_spaces, newline, msg)
                     if len(values) == 0:
                         break
         # remove all remaining option occurrences from the rest of the section
@@ -449,6 +464,7 @@ def main():
             state=dict(type='str', default='present', choices=['absent', 'present']),
             exclusive=dict(type='bool', default=True),
             no_extra_spaces=dict(type='bool', default=False),
+            ignore_spaces=dict(type='bool', default=False),
             allow_no_value=dict(type='bool', default=False),
             create=dict(type='bool', default=True),
             follow=dict(type='bool', default=False)
@@ -469,6 +485,7 @@ def main():
     exclusive = module.params['exclusive']
     backup = module.params['backup']
     no_extra_spaces = module.params['no_extra_spaces']
+    ignore_spaces = module.params['ignore_spaces']
     allow_no_value = module.params['allow_no_value']
     create = module.params['create']
     follow = module.params['follow']
@@ -481,7 +498,9 @@ def main():
     elif values is None:
         values = []
 
-    (changed, backup_file, diff, msg) = do_ini(module, path, section, option, values, state, exclusive, backup, no_extra_spaces, create, allow_no_value, follow)
+    (changed, backup_file, diff, msg) = do_ini(
+        module, path, section, option, values, state, exclusive, backup,
+        no_extra_spaces, ignore_spaces, create, allow_no_value, follow)
 
     if not module.check_mode and os.path.exists(path):
         file_args = module.load_file_common_arguments(module.params)


Test patch:
diff --git a/tests/integration/targets/ini_file/tasks/main.yml b/tests/integration/targets/ini_file/tasks/main.yml
--- a/tests/integration/targets/ini_file/tasks/main.yml
+++ b/tests/integration/targets/ini_file/tasks/main.yml
@@ -38,3 +38,9 @@
 
     - name: include tasks to test regressions
       include_tasks: tests/03-encoding.yml
+
+    - name: include tasks to test symlink handling
+      include_tasks: tests/04-symlink.yml
+
+    - name: include tasks to test ignore_spaces
+      include_tasks: tests/05-ignore_spaces.yml
diff --git a/tests/integration/targets/ini_file/tasks/tests/00-basic.yml b/tests/integration/targets/ini_file/tasks/tests/00-basic.yml
--- a/tests/integration/targets/ini_file/tasks/tests/00-basic.yml
+++ b/tests/integration/targets/ini_file/tasks/tests/00-basic.yml
@@ -3,7 +3,7 @@
 # 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
 
-## basiscs
+## basics
 
 - name: test-basic 1 - specify both "value" and "values" and fail
   ini_file:
diff --git a/tests/integration/targets/ini_file/tasks/tests/04-symlink.yml b/tests/integration/targets/ini_file/tasks/tests/04-symlink.yml
--- a/tests/integration/targets/ini_file/tasks/tests/04-symlink.yml
+++ b/tests/integration/targets/ini_file/tasks/tests/04-symlink.yml
@@ -9,26 +9,26 @@
       content: |
         [main]
         foo=BAR
-      dest: my_original_file.ini
+      dest: "{{ remote_tmp_dir }}/my_original_file.ini"
   - name: Clean up symlink.ini
     ansible.builtin.file:
-      path: symlink.ini
+      path: "{{ remote_tmp_dir }}/symlink.ini"
       state: absent
   - name: Create a symbolic link
     ansible.builtin.file:
       src: my_original_file.ini
-      dest: symlink.ini
+      dest: "{{ remote_tmp_dir }}/symlink.ini"
       state: link
 
 - name: Set the proxy key on the symlink which will be converted as a file
   community.general.ini_file:
-    path: symlink.ini
+    path: "{{ remote_tmp_dir }}/symlink.ini"
     section: main
     option: proxy
     value: 'http://proxy.myorg.org:3128'
 - name: Set the proxy key on the final file that is still unchanged
   community.general.ini_file:
-    path: my_original_file.ini
+    path: "{{ remote_tmp_dir }}/my_original_file.ini"
     section: main
     option: proxy
     value: 'http://proxy.myorg.org:3128'
@@ -41,7 +41,7 @@
 - block: *prepare
 - name: Set the proxy key on the symlink which will be preserved
   community.general.ini_file:
-    path: symlink.ini
+    path: "{{ remote_tmp_dir }}/symlink.ini"
     section: main
     option: proxy
     value: 'http://proxy.myorg.org:3128'
@@ -49,7 +49,7 @@
   register: result
 - name: Set the proxy key on the target directly that was changed in the previous step
   community.general.ini_file:
-    path: my_original_file.ini
+    path: "{{ remote_tmp_dir }}/my_original_file.ini"
     section: main
     option: proxy
     value: 'http://proxy.myorg.org:3128'
diff --git a/tests/integration/targets/ini_file/tasks/tests/05-ignore_spaces.yml b/tests/integration/targets/ini_file/tasks/tests/05-ignore_spaces.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ini_file/tasks/tests/05-ignore_spaces.yml
@@ -0,0 +1,123 @@
+---
+# Copyright (c) Ansible Project
+# 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
+
+## testing ignore_spaces option
+
+- name: test-ignore_spaces 1 (commented line updated) - create test file
+  copy:
+    dest: "{{ output_file }}"
+    content: "[foo]\n; bar=baz\n"
+
+- name: test-ignore_spaces 1 - set new value
+  ini_file:
+    path: "{{ output_file }}"
+    section: foo
+    option: bar
+    value: frelt
+    ignore_spaces: true
+  register: result
+
+- name: test-ignore_spaces 1 - read content from output file
+  slurp:
+    src: "{{ output_file }}"
+  register: output_content
+
+- name: test-ignore_spaces 1 - verify results
+  vars:
+    actual_content: "{{ output_content.content | b64decode }}"
+    expected_content: "[foo]\nbar = frelt\n"
+  assert:
+    that:
+      - actual_content == expected_content
+      - result is changed
+      - result.msg == 'option changed'
+
+- name: test-ignore_spaces 2 (uncommented line updated) - create test file
+  copy:
+    dest: "{{ output_file }}"
+    content: "[foo]\nbar=baz\n"
+
+- name: test-ignore_spaces 2 - set new value
+  ini_file:
+    path: "{{ output_file }}"
+    section: foo
+    option: bar
+    value: frelt
+    ignore_spaces: true
+  register: result
+
+- name: test-ignore_spaces 2 - read content from output file
+  slurp:
+    src: "{{ output_file }}"
+  register: output_content
+
+- name: test-ignore_spaces 2 - verify results
+  vars:
+    actual_content: "{{ output_content.content | b64decode }}"
+    expected_content: "[foo]\nbar = frelt\n"
+  assert:
+    that:
+      - actual_content == expected_content
+      - result is changed
+      - result.msg == 'option changed'
+
+- name: test-ignore_spaces 3 (spaces on top of no spaces) - create test file
+  copy:
+    dest: "{{ output_file }}"
+    content: "[foo]\nbar=baz\n"
+
+- name: test-ignore_spaces 3 - try to set value
+  ini_file:
+    path: "{{ output_file }}"
+    section: foo
+    option: bar
+    value: baz
+    ignore_spaces: true
+  register: result
+
+- name: test-ignore_spaces 3 - read content from output file
+  slurp:
+    src: "{{ output_file }}"
+  register: output_content
+
+- name: test-ignore_spaces 3 - verify results
+  vars:
+    actual_content: "{{ output_content.content | b64decode }}"
+    expected_content: "[foo]\nbar=baz\n"
+  assert:
+    that:
+      - actual_content == expected_content
+      - result is not changed
+      - result.msg == "OK"
+
+- name: test-ignore_spaces 4 (no spaces on top of spaces) - create test file
+  copy:
+    dest: "{{ output_file }}"
+    content: "[foo]\nbar = baz\n"
+
+- name: test-ignore_spaces 4 - try to set value
+  ini_file:
+    path: "{{ output_file }}"
+    section: foo
+    option: bar
+    value: baz
+    ignore_spaces: true
+    no_extra_spaces: true
+  register: result
+
+- name: test-ignore_spaces 4 - read content from output file
+  slurp:
+    src: "{{ output_file }}"
+  register: output_content
+
+- name: test-ignore_spaces 4 - verify results
+  vars:
+    actual_content: "{{ output_content.content | b64decode }}"
+    expected_content: "[foo]\nbar = baz\n"
+  assert:
+    that:
+      - actual_content == expected_content
+      - result is not changed
+      - result.msg == "OK"
diff --git a/tests/unit/plugins/modules/test_ini_file.py b/tests/unit/plugins/modules/test_ini_file.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/modules/test_ini_file.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2023 Ansible Project
+# 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)
+__metaclass__ = type
+
+from ansible_collections.community.general.plugins.modules import ini_file
+
+
+def do_test(option, ignore_spaces, newline, before, expected_after,
+            expected_changed, expected_msg):
+    section_lines = [before]
+    changed_lines = [0]
+    changed, msg = ini_file.update_section_line(
+        option, None, section_lines, 0, changed_lines, ignore_spaces,
+        newline, None)
+    assert section_lines[0] == expected_after
+    assert changed == expected_changed
+    assert changed_lines[0] == 1
+    assert msg == expected_msg
+
+
+def test_ignore_spaces_comment():
+    oldline = ';foobar=baz'
+    newline = 'foobar = baz'
+    do_test('foobar', True, newline, oldline, newline, True, 'option changed')
+
+
+def test_ignore_spaces_changed():
+    oldline = 'foobar=baz'
+    newline = 'foobar = freeble'
+    do_test('foobar', True, newline, oldline, newline, True, 'option changed')
+
+
+def test_ignore_spaces_unchanged():
+    oldline = 'foobar=baz'
+    newline = 'foobar = baz'
+    do_test('foobar', True, newline, oldline, oldline, False, None)
+
+
+def test_no_ignore_spaces_changed():
+    oldline = 'foobar=baz'
+    newline = 'foobar = baz'
+    do_test('foobar', False, newline, oldline, newline, True, 'option changed')
+
+
+def test_no_ignore_spaces_unchanged():
+    newline = 'foobar=baz'
+    do_test('foobar', False, newline, newline, newline, False, None)