From d01402671ce33ecf97b54d49aec6bef1b3f1d11a Mon Sep 17 00:00:00 2001 From: Alexei Znamensky Date: Mon, 18 May 2026 22:52:49 +1200 Subject: [PATCH 1/5] feat(onepassword_info): rewrite module to support op CLI v2 only Drop v1 syntax (get item, --output=raw, documentAttributes, nested fields/sections). Add CmdRunner-based auth and item fetch using v2 commands (account list/get, signin --raw, account add --raw --signin, item get --format json). Fix config_file_path None guard in get_token. Remove global module antipattern. Co-Authored-By: Claude Sonnet 4.6 --- plugins/module_utils/_onepassword.py | 23 +++ plugins/modules/onepassword_info.py | 235 +++++++++++---------------- 2 files changed, 114 insertions(+), 144 deletions(-) diff --git a/plugins/module_utils/_onepassword.py b/plugins/module_utils/_onepassword.py index 95229abef7..bc63168cab 100644 --- a/plugins/module_utils/_onepassword.py +++ b/plugins/module_utils/_onepassword.py @@ -9,6 +9,29 @@ from __future__ import annotations import os +from ansible_collections.community.general.plugins.module_utils import _cmd_runner_fmt as fmt +from ansible_collections.community.general.plugins.module_utils._cmd_runner import CmdRunner + + +def onepassword_runner(module, command): + return CmdRunner( + module, + command=command, + arg_formats=dict( + _account_list=fmt.as_fixed(["account", "list"]), + _account_get=fmt.as_fixed(["account", "get"]), + _signin=fmt.as_fixed(["signin", "--raw"]), + _account_add=fmt.as_fixed(["account", "add", "--raw", "--signin"]), + _item_get=fmt.as_fixed(["item", "get", "--format", "json"]), + account=fmt.as_opt_val("--account"), + address=fmt.as_opt_val("--address"), + email=fmt.as_opt_val("--email"), + item_id=fmt.as_list(), + vault=fmt.as_opt_eq_val("--vault"), + session=fmt.as_optval("--session="), + ), + ) + class OnePasswordConfig: _config_file_paths = ( diff --git a/plugins/modules/onepassword_info.py b/plugins/modules/onepassword_info.py index 524fe96196..4f3b72eb19 100644 --- a/plugins/modules/onepassword_info.py +++ b/plugins/modules/onepassword_info.py @@ -14,9 +14,8 @@ module: onepassword_info author: - Ryan Conway (@Rylon) requirements: - - C(op) 1Password command line utility. See U(https://support.1password.com/command-line/) + - C(op) 1Password command line utility version 2 or later. See U(https://support.1password.com/command-line/) notes: - - Tested with C(op) version 0.5.5. - Based on the P(community.general.onepassword#lookup) lookup plugin by Scott Buchanan . short_description: Gather items from 1Password description: @@ -43,8 +42,7 @@ options: field: type: str description: - - The name of the field to search for within this item (optional, defaults to V(password), or V(document) if the - item has an attachment). + - The name of the field to search for within this item (optional, defaults to V(password)). section: type: str description: @@ -124,7 +122,6 @@ EXAMPLES = r""" field: Custom field name # optional, defaults to 'password' section: Custom section name # optional, defaults to 'None' vault: Name of the vault # optional, only necessary if there is more than 1 Vault available - - name: A 1Password item with document attachment delegate_to: localhost register: my_1password_item no_log: true # Don't want to log the secrets to the console! @@ -148,89 +145,58 @@ onepassword: Custom field name: the value of this field "My Other 1Password item": password: the value of this field - "A 1Password item with document attachment": - document: the contents of the document attached to this item """ -import errno import json import os -import re from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.common.text.converters import to_bytes, to_native +from ansible.module_utils.common.text.converters import to_bytes -from ansible_collections.community.general.plugins.module_utils._onepassword import OnePasswordConfig - - -class AnsibleModuleError(Exception): - def __init__(self, results): - self.results = results - - def __repr__(self): - return self.results +from ansible_collections.community.general.plugins.module_utils._onepassword import ( + OnePasswordConfig, + onepassword_runner, +) class OnePasswordInfo: - def __init__(self): - self.cli_path = module.params.get("cli_path") - self.auto_login = module.params.get("auto_login") + def __init__(self, module): + self.module = module + self.cli_path = self.module.params.get("cli_path") + self.auto_login = self.module.params.get("auto_login") self.logged_in = False self.token = None - terms = module.params.get("search_terms") + terms = self.module.params.get("search_terms") self.terms = self.parse_search_terms(terms) self._config = OnePasswordConfig() - - def _run(self, args, expected_rc=0, command_input=None, ignore_errors=False): - if self.token: - # Adds the session token to all commands if we're logged in. - args += [to_bytes("--session=") + self.token] - - command = [self.cli_path] + args - rc, out, err = module.run_command(command, data=command_input, check_rc=False, binary_data=True, encoding=None) - if not ignore_errors and rc != expected_rc: - raise AnsibleModuleError(to_native(err)) - return rc, out, err + self._runner = onepassword_runner(self.module, self.cli_path) def _parse_field(self, data_json, item_id, field_name, section_title=None): data = json.loads(data_json) + field_name_lower = field_name.lower() - if "documentAttributes" in data["details"]: - # This is actually a document, let's fetch the document data instead! - document = self._run(["get", "document", data["overview"]["title"]]) - return {"document": document[1].strip()} - - else: - # This is not a document, let's try to find the requested field - - # Some types of 1Password items have a 'password' field directly alongside the 'fields' attribute, - # not inside it, so we need to check there first. - if field_name in data["details"]: - return {field_name: data["details"][field_name]} - - # Otherwise we continue looking inside the 'fields' attribute for the specified field. + for field in data.get("fields", []): + if section_title is None: + if field.get(field_name_lower): + return {field_name: field.get(field_name_lower)} + if field.get("label", "").lower() == field_name_lower: + return {field_name: field.get("value", "")} + if field.get("id", "").lower() == field_name_lower: + return {field_name: field.get("value", "")} else: - if section_title is None: - for field_data in data["details"].get("fields", []): - if field_data.get("name", "").lower() == field_name.lower(): - return {field_name: field_data.get("value", "")} + section = field.get("section", {}) + current_section = section.get("label", section.get("id", "")).lower() + if section_title.lower() == current_section: + if field.get("label", "").lower() == field_name_lower: + return {field_name: field.get("value", "")} + if field.get("id", "").lower() == field_name_lower: + return {field_name: field.get("value", "")} - # Not found it yet, so now lets see if there are any sections defined - # and search through those for the field. If a section was given, we skip - # any non-matching sections, otherwise we search them all until we find the field. - for section_data in data["details"].get("sections", []): - if section_title is not None and section_title.lower() != section_data["title"].lower(): - continue - for field_data in section_data.get("fields", []): - if field_data.get("t", "").lower() == field_name.lower(): - return {field_name: field_data.get("v", "")} - - # We will get here if the field could not be found in any section and the item wasn't a document to be downloaded. optional_section_title = "" if section_title is None else f" in the section '{section_title}'" - module.fail_json( + self.module.fail_json( msg=f"Unable to find an item in 1Password named '{item_id}' with the field '{field_name}'{optional_section_title}." ) @@ -242,7 +208,7 @@ class OnePasswordInfo: term = {"name": term} if "name" not in term: - module.fail_json(msg=f"Missing required 'name' field from search term, got: '{term}'") + self.module.fail_json(msg=f"Missing required 'name' field from search term, got: '{term}'") term["field"] = term.get("field", "password") term["section"] = term.get("section") @@ -253,94 +219,80 @@ class OnePasswordInfo: return processed_terms def get_raw(self, item_id, vault=None): - try: - args = ["get", "item", item_id] - if vault is not None: - args += [f"--vault={vault}"] - rc, output, dummy = self._run(args) - return output - - except Exception as e: - if re.search(".*not found.*", f"{e}"): - module.fail_json(msg=f"Unable to find an item in 1Password named '{item_id}'.") - else: - module.fail_json(msg=f"Unexpected error attempting to find an item in 1Password named '{item_id}': {e}") + with self._runner("_item_get item_id vault session") as ctx: + rc, out, err = ctx.run(item_id=item_id, vault=vault, session=self.token) + if rc != 0: + if "not found" in err.lower(): + self.module.fail_json(msg=f"Unable to find an item in 1Password named '{item_id}'.") + self.module.fail_json( + msg=f"Unexpected error attempting to find an item in 1Password named '{item_id}': {err}" + ) + return out def get_field(self, item_id, field, section=None, vault=None): output = self.get_raw(item_id, vault) - return self._parse_field(output, item_id, field, section) if output != "" else "" + return self._parse_field(output, item_id, field, section) if output else "" def full_login(self): - if self.auto_login is not None: - if None in [ - self.auto_login.get("subdomain"), - self.auto_login.get("username"), - self.auto_login.get("secret_key"), - self.auto_login.get("master_password"), - ]: - module.fail_json( - msg="Unable to perform initial sign in to 1Password. " - "subdomain, username, secret_key, and master_password are required to perform initial sign in." - ) - - args = [ - "signin", - f"{self.auto_login['subdomain']}.1password.com", - to_bytes(self.auto_login["username"]), - to_bytes(self.auto_login["secret_key"]), - "--output=raw", - ] - - try: - rc, out, err = self._run(args, command_input=to_bytes(self.auto_login["master_password"])) - self.token = out.strip() - except AnsibleModuleError as e: - module.fail_json(msg=f"Failed to perform initial sign in to 1Password: {e}") - else: - module.fail_json( + if self.auto_login is None: + self.module.fail_json( msg=f"Unable to perform an initial sign in to 1Password. Please run '{self.cli_path} signin' " "or define credentials in 'auto_login'. See the module documentation for details." ) + if None in [ + self.auto_login.get("subdomain"), + self.auto_login.get("username"), + self.auto_login.get("secret_key"), + self.auto_login.get("master_password"), + ]: + self.module.fail_json( + msg="Unable to perform initial sign in to 1Password. " + "subdomain, username, secret_key, and master_password are required to perform initial sign in." + ) + + subdomain = self.auto_login["subdomain"] + with self._runner( + "_account_add address email", + data=to_bytes(self.auto_login["master_password"]), + environ_update={"OP_SECRET_KEY": self.auto_login["secret_key"]}, + ) as ctx: + rc, out, err = ctx.run( + address=f"{subdomain}.1password.com", + email=self.auto_login["username"], + ) + if rc != 0: + self.module.fail_json(msg=f"Failed to perform initial sign in to 1Password: {err}") + self.token = out.strip() + def get_token(self): - # If the config file exists, assume an initial signin has taken place and try basic sign in - if os.path.isfile(self._config.config_file_path): - if self.auto_login is not None: - # Since we are not currently signed in, master_password is required at a minimum - if not self.auto_login.get("master_password"): - module.fail_json(msg="Unable to sign in to 1Password. 'auto_login.master_password' is required.") + config_path = self._config.config_file_path + if config_path and os.path.isfile(config_path) and self.auto_login is not None: + if not self.auto_login.get("master_password"): + self.module.fail_json(msg="Unable to sign in to 1Password. 'auto_login.master_password' is required.") - # Try signing in using the master_password and a subdomain if one is provided - try: - args = ["signin", "--output=raw"] + with self._runner("_signin account", data=to_bytes(self.auto_login["master_password"])) as ctx: + rc, out, err = ctx.run(account=self.auto_login.get("subdomain")) + if rc == 0: + self.token = out.strip() + return - if self.auto_login.get("subdomain"): - args = ["signin", self.auto_login["subdomain"], "--output=raw"] - - rc, out, err = self._run(args, command_input=to_bytes(self.auto_login["master_password"])) - self.token = out.strip() - - except AnsibleModuleError: - self.full_login() - - else: - self.full_login() - - else: - # Attempt a full sign in since there appears to be no existing sign in - self.full_login() + self.full_login() def assert_logged_in(self): - try: - rc, out, err = self._run(["get", "account"], ignore_errors=True) + subdomain = self.auto_login.get("subdomain") if self.auto_login else None + account = f"{subdomain}.1password.com" if subdomain else None + + with self._runner("_account_list account") as ctx: + rc, out, err = ctx.run(account=account) + if rc == 0 and out.strip(): + with self._runner("_account_get account") as ctx: + rc, out, err = ctx.run(account=account) if rc == 0: self.logged_in = True - if not self.logged_in: - self.get_token() - except OSError as e: - if e.errno == errno.ENOENT: - module.fail_json(msg=f"1Password CLI tool '{self.cli_path}' not installed in path on control machine") - raise e + + if not self.logged_in: + self.get_token() def run(self): result = {} @@ -351,19 +303,15 @@ class OnePasswordInfo: value = self.get_field(term["name"], term["field"], term["section"], term["vault"]) if term["name"] in result: - # If we already have a result for this key, we have to append this result dictionary - # to the existing one. This is only applicable when there is a single item - # in 1Password which has two different fields, and we want to retrieve both of them. + # Two search terms for the same item — merge the field dicts. result[term["name"]].update(value) else: - # If this is the first result for this key, simply set it. result[term["name"]] = value return result def main(): - global module module = AnsibleModule( argument_spec=dict( cli_path=dict(type="path", default="op"), @@ -380,9 +328,8 @@ def main(): ), supports_check_mode=True, ) - module.run_command_environ_update = {"LANGUAGE": "C", "LC_ALL": "C"} - results = {"onepassword": OnePasswordInfo().run()} + results = {"onepassword": OnePasswordInfo(module).run()} module.exit_json(changed=False, **results) From 7edfbe55621140c0c501fa814c621e3d52a6eb4d Mon Sep 17 00:00:00 2001 From: Alexei Znamensky Date: Sat, 23 May 2026 11:56:45 +1200 Subject: [PATCH 2/5] test(onepassword_info): add unit tests for op CLI v2 rewrite Cover happy path (already logged in, vault, section field), error paths (item not found, field not found, no auto_login), and auth flows (get_token via signin, full login via account add). Co-Authored-By: Claude Sonnet 4.6 --- .../plugins/modules/test_onepassword_info.py | 136 ++++++++++++++++++ .../modules/test_onepassword_info.yaml | 134 +++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 tests/unit/plugins/modules/test_onepassword_info.py create mode 100644 tests/unit/plugins/modules/test_onepassword_info.yaml diff --git a/tests/unit/plugins/modules/test_onepassword_info.py b/tests/unit/plugins/modules/test_onepassword_info.py new file mode 100644 index 0000000000..f617215b8c --- /dev/null +++ b/tests/unit/plugins/modules/test_onepassword_info.py @@ -0,0 +1,136 @@ +# Copyright (c) 2026, Alexei Znamensky +# 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 annotations + +import json +from unittest.mock import PropertyMock + +import pytest +from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import set_module_args + +from ansible_collections.community.general.plugins.module_utils._onepassword import OnePasswordConfig +from ansible_collections.community.general.plugins.modules import onepassword_info + +from .uthelper import RunCommandMock, TestCaseMock, UTHelper + + +ITEM_JSON = json.dumps( + { + "fields": [ + {"id": "password", "label": "password", "value": "secret123"}, + ] + } +) + + +class OnePasswordConfigMock(TestCaseMock): + name = "onepassword_config" + + def setup(self, mocker): + mocker.patch.object( + OnePasswordConfig, + "config_file_path", + new_callable=PropertyMock, + return_value=self.mock_specs.get("config_file_path"), + ) + + def check(self, test_case, results): + pass + + +UTHelper.from_module(onepassword_info, __name__, mocks=[RunCommandMock, OnePasswordConfigMock]) + + +def _patch_bin_path(mocker): + mocker.patch( + "ansible.module_utils.basic.AnsibleModule.get_bin_path", + lambda self_, path, *args, **kwargs: f"/testbin/{path}", + ) + + +def _patch_run_command(mocker, responses): + mocker.patch( + "ansible.module_utils.basic.AnsibleModule.run_command", + side_effect=lambda cmd, **kwargs: next(responses), + ) + + +def test_get_token_signin(mocker, capfd): + master_password = "masterpass" + with set_module_args( + { + "search_terms": [{"name": "My Item"}], + "auto_login": {"subdomain": "mycompany", "master_password": master_password}, + } + ): + _patch_bin_path(mocker) + mocker.patch.object( + OnePasswordConfig, + "config_file_path", + new_callable=PropertyMock, + return_value="/home/user/.op/config", + ) + mocker.patch( + "ansible_collections.community.general.plugins.modules.onepassword_info.os.path.isfile", + return_value=True, + ) + _patch_run_command( + mocker, + iter( + [ + (0, "", ""), # account list → out empty, not logged in + (0, "mytoken\n", ""), # signin --raw --account mycompany + (0, ITEM_JSON, ""), # item get --format json My Item --session=mytoken + ] + ), + ) + + with pytest.raises(SystemExit): + onepassword_info.main() + + out, _ = capfd.readouterr() + result = json.loads(out) + assert not result.get("failed"), result.get("msg") + assert result["onepassword"]["My Item"]["password"] == "secret123" + + +def test_full_login(mocker, capfd): + master_password = "masterpass" + with set_module_args( + { + "search_terms": [{"name": "My Item"}], + "auto_login": { + "subdomain": "mycompany", + "username": "user@example.com", + "secret_key": "mysecretkey", + "master_password": master_password, + }, + } + ): + _patch_bin_path(mocker) + mocker.patch.object( + OnePasswordConfig, + "config_file_path", + new_callable=PropertyMock, + return_value=None, + ) + _patch_run_command( + mocker, + iter( + [ + (0, "", ""), # account list → not logged in + (0, "mytoken\n", ""), # account add --raw --signin → token + (0, ITEM_JSON, ""), # item get --format json My Item --session=mytoken + ] + ), + ) + + with pytest.raises(SystemExit): + onepassword_info.main() + + out, _ = capfd.readouterr() + result = json.loads(out) + assert not result.get("failed"), result.get("msg") + assert result["onepassword"]["My Item"]["password"] == "secret123" diff --git a/tests/unit/plugins/modules/test_onepassword_info.yaml b/tests/unit/plugins/modules/test_onepassword_info.yaml new file mode 100644 index 0000000000..89c5b45600 --- /dev/null +++ b/tests/unit/plugins/modules/test_onepassword_info.yaml @@ -0,0 +1,134 @@ +# Copyright (c) 2026, Alexei Znamensky +# 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 + +--- +anchors: + env: &env {environ_update: {LANGUAGE: C, LC_ALL: C}, check_rc: false} + item_json: &item_json '{"fields": [{"id": "password", "label": "password", "value": "secret123"}]}' + account_list: &account_list + command: [/testbin/op, account, list] + environ: *env + rc: 0 + out: "account info\n" + err: '' + account_get: &account_get + command: [/testbin/op, account, get] + environ: *env + rc: 0 + out: '' + err: '' + +test_cases: + - id: already_logged_in + input: + search_terms: + - name: My Item + output: + changed: false + onepassword: + "My Item": + password: secret123 + mocks: + run_command: + - *account_list + - *account_get + - command: [/testbin/op, item, get, --format, json, My Item] + environ: *env + rc: 0 + out: *item_json + err: '' + + - id: already_logged_in_with_vault + input: + search_terms: + - name: My Item + vault: Personal + output: + changed: false + onepassword: + "My Item": + password: secret123 + mocks: + run_command: + - *account_list + - *account_get + - command: [/testbin/op, item, get, --format, json, My Item, --vault=Personal] + environ: *env + rc: 0 + out: *item_json + err: '' + + - id: already_logged_in_section_field + input: + search_terms: + - name: My Item + field: Custom Field + section: My Section + output: + changed: false + onepassword: + "My Item": + Custom Field: custom_val + mocks: + run_command: + - *account_list + - *account_get + - command: [/testbin/op, item, get, --format, json, My Item] + environ: *env + rc: 0 + out: >- + {"fields": [{"id": "cf_id", "label": "Custom Field", "value": "custom_val", + "section": {"id": "sec_id", "label": "My Section"}}]} + err: '' + + - id: item_not_found + input: + search_terms: + - name: Missing Item + output: + failed: true + msg: "Unable to find an item in 1Password named 'Missing Item'." + mocks: + run_command: + - *account_list + - *account_get + - command: [/testbin/op, item, get, --format, json, Missing Item] + environ: *env + rc: 1 + out: '' + err: '"Missing Item" not found in vault' + + - id: field_not_found + input: + search_terms: + - name: My Item + field: nonexistent + output: + failed: true + msg: "Unable to find an item in 1Password named 'My Item' with the field 'nonexistent'." + mocks: + run_command: + - *account_list + - *account_get + - command: [/testbin/op, item, get, --format, json, My Item] + environ: *env + rc: 0 + out: *item_json + err: '' + + - id: not_logged_in_no_auto_login + input: + search_terms: + - name: My Item + output: + failed: true + mocks: + run_command: + - command: [/testbin/op, account, list] + environ: *env + rc: 0 + out: '' + err: '' + onepassword_config: + config_file_path: ~ From 71786e9b70ccccbd1fae19af4e15a36e3245e9bb Mon Sep 17 00:00:00 2001 From: Alexei Znamensky Date: Sat, 23 May 2026 12:56:32 +1200 Subject: [PATCH 3/5] test(onepassword_info): fix pylint disallowed-name for _ variable Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/plugins/modules/test_onepassword_info.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/plugins/modules/test_onepassword_info.py b/tests/unit/plugins/modules/test_onepassword_info.py index f617215b8c..079b495038 100644 --- a/tests/unit/plugins/modules/test_onepassword_info.py +++ b/tests/unit/plugins/modules/test_onepassword_info.py @@ -90,7 +90,7 @@ def test_get_token_signin(mocker, capfd): with pytest.raises(SystemExit): onepassword_info.main() - out, _ = capfd.readouterr() + out, dummy = capfd.readouterr() result = json.loads(out) assert not result.get("failed"), result.get("msg") assert result["onepassword"]["My Item"]["password"] == "secret123" @@ -130,7 +130,7 @@ def test_full_login(mocker, capfd): with pytest.raises(SystemExit): onepassword_info.main() - out, _ = capfd.readouterr() + out, dummy = capfd.readouterr() result = json.loads(out) assert not result.get("failed"), result.get("msg") assert result["onepassword"]["My Item"]["password"] == "secret123" From 79e5010059a36ec18162a3c78292210bb2d19b2d Mon Sep 17 00:00:00 2001 From: Alexei Znamensky Date: Sat, 23 May 2026 13:53:48 +1200 Subject: [PATCH 4/5] fix file formatting --- tests/unit/plugins/modules/test_onepassword_info.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/plugins/modules/test_onepassword_info.py b/tests/unit/plugins/modules/test_onepassword_info.py index 079b495038..acf3a92c6f 100644 --- a/tests/unit/plugins/modules/test_onepassword_info.py +++ b/tests/unit/plugins/modules/test_onepassword_info.py @@ -15,7 +15,6 @@ from ansible_collections.community.general.plugins.modules import onepassword_in from .uthelper import RunCommandMock, TestCaseMock, UTHelper - ITEM_JSON = json.dumps( { "fields": [ From d559bd73ec8db665afd91576556d99e6f325ea25 Mon Sep 17 00:00:00 2001 From: Alexei Znamensky Date: Sat, 23 May 2026 14:08:09 +1200 Subject: [PATCH 5/5] feat(onepassword_info): add changelog fragment for PR 12089 Co-Authored-By: Claude Sonnet 4.6 --- changelogs/fragments/12089-onepassword-info-cli-v2.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/12089-onepassword-info-cli-v2.yml diff --git a/changelogs/fragments/12089-onepassword-info-cli-v2.yml b/changelogs/fragments/12089-onepassword-info-cli-v2.yml new file mode 100644 index 0000000000..081f2832f3 --- /dev/null +++ b/changelogs/fragments/12089-onepassword-info-cli-v2.yml @@ -0,0 +1,2 @@ +bugfixes: + - onepassword_info - rewrite module to support op CLI v2 (https://github.com/ansible-collections/community.general/issues/5303, https://github.com/ansible-collections/community.general/issues/7321, https://github.com/ansible-collections/community.general/pull/12089).