Repository: ansible-collections/community.general
Pull request #9821: systemd_info - add wildcards support
Author: login: NomakCooper
Labels: name: feature
description: This issue/PR relates to a feature request
name: module
description: module
name: tests
description: tests
name: integration
description: tests/integration
name: module_utils
description: module_utils
name: plugins
description: plugin (any type)
name: backport-10
description: Automatically create a backport for the stable-10 branch
Created: 2025-03-02T21:40:27Z | Merged: 2025-03-15T06:36:16Z

##### SUMMARY
<!--- Describe the change below, including rationale and design decisions -->
This PR adds support for wildcard expressions.

Examples:

```yaml
# Gather info using wildcards/expression
- name: Gather info of units that start with 'systemd-'
  community.general.systemd_info:
    unitname:
      - 'systemd-*'
  register: results
```

- Added new line in main description.
- Added new line in `unitname` description
- Added new example
- Added new integration tasks to test wildcard expressions and duplicates
<!--- HINT: Include "Fixes #nnn" if you are fixing an existing issue -->

<!--- Please do not forget to include a changelog fragment:
      https://docs.ansible.com/ansible/devel/community/collection_development_process.html#creating-changelog-fragments
      No need to include one for docs-only or test-only PR, and for new plugin/module PRs.
      Read about more details in CONTRIBUTING.md.
      -->

##### ISSUE TYPE
<!--- Pick one or more below and delete the rest.
      'Test Pull Request' is for PRs that add/extend tests without code changes. -->
- Feature Pull Request

##### COMPONENT NAME
<!--- Write the SHORT NAME of the module, plugin, task or feature below. -->
systemd_info
##### ADDITIONAL INFORMATION
<!--- Include additional information to help people understand the change here -->
<!--- A step-by-step reproduction of the problem is helpful if there is no related issue -->

<!--- Paste verbatim command output below, e.g. before and after your change -->
```paste below

```



Diff:
diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml
index 60634039115..c92ce760344 100644
--- a/.github/BOTMETA.yml
+++ b/.github/BOTMETA.yml
@@ -404,6 +404,8 @@ files:
     maintainers: russoz
   $module_utils/ssh.py:
     maintainers: russoz
+  $module_utils/systemd.py:
+    maintainers: NomakCooper
   $module_utils/storage/hpe3par/hpe3par.py:
     maintainers: farhan7500 gautamphegde
   $module_utils/utm_utils.py:
diff --git a/changelogs/fragments/9821-systemd_info-add-wildcards.yml b/changelogs/fragments/9821-systemd_info-add-wildcards.yml
new file mode 100644
index 00000000000..a1349f8533f
--- /dev/null
+++ b/changelogs/fragments/9821-systemd_info-add-wildcards.yml
@@ -0,0 +1,2 @@
+minor_changes:
+  - systemd_info - add wildcard expression support in ``unitname`` option (https://github.com/ansible-collections/community.general/pull/9821).
\ No newline at end of file
diff --git a/plugins/module_utils/systemd.py b/plugins/module_utils/systemd.py
new file mode 100644
index 00000000000..5d74118d12a
--- /dev/null
+++ b/plugins/module_utils/systemd.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025, Marco Noce <nce.marco@gmail.com>
+# 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.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt
+
+
+def systemd_runner(module, command, **kwargs):
+    arg_formats = dict(
+        version=cmd_runner_fmt.as_fixed("--version"),
+        list_units=cmd_runner_fmt.as_fixed(["list-units", "--no-pager"]),
+        types=cmd_runner_fmt.as_func(lambda v: [] if not v else ["--type", ",".join(v)]),
+        all=cmd_runner_fmt.as_fixed("--all"),
+        plain=cmd_runner_fmt.as_fixed("--plain"),
+        no_legend=cmd_runner_fmt.as_fixed("--no-legend"),
+        show=cmd_runner_fmt.as_fixed("show"),
+        props=cmd_runner_fmt.as_func(lambda v: [] if not v else ["-p", ",".join(v)]),
+        dashdash=cmd_runner_fmt.as_fixed("--"),
+        unit=cmd_runner_fmt.as_list(),
+    )
+
+    runner = CmdRunner(
+        module,
+        command=command,
+        arg_formats=arg_formats,
+        check_rc=True,
+        **kwargs
+    )
+    return runner
diff --git a/plugins/modules/systemd_info.py b/plugins/modules/systemd_info.py
index 02845e94257..2bf06a92d60 100644
--- a/plugins/modules/systemd_info.py
+++ b/plugins/modules/systemd_info.py
@@ -20,13 +20,16 @@
     but only with the minimal properties (RV(units.name), RV(units.loadstate), RV(units.activestate), RV(units.substate)).
   - When O(unitname) and O(extra_properties) are used, the module first checks if the unit exists,
     then check if properties exist. If not, the module fails.
+  - When O(unitname) is used with wildcard expressions, the module checks for units that match the indicated expressions,
+    if units are not present for all the indicated expressions, the module fails.
 version_added: "10.4.0"
 options:
   unitname:
     description:
       - List of unit names to process.
       - It supports C(.service), C(.target), C(.socket), and C(.mount) units type.
-      - Each name must correspond to the full name of the C(systemd) unit.
+      - Each name must correspond to the full name of the C(systemd) unit or to a wildcard expression like V('ssh*') and V('*.service').
+      - Wildcard expressions in O(unitname) are supported since community.general 10.5.0.
     type: list
     elements: str
     default: []
@@ -62,6 +65,13 @@
     extra_properties:
       - Description
     register: results
+
+# Gather info using wildcards/expression
+- name: Gather info of units that start with 'systemd-'
+  community.general.systemd_info:
+    unitname:
+      - 'systemd-*'
+  register: results
 '''
 
 RETURN = r'''
@@ -195,11 +205,28 @@
   }
 '''
 
+import fnmatch
 from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.community.general.plugins.module_utils.systemd import systemd_runner
+
+
+def get_version(runner):
+    with runner("version") as ctx:
+        rc, stdout, stderr = ctx.run()
+    return stdout.strip()
 
 
-def run_command(module, cmd):
-    rc, stdout, stderr = module.run_command(cmd, check_rc=True)
+def list_units(runner, types_value):
+    context = "list_units types all plain no_legend"
+    with runner(context) as ctx:
+        rc, stdout, stderr = ctx.run(types=types_value)
+    return stdout.strip()
+
+
+def show_unit_properties(runner, prop_list, unit):
+    context = "show props dashdash unit"
+    with runner(context) as ctx:
+        rc, stdout, stderr = ctx.run(props=prop_list, unit=unit)
     return stdout.strip()
 
 
@@ -214,9 +241,8 @@ def parse_show_output(output):
     return result
 
 
-def get_unit_properties(module, systemctl_bin, unit, prop_list):
-    cmd = [systemctl_bin, "show", "-p", ",".join(prop_list), "--", unit]
-    output = run_command(module, cmd)
+def get_unit_properties(runner, prop_list, unit):
+    output = show_unit_properties(runner, prop_list, unit)
     return parse_show_output(output)
 
 
@@ -235,125 +261,141 @@ def determine_category(unit):
 
 def extract_unit_properties(unit_data, prop_list):
     lowerprop = [x.lower() for x in prop_list]
-    extracted = {
-        prop: unit_data[prop] for prop in lowerprop if prop in unit_data
-    }
-    return extracted
+    return {prop: unit_data[prop] for prop in lowerprop if prop in unit_data}
+
 
+def unit_exists(unit, units_info):
+    info = units_info.get(unit, {})
+    loadstate = info.get("loadstate", "").lower()
+    return loadstate not in ("not-found", "masked")
 
-def unit_exists(module, systemctl_bin, unit):
-    cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit]
-    rc, stdout, stderr = module.run_command(cmd)
-    return (rc == 0)
 
+def get_category_base_props(category):
+    base_props = {
+        'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'],
+        'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
+        'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
+        'mount': ['Where', 'What', 'Options', 'Type']
+    }
+    return base_props.get(category, [])
 
-def validate_unit_and_properties(module, systemctl_bin, unit, extra_properties):
-    cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit]
 
-    output = run_command(module, cmd)
-    if "loadstate=not-found" in output.lower():
+def validate_unit_and_properties(runner, unit, extra_properties, units_info, property_cache):
+    if not unit_exists(unit, units_info):
         module.fail_json(msg="Unit '{0}' does not exist or is inaccessible.".format(unit))
 
+    category = determine_category(unit)
+
+    if not category:
+        module.fail_json(msg="Could not determine the category for unit '{0}'.".format(unit))
+
+    state_props = ['LoadState', 'ActiveState', 'SubState']
+    props = get_category_base_props(category)
+    full_props = set(props + state_props + extra_properties)
+
+    if unit not in property_cache:
+        unit_data = get_unit_properties(runner, full_props, unit)
+        property_cache[unit] = unit_data
+    else:
+        unit_data = property_cache[unit]
     if extra_properties:
-        unit_data = get_unit_properties(module, systemctl_bin, unit, extra_properties)
         missing_props = [prop for prop in extra_properties if prop.lower() not in unit_data]
         if missing_props:
             module.fail_json(msg="The following properties do not exist for unit '{0}': {1}".format(unit, ", ".join(missing_props)))
 
+    return True
 
-def main():
-    module_args = dict(
-        unitname=dict(type='list', elements='str', default=[]),
-        extra_properties=dict(type='list', elements='str', default=[])
-    )
 
-    module = AnsibleModule(
-        argument_spec=module_args,
-        supports_check_mode=True
-    )
+def process_wildcards(selected_units, all_units, module):
+    resolved_units = {}
+    non_matching_patterns = []
 
-    systemctl_bin = module.get_bin_path('systemctl', required=True)
+    for pattern in selected_units:
+        matches = fnmatch.filter(all_units, pattern)
+        if not matches:
+            non_matching_patterns.append(pattern)
+        else:
+            for match in matches:
+                resolved_units[match] = True
 
-    run_command(module, [systemctl_bin, '--version'])
+    if not resolved_units:
+        module.fail_json(msg="No units match any of the provided patterns: {}".format(", ".join(non_matching_patterns)))
 
-    base_properties = {
-        'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'],
-        'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
-        'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
-        'mount': ['Where', 'What', 'Options', 'Type']
-    }
-    state_props = ['LoadState', 'ActiveState', 'SubState']
+    return resolved_units, non_matching_patterns
 
-    results = {}
 
-    if not module.params['unitname']:
-        list_cmd = [
-            systemctl_bin, "list-units",
-            "--no-pager",
-            "--type", "service,target,socket,mount",
-            "--all",
-            "--plain",
-            "--no-legend"
-        ]
-        list_output = run_command(module, list_cmd)
-        for line in list_output.splitlines():
-            tokens = line.split()
-            if len(tokens) < 4:
-                continue
-
-            unit_name = tokens[0]
-            loadstate = tokens[1]
-            activestate = tokens[2]
-            substate = tokens[3]
-
-            fact = {
-                "name": unit_name,
-                "loadstate": loadstate,
-                "activestate": activestate,
-                "substate": substate
-            }
-
-            if loadstate in ("not-found", "masked"):
-                results[unit_name] = fact
-                continue
-
-            category = determine_category(unit_name)
-            if not category:
-                results[unit_name] = fact
-                continue
-
-            props = base_properties.get(category, [])
-            full_props = set(props + state_props)
-            unit_data = get_unit_properties(module, systemctl_bin, unit_name, full_props)
-
-            fact.update(extract_unit_properties(unit_data, full_props))
-            results[unit_name] = fact
+def process_unit(runner, unit, extra_properties, units_info, property_cache, state_props):
+    if not unit_exists(unit, units_info):
+        return units_info.get(unit, {"name": unit, "loadstate": "not-found"})
 
-    else:
-        selected_units = module.params['unitname']
-        extra_properties = module.params['extra_properties']
+    validate_unit_and_properties(runner, unit, extra_properties, units_info, property_cache)
+    category = determine_category(unit)
+
+    if not category:
+        module.fail_json(msg="Could not determine the category for unit '{0}'.".format(unit))
 
-        for unit in selected_units:
-            validate_unit_and_properties(module, systemctl_bin, unit, extra_properties)
-            category = determine_category(unit)
+    props = get_category_base_props(category)
+    full_props = set(props + state_props + extra_properties)
+    unit_data = property_cache[unit]
+    fact = {"name": unit}
+    minimal_keys = ["LoadState", "ActiveState", "SubState"]
+    fact.update(extract_unit_properties(unit_data, minimal_keys))
+    ls = unit_data.get("loadstate", "").lower()
 
-            if not category:
-                module.fail_json(msg="Could not determine the category for unit '{0}'.".format(unit))
+    if ls not in ("not-found", "masked"):
+        fact.update(extract_unit_properties(unit_data, full_props))
 
-            props = base_properties.get(category, [])
-            full_props = set(props + state_props + extra_properties)
-            unit_data = get_unit_properties(module, systemctl_bin, unit, full_props)
-            fact = {"name": unit}
-            minimal_keys = ["LoadState", "ActiveState", "SubState"]
+    return fact
 
-            fact.update(extract_unit_properties(unit_data, minimal_keys))
 
-            ls = unit_data.get("loadstate", "").lower()
-            if ls not in ("not-found", "masked"):
-                fact.update(extract_unit_properties(unit_data, full_props))
+def main():
+    global module
+    module_args = dict(
+        unitname=dict(type='list', elements='str', default=[]),
+        extra_properties=dict(type='list', elements='str', default=[])
+    )
+    module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
+    systemctl_bin = module.get_bin_path('systemctl', required=True)
+
+    base_runner = systemd_runner(module, systemctl_bin)
 
-            results[unit] = fact
+    get_version(base_runner)
+
+    state_props = ['LoadState', 'ActiveState', 'SubState']
+    results = {}
+
+    unit_types = ["service", "target", "socket", "mount"]
+
+    list_output = list_units(base_runner, unit_types)
+    units_info = {}
+    for line in list_output.splitlines():
+        tokens = line.split()
+        if len(tokens) < 4:
+            continue
+        unit_name = tokens[0]
+        loadstate = tokens[1]
+        activestate = tokens[2]
+        substate = tokens[3]
+        units_info[unit_name] = {
+            "name": unit_name,
+            "loadstate": loadstate,
+            "activestate": activestate,
+            "substate": substate,
+        }
+
+    property_cache = {}
+    extra_properties = module.params['extra_properties']
+
+    if module.params['unitname']:
+        selected_units = module.params['unitname']
+        all_units = list(units_info)
+        resolved_units, non_matching = process_wildcards(selected_units, all_units, module)
+        units_to_process = sorted(resolved_units)
+    else:
+        units_to_process = list(units_info)
 
+    for unit in units_to_process:
+        results[unit] = process_unit(base_runner, unit, extra_properties, units_info, property_cache, state_props)
     module.exit_json(changed=False, units=results)
 
 
diff --git a/tests/integration/targets/systemd_info/tasks/tests.yml b/tests/integration/targets/systemd_info/tasks/tests.yml
index 0806885b9bf..c0805954451 100644
--- a/tests/integration/targets/systemd_info/tasks/tests.yml
+++ b/tests/integration/targets/systemd_info/tasks/tests.yml
@@ -104,4 +104,36 @@
       - journal_extra.units['systemd-journald.service'].restart is defined
       - journal_extra.units['systemd-journald.service'].description == journald_shell.Description
       - journal_extra.units['systemd-journald.service'].restart == journald_shell.Restart
-    success_msg: "Success: Extra property values are correct."
\ No newline at end of file
+    success_msg: "Success: Extra property values are correct."
+
+- name: Gather info using wildcard pattern for services
+  community.general.systemd_info:
+    unitname:
+      - '*.service'
+    extra_properties:
+      - Description
+  register: result_wildcards
+
+- name: Assert that at least one service unit was returned
+  ansible.builtin.assert:
+    that:
+      - result_wildcards.units | length > 0
+
+- name: Gather info using multiple wildcard patterns
+  community.general.systemd_info:
+    unitname:
+      - '*.service'
+      - 'ssh*'
+  register: result_multi
+
+- name: Debug multi-wildcard results
+  ansible.builtin.debug:
+    var: result_multi.units
+
+- name: Assert deduplication of units
+  ansible.builtin.assert:
+    that:
+      - unique_keys | length == all_keys | length
+  vars:
+    all_keys: "{{ result_multi.units | dict2items | map(attribute='key') | list }}"
+    unique_keys: "{{ all_keys | unique }}"
\ No newline at end of file


Reviews:
user: login: russoz
body: hi @NomakCooper, thanks for the PR! :-) Couple of initial comments.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-08T04:17:52Z
user: login: NomakCooper
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T06:45:44Z
user: login: NomakCooper
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T06:49:06Z
user: login: NomakCooper
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T06:55:21Z
user: login: NomakCooper
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T07:10:11Z
user: login: NomakCooper
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T07:19:53Z
user: login: russoz
body: Hi @NomakCooper 

`CmdRunner` is meant to make us not repeat ourselves all the time. Please see how it is used (`pipx`, `xfconf` and `gconftool2` are good examples).

I will have some improvement suggestions on the argformats as well, but that's easier with one consolidated runner.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-09T14:08:40Z
user: login: NomakCooper
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T14:27:50Z
user: login: NomakCooper
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T16:35:33Z
user: login: russoz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T20:01:46Z
user: login: russoz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T20:01:53Z
user: login: NomakCooper
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T20:10:16Z
user: login: NomakCooper
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T20:10:49Z
user: login: russoz
body: Hi there, as mentioned, a couple of suggestions on the arg formats, but even without these adjustments, LGTM
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-11T19:24:54Z
user: login: russoz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T09:10:21Z
user: login: NomakCooper
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T14:23:23Z
user: login: russoz
body: LGTM
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-12T18:35:50Z

Review comments:
diff_hunk: @@ -235,30 +245,88 @@ def determine_category(unit):
 
 def extract_unit_properties(unit_data, prop_list):
     lowerprop = [x.lower() for x in prop_list]
-    extracted = {
-        prop: unit_data[prop] for prop in lowerprop if prop in unit_data
-    }
-    return extracted
-
+    return {prop: unit_data[prop] for prop in lowerprop if prop in unit_data}
 
-def unit_exists(module, systemctl_bin, unit):
-    cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit]
-    rc, stdout, stderr = module.run_command(cmd)
-    return (rc == 0)
 
+def unit_exists(unit, units_info):
+    info = units_info.get(unit, {})
+    loadstate = info.get("loadstate", "").lower()
+    return loadstate not in ("not-found", "masked")
 
-def validate_unit_and_properties(module, systemctl_bin, unit, extra_properties):
-    cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit]
 
-    output = run_command(module, cmd)
-    if "loadstate=not-found" in output.lower():
+def validate_unit_and_properties(module, systemctl_bin, unit, extra_properties, units_info, property_cache):
+    if not unit_exists(unit, units_info):
         module.fail_json(msg="Unit '{0}' does not exist or is inaccessible.".format(unit))
 
+    category = determine_category(unit)
+
+    if not category:
+        module.fail_json(msg="Could not determine the category for unit '{0}'.".format(unit))
+
+    base_props = {
+        'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'],
+        'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
+        'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
+        'mount': ['Where', 'What', 'Options', 'Type']
+    }
path: plugins/modules/systemd_info.py
user: login: russoz
body: This is repeating the block in line 342. 

If I may suggest, refactor this out to a new function, something like:
```python
def get_category_base_props(category):
    base_props = {
        'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'],
        'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
        'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
        'mount': ['Where', 'What', 'Options', 'Type']
    }
    return base_props.get(category, [])
```

and remove the dictionaries from both here and `main()`, while adjusting line 274 and 319 (maybe more, I wasn't thorough) to:
```python
    props = get_category_base_props(category)
```
then, of course, you no longer need to toss `base_props` around
created_at: 2025-03-08T02:39:53Z
author_association: COLLABORATOR
diff_hunk: @@ -235,30 +245,88 @@ def determine_category(unit):
 
 def extract_unit_properties(unit_data, prop_list):
     lowerprop = [x.lower() for x in prop_list]
-    extracted = {
-        prop: unit_data[prop] for prop in lowerprop if prop in unit_data
-    }
-    return extracted
-
+    return {prop: unit_data[prop] for prop in lowerprop if prop in unit_data}
 
-def unit_exists(module, systemctl_bin, unit):
-    cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit]
-    rc, stdout, stderr = module.run_command(cmd)
-    return (rc == 0)
 
+def unit_exists(unit, units_info):
+    info = units_info.get(unit, {})
+    loadstate = info.get("loadstate", "").lower()
+    return loadstate not in ("not-found", "masked")
 
-def validate_unit_and_properties(module, systemctl_bin, unit, extra_properties):
-    cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit]
 
-    output = run_command(module, cmd)
-    if "loadstate=not-found" in output.lower():
+def validate_unit_and_properties(module, systemctl_bin, unit, extra_properties, units_info, property_cache):
+    if not unit_exists(unit, units_info):
         module.fail_json(msg="Unit '{0}' does not exist or is inaccessible.".format(unit))
 
+    category = determine_category(unit)
+
+    if not category:
+        module.fail_json(msg="Could not determine the category for unit '{0}'.".format(unit))
+
+    base_props = {
+        'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'],
+        'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
+        'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
+        'mount': ['Where', 'What', 'Options', 'Type']
+    }
+
+    state_props = ['LoadState', 'ActiveState', 'SubState']
+    props = base_props.get(category, [])
+    full_props = set(props + state_props + extra_properties)
+
+    if unit not in property_cache:
+        unit_data = get_unit_properties(module, systemctl_bin, unit, full_props)
+        property_cache[unit] = unit_data
+    else:
+        unit_data = property_cache[unit]
+
     if extra_properties:
-        unit_data = get_unit_properties(module, systemctl_bin, unit, extra_properties)
         missing_props = [prop for prop in extra_properties if prop.lower() not in unit_data]
         if missing_props:
-            module.fail_json(msg="The following properties do not exist for unit '{0}': {1}".format(unit, ", ".join(missing_props)))
+            module.fail_json(msg="The following properties do not exist for unit '{0}': {1}".format(
+                unit, ", ".join(missing_props)))
+    return True
+
+
+def process_wildcards(selected_units, all_units, module):
+    resolved_units = {}
+    non_matching_patterns = []
+
+    for pattern in selected_units:
+        matches = [unit for unit in all_units if fnmatch.fnmatch(unit, pattern)]
path: plugins/modules/systemd_info.py
user: login: russoz
body: I never used `fnmatch` myself, so I checked up the docs for it. From what is there, I think this line might benefit from `fnmatch.filter()`:
https://docs.python.org/3/library/fnmatch.html#fnmatch.filter

Maybe:
```suggestion
        matches = fnmatch.filter(all_units, pattern)
```

created_at: 2025-03-08T02:45:23Z
author_association: COLLABORATOR
diff_hunk: @@ -267,92 +335,64 @@ def main():
         extra_properties=dict(type='list', elements='str', default=[])
     )
 
-    module = AnsibleModule(
-        argument_spec=module_args,
-        supports_check_mode=True
-    )
-
+    module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
     systemctl_bin = module.get_bin_path('systemctl', required=True)
-
     run_command(module, [systemctl_bin, '--version'])
 
-    base_properties = {
+    base_props = {
         'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'],
         'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
         'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
         'mount': ['Where', 'What', 'Options', 'Type']
     }
-    state_props = ['LoadState', 'ActiveState', 'SubState']
 
+    state_props = ['LoadState', 'ActiveState', 'SubState']
     results = {}
 
-    if not module.params['unitname']:
-        list_cmd = [
-            systemctl_bin, "list-units",
-            "--no-pager",
-            "--type", "service,target,socket,mount",
-            "--all",
-            "--plain",
-            "--no-legend"
-        ]
-        list_output = run_command(module, list_cmd)
-        for line in list_output.splitlines():
-            tokens = line.split()
-            if len(tokens) < 4:
-                continue
-
-            unit_name = tokens[0]
-            loadstate = tokens[1]
-            activestate = tokens[2]
-            substate = tokens[3]
-
-            fact = {
-                "name": unit_name,
-                "loadstate": loadstate,
-                "activestate": activestate,
-                "substate": substate
-            }
-
-            if loadstate in ("not-found", "masked"):
-                results[unit_name] = fact
-                continue
-
-            category = determine_category(unit_name)
-            if not category:
-                results[unit_name] = fact
-                continue
-
-            props = base_properties.get(category, [])
-            full_props = set(props + state_props)
-            unit_data = get_unit_properties(module, systemctl_bin, unit_name, full_props)
-
-            fact.update(extract_unit_properties(unit_data, full_props))
-            results[unit_name] = fact
-
-    else:
+    list_cmd = [
+        systemctl_bin, "list-units",
+        "--no-pager",
+        "--type", "service,target,socket,mount",
+        "--all",
+        "--plain",
+        "--no-legend"
+    ]
+
+    list_output = run_command(module, list_cmd)
path: plugins/modules/systemd_info.py
user: login: russoz
body: You must set the env vars to ensure the command will always execute in en_US locale, to prevent parsing errors when the command output comes in different languages. 

You might want to consider using `CmdRunner`. See https://docs.ansible.com/ansible/latest/collections/community/general/docsite/guide_cmdrunner.html for more details. Amongst other features, `CmdRunner` automatically set the locale for you.
created_at: 2025-03-08T03:15:07Z
author_association: COLLABORATOR
diff_hunk: @@ -267,92 +335,64 @@ def main():
         extra_properties=dict(type='list', elements='str', default=[])
     )
 
-    module = AnsibleModule(
-        argument_spec=module_args,
-        supports_check_mode=True
-    )
-
+    module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
     systemctl_bin = module.get_bin_path('systemctl', required=True)
-
     run_command(module, [systemctl_bin, '--version'])
 
-    base_properties = {
+    base_props = {
         'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'],
         'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
         'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
         'mount': ['Where', 'What', 'Options', 'Type']
     }
-    state_props = ['LoadState', 'ActiveState', 'SubState']
 
+    state_props = ['LoadState', 'ActiveState', 'SubState']
     results = {}
 
-    if not module.params['unitname']:
-        list_cmd = [
-            systemctl_bin, "list-units",
-            "--no-pager",
-            "--type", "service,target,socket,mount",
-            "--all",
-            "--plain",
-            "--no-legend"
-        ]
-        list_output = run_command(module, list_cmd)
-        for line in list_output.splitlines():
-            tokens = line.split()
-            if len(tokens) < 4:
-                continue
-
-            unit_name = tokens[0]
-            loadstate = tokens[1]
-            activestate = tokens[2]
-            substate = tokens[3]
-
-            fact = {
-                "name": unit_name,
-                "loadstate": loadstate,
-                "activestate": activestate,
-                "substate": substate
-            }
-
-            if loadstate in ("not-found", "masked"):
-                results[unit_name] = fact
-                continue
-
-            category = determine_category(unit_name)
-            if not category:
-                results[unit_name] = fact
-                continue
-
-            props = base_properties.get(category, [])
-            full_props = set(props + state_props)
-            unit_data = get_unit_properties(module, systemctl_bin, unit_name, full_props)
-
-            fact.update(extract_unit_properties(unit_data, full_props))
-            results[unit_name] = fact
-
-    else:
+    list_cmd = [
+        systemctl_bin, "list-units",
+        "--no-pager",
+        "--type", "service,target,socket,mount",
+        "--all",
+        "--plain",
+        "--no-legend"
+    ]
+
+    list_output = run_command(module, list_cmd)
+    units_info = {}
+
+    for line in list_output.splitlines():
+        tokens = line.split()
+        if len(tokens) < 4:
+            continue
+        unit_name = tokens[0]
+        loadstate = tokens[1]
+        activestate = tokens[2]
+        substate = tokens[3]
+        units_info[unit_name] = {
+            "name": unit_name,
+            "loadstate": loadstate,
+            "activestate": activestate,
+            "substate": substate,
+        }
+
+    property_cache = {}
+
+    extra_properties = module.params['extra_properties']
+
+    if module.params['unitname']:
         selected_units = module.params['unitname']
-        extra_properties = module.params['extra_properties']
-
-        for unit in selected_units:
-            validate_unit_and_properties(module, systemctl_bin, unit, extra_properties)
-            category = determine_category(unit)
-
-            if not category:
-                module.fail_json(msg="Could not determine the category for unit '{0}'.".format(unit))
-
-            props = base_properties.get(category, [])
-            full_props = set(props + state_props + extra_properties)
-            unit_data = get_unit_properties(module, systemctl_bin, unit, full_props)
-            fact = {"name": unit}
-            minimal_keys = ["LoadState", "ActiveState", "SubState"]
-
-            fact.update(extract_unit_properties(unit_data, minimal_keys))
-
-            ls = unit_data.get("loadstate", "").lower()
-            if ls not in ("not-found", "masked"):
-                fact.update(extract_unit_properties(unit_data, full_props))
+        all_units = list(units_info.keys())
+        resolved_units, non_matching = process_wildcards(selected_units, all_units, module)
+        units_to_process = sorted(resolved_units.keys())
+    else:
+        units_to_process = list(units_info.keys())
path: plugins/modules/systemd_info.py
user: login: russoz
body: I risk saying (without really testing) that `.keys()` are probably redundant in all these cases.

In line 389, `list()` is redundant, probably in 385 as well.
created_at: 2025-03-08T03:18:18Z
author_association: COLLABORATOR
diff_hunk: @@ -20,13 +20,15 @@
     but only with the minimal properties (RV(units.name), RV(units.loadstate), RV(units.activestate), RV(units.substate)).
   - When O(unitname) and O(extra_properties) are used, the module first checks if the unit exists,
     then check if properties exist. If not, the module fails.
+  - When O(unitname) is used with wildcard expressions, the module checks for units that match the indicated expressions,
+    if units are not present for all the indicated expressions, the module fails.
 version_added: "10.4.0"
 options:
   unitname:
     description:
       - List of unit names to process.
       - It supports C(.service), C(.target), C(.socket), and C(.mount) units type.
-      - Each name must correspond to the full name of the C(systemd) unit.
+      - Each name must correspond to the full name of the C(systemd) unit or to a wildcard expression like V('ssh*') and V('*.service').
path: plugins/modules/systemd_info.py
user: login: russoz
body: Please add a note indicating that wildcards are only accepted in community.general 10.5.0 or later versions.
created_at: 2025-03-08T03:30:21Z
author_association: COLLABORATOR
diff_hunk: @@ -235,30 +245,88 @@ def determine_category(unit):
 
 def extract_unit_properties(unit_data, prop_list):
     lowerprop = [x.lower() for x in prop_list]
-    extracted = {
-        prop: unit_data[prop] for prop in lowerprop if prop in unit_data
-    }
-    return extracted
-
+    return {prop: unit_data[prop] for prop in lowerprop if prop in unit_data}
 
-def unit_exists(module, systemctl_bin, unit):
-    cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit]
-    rc, stdout, stderr = module.run_command(cmd)
-    return (rc == 0)
 
+def unit_exists(unit, units_info):
+    info = units_info.get(unit, {})
+    loadstate = info.get("loadstate", "").lower()
+    return loadstate not in ("not-found", "masked")
 
-def validate_unit_and_properties(module, systemctl_bin, unit, extra_properties):
-    cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit]
 
-    output = run_command(module, cmd)
-    if "loadstate=not-found" in output.lower():
+def validate_unit_and_properties(module, systemctl_bin, unit, extra_properties, units_info, property_cache):
+    if not unit_exists(unit, units_info):
         module.fail_json(msg="Unit '{0}' does not exist or is inaccessible.".format(unit))
 
+    category = determine_category(unit)
+
+    if not category:
+        module.fail_json(msg="Could not determine the category for unit '{0}'.".format(unit))
+
+    base_props = {
+        'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'],
+        'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
+        'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
+        'mount': ['Where', 'What', 'Options', 'Type']
+    }
path: plugins/modules/systemd_info.py
user: login: NomakCooper
body: Oops. I thought I had fixed all occurrences to avoid repetitions, but I missed something as always. 
created_at: 2025-03-09T06:45:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -267,92 +335,64 @@ def main():
         extra_properties=dict(type='list', elements='str', default=[])
     )
 
-    module = AnsibleModule(
-        argument_spec=module_args,
-        supports_check_mode=True
-    )
-
+    module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
     systemctl_bin = module.get_bin_path('systemctl', required=True)
-
     run_command(module, [systemctl_bin, '--version'])
 
-    base_properties = {
+    base_props = {
         'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'],
         'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
         'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
         'mount': ['Where', 'What', 'Options', 'Type']
     }
-    state_props = ['LoadState', 'ActiveState', 'SubState']
 
+    state_props = ['LoadState', 'ActiveState', 'SubState']
     results = {}
 
-    if not module.params['unitname']:
-        list_cmd = [
-            systemctl_bin, "list-units",
-            "--no-pager",
-            "--type", "service,target,socket,mount",
-            "--all",
-            "--plain",
-            "--no-legend"
-        ]
-        list_output = run_command(module, list_cmd)
-        for line in list_output.splitlines():
-            tokens = line.split()
-            if len(tokens) < 4:
-                continue
-
-            unit_name = tokens[0]
-            loadstate = tokens[1]
-            activestate = tokens[2]
-            substate = tokens[3]
-
-            fact = {
-                "name": unit_name,
-                "loadstate": loadstate,
-                "activestate": activestate,
-                "substate": substate
-            }
-
-            if loadstate in ("not-found", "masked"):
-                results[unit_name] = fact
-                continue
-
-            category = determine_category(unit_name)
-            if not category:
-                results[unit_name] = fact
-                continue
-
-            props = base_properties.get(category, [])
-            full_props = set(props + state_props)
-            unit_data = get_unit_properties(module, systemctl_bin, unit_name, full_props)
-
-            fact.update(extract_unit_properties(unit_data, full_props))
-            results[unit_name] = fact
-
-    else:
+    list_cmd = [
+        systemctl_bin, "list-units",
+        "--no-pager",
+        "--type", "service,target,socket,mount",
+        "--all",
+        "--plain",
+        "--no-legend"
+    ]
+
+    list_output = run_command(module, list_cmd)
+    units_info = {}
+
+    for line in list_output.splitlines():
+        tokens = line.split()
+        if len(tokens) < 4:
+            continue
+        unit_name = tokens[0]
+        loadstate = tokens[1]
+        activestate = tokens[2]
+        substate = tokens[3]
+        units_info[unit_name] = {
+            "name": unit_name,
+            "loadstate": loadstate,
+            "activestate": activestate,
+            "substate": substate,
+        }
+
+    property_cache = {}
+
+    extra_properties = module.params['extra_properties']
+
+    if module.params['unitname']:
         selected_units = module.params['unitname']
-        extra_properties = module.params['extra_properties']
-
-        for unit in selected_units:
-            validate_unit_and_properties(module, systemctl_bin, unit, extra_properties)
-            category = determine_category(unit)
-
-            if not category:
-                module.fail_json(msg="Could not determine the category for unit '{0}'.".format(unit))
-
-            props = base_properties.get(category, [])
-            full_props = set(props + state_props + extra_properties)
-            unit_data = get_unit_properties(module, systemctl_bin, unit, full_props)
-            fact = {"name": unit}
-            minimal_keys = ["LoadState", "ActiveState", "SubState"]
-
-            fact.update(extract_unit_properties(unit_data, minimal_keys))
-
-            ls = unit_data.get("loadstate", "").lower()
-            if ls not in ("not-found", "masked"):
-                fact.update(extract_unit_properties(unit_data, full_props))
+        all_units = list(units_info.keys())
+        resolved_units, non_matching = process_wildcards(selected_units, all_units, module)
+        units_to_process = sorted(resolved_units.keys())
+    else:
+        units_to_process = list(units_info.keys())
path: plugins/modules/systemd_info.py
user: login: NomakCooper
body: I made the same mistake as in the first PR. **`units_info`** and **`resolved_units`** already give the keys by default, so **`.keys()`** isn't needed.
created_at: 2025-03-09T06:49:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,13 +20,15 @@
     but only with the minimal properties (RV(units.name), RV(units.loadstate), RV(units.activestate), RV(units.substate)).
   - When O(unitname) and O(extra_properties) are used, the module first checks if the unit exists,
     then check if properties exist. If not, the module fails.
+  - When O(unitname) is used with wildcard expressions, the module checks for units that match the indicated expressions,
+    if units are not present for all the indicated expressions, the module fails.
 version_added: "10.4.0"
 options:
   unitname:
     description:
       - List of unit names to process.
       - It supports C(.service), C(.target), C(.socket), and C(.mount) units type.
-      - Each name must correspond to the full name of the C(systemd) unit.
+      - Each name must correspond to the full name of the C(systemd) unit or to a wildcard expression like V('ssh*') and V('*.service').
path: plugins/modules/systemd_info.py
user: login: NomakCooper
body: It is definitely useful for those reading the documentation to know that wildcard expressions are only supported from version **`10.5.0`**.

However, while searching the documentation, I didn’t find a specific semantic markup to indicate a version, so I think a simple **`C(10.5.0)`** will have to be used.
created_at: 2025-03-09T06:55:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -235,30 +245,88 @@ def determine_category(unit):
 
 def extract_unit_properties(unit_data, prop_list):
     lowerprop = [x.lower() for x in prop_list]
-    extracted = {
-        prop: unit_data[prop] for prop in lowerprop if prop in unit_data
-    }
-    return extracted
-
+    return {prop: unit_data[prop] for prop in lowerprop if prop in unit_data}
 
-def unit_exists(module, systemctl_bin, unit):
-    cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit]
-    rc, stdout, stderr = module.run_command(cmd)
-    return (rc == 0)
 
+def unit_exists(unit, units_info):
+    info = units_info.get(unit, {})
+    loadstate = info.get("loadstate", "").lower()
+    return loadstate not in ("not-found", "masked")
 
-def validate_unit_and_properties(module, systemctl_bin, unit, extra_properties):
-    cmd = [systemctl_bin, "show", "-p", "LoadState", "--", unit]
 
-    output = run_command(module, cmd)
-    if "loadstate=not-found" in output.lower():
+def validate_unit_and_properties(module, systemctl_bin, unit, extra_properties, units_info, property_cache):
+    if not unit_exists(unit, units_info):
         module.fail_json(msg="Unit '{0}' does not exist or is inaccessible.".format(unit))
 
+    category = determine_category(unit)
+
+    if not category:
+        module.fail_json(msg="Could not determine the category for unit '{0}'.".format(unit))
+
+    base_props = {
+        'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'],
+        'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
+        'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
+        'mount': ['Where', 'What', 'Options', 'Type']
+    }
+
+    state_props = ['LoadState', 'ActiveState', 'SubState']
+    props = base_props.get(category, [])
+    full_props = set(props + state_props + extra_properties)
+
+    if unit not in property_cache:
+        unit_data = get_unit_properties(module, systemctl_bin, unit, full_props)
+        property_cache[unit] = unit_data
+    else:
+        unit_data = property_cache[unit]
+
     if extra_properties:
-        unit_data = get_unit_properties(module, systemctl_bin, unit, extra_properties)
         missing_props = [prop for prop in extra_properties if prop.lower() not in unit_data]
         if missing_props:
-            module.fail_json(msg="The following properties do not exist for unit '{0}': {1}".format(unit, ", ".join(missing_props)))
+            module.fail_json(msg="The following properties do not exist for unit '{0}': {1}".format(
+                unit, ", ".join(missing_props)))
+    return True
+
+
+def process_wildcards(selected_units, all_units, module):
+    resolved_units = {}
+    non_matching_patterns = []
+
+    for pattern in selected_units:
+        matches = [unit for unit in all_units if fnmatch.fnmatch(unit, pattern)]
path: plugins/modules/systemd_info.py
user: login: NomakCooper
body: For the use of **`fnmatch`**, I searched in other modules of the collection, and at the moment, none of them use **`fnmatch.filter`** ( even though it has been available since **`Python 2.2`** ).

While searching, I found that, as of now, only the **`ansible.builtin.apt`** module uses **`fnmatch.filter`**.

Using **`fnmatch.fnmatch`** when **`fnmatch.filter`** is available doesn't make much sense, so I’ll try using it.
created_at: 2025-03-09T07:10:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -267,92 +335,64 @@ def main():
         extra_properties=dict(type='list', elements='str', default=[])
     )
 
-    module = AnsibleModule(
-        argument_spec=module_args,
-        supports_check_mode=True
-    )
-
+    module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
     systemctl_bin = module.get_bin_path('systemctl', required=True)
-
     run_command(module, [systemctl_bin, '--version'])
 
-    base_properties = {
+    base_props = {
         'service': ['FragmentPath', 'UnitFileState', 'UnitFilePreset', 'MainPID', 'ExecMainPID'],
         'target': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
         'socket': ['FragmentPath', 'UnitFileState', 'UnitFilePreset'],
         'mount': ['Where', 'What', 'Options', 'Type']
     }
-    state_props = ['LoadState', 'ActiveState', 'SubState']
 
+    state_props = ['LoadState', 'ActiveState', 'SubState']
     results = {}
 
-    if not module.params['unitname']:
-        list_cmd = [
-            systemctl_bin, "list-units",
-            "--no-pager",
-            "--type", "service,target,socket,mount",
-            "--all",
-            "--plain",
-            "--no-legend"
-        ]
-        list_output = run_command(module, list_cmd)
-        for line in list_output.splitlines():
-            tokens = line.split()
-            if len(tokens) < 4:
-                continue
-
-            unit_name = tokens[0]
-            loadstate = tokens[1]
-            activestate = tokens[2]
-            substate = tokens[3]
-
-            fact = {
-                "name": unit_name,
-                "loadstate": loadstate,
-                "activestate": activestate,
-                "substate": substate
-            }
-
-            if loadstate in ("not-found", "masked"):
-                results[unit_name] = fact
-                continue
-
-            category = determine_category(unit_name)
-            if not category:
-                results[unit_name] = fact
-                continue
-
-            props = base_properties.get(category, [])
-            full_props = set(props + state_props)
-            unit_data = get_unit_properties(module, systemctl_bin, unit_name, full_props)
-
-            fact.update(extract_unit_properties(unit_data, full_props))
-            results[unit_name] = fact
-
-    else:
+    list_cmd = [
+        systemctl_bin, "list-units",
+        "--no-pager",
+        "--type", "service,target,socket,mount",
+        "--all",
+        "--plain",
+        "--no-legend"
+    ]
+
+    list_output = run_command(module, list_cmd)
path: plugins/modules/systemd_info.py
user: login: NomakCooper
body: how did I overlook the possibility that the host might have a different locale?

The output of **`systemctl`** commands depends on the locale, so if used in environments with specific languages, the parsing functions might fail.
created_at: 2025-03-09T07:19:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,11 +205,70 @@
   }
 '''
 
+import fnmatch
 from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt
+
+
+def run_version(module, systemctl_bin):
+    runner = CmdRunner(
+        module,
+        command=systemctl_bin,
+        arg_formats=dict(
+            version=cmd_runner_fmt.as_fixed("--version")
+        ),
+        default_args_order="version",
+        environ_update={'LC_ALL': 'en_US.UTF-8', 'LANG': 'en_US.UTF-8'},
+        force_lang="en_US",
+        check_rc=True,
+    )
+    with runner("version") as ctx:
+        rc, stdout, stderr = ctx.run()
+    return stdout.strip()
+
+
+def list_units(module, systemctl_bin):
+    runner = CmdRunner(
+        module,
+        command=systemctl_bin,
+        arg_formats=dict(
+            list_units=cmd_runner_fmt.as_fixed("list-units"),
+            no_pager=cmd_runner_fmt.as_fixed("--no-pager"),
+            typ=cmd_runner_fmt.as_fixed("--type"),
+            types=cmd_runner_fmt.as_fixed("service,target,socket,mount"),
+            all=cmd_runner_fmt.as_fixed("--all"),
+            plain=cmd_runner_fmt.as_fixed("--plain"),
+            no_legend=cmd_runner_fmt.as_fixed("--no-legend"),
+        ),
path: plugins/modules/systemd_info.py
user: login: russoz
body: I would suggest creating on single runner with all the parameters used, and then each function could call with a specific context. Creating a different runner for each function makes for considerable overhead just to run a command.
created_at: 2025-03-09T13:53:17Z
author_association: COLLABORATOR
diff_hunk: @@ -195,11 +205,70 @@
   }
 '''
 
+import fnmatch
 from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt
+
+
+def run_version(module, systemctl_bin):
+    runner = CmdRunner(
+        module,
+        command=systemctl_bin,
+        arg_formats=dict(
+            version=cmd_runner_fmt.as_fixed("--version")
+        ),
+        default_args_order="version",
+        environ_update={'LC_ALL': 'en_US.UTF-8', 'LANG': 'en_US.UTF-8'},
+        force_lang="en_US",
+        check_rc=True,
+    )
+    with runner("version") as ctx:
+        rc, stdout, stderr = ctx.run()
+    return stdout.strip()
+
+
+def list_units(module, systemctl_bin):
+    runner = CmdRunner(
+        module,
+        command=systemctl_bin,
+        arg_formats=dict(
+            list_units=cmd_runner_fmt.as_fixed("list-units"),
+            no_pager=cmd_runner_fmt.as_fixed("--no-pager"),
+            typ=cmd_runner_fmt.as_fixed("--type"),
+            types=cmd_runner_fmt.as_fixed("service,target,socket,mount"),
+            all=cmd_runner_fmt.as_fixed("--all"),
+            plain=cmd_runner_fmt.as_fixed("--plain"),
+            no_legend=cmd_runner_fmt.as_fixed("--no-legend"),
+        ),
+        default_args_order="list_units no_pager typ types all plain no_legend",
+        environ_update={'LC_ALL': 'en_US.UTF-8', 'LANG': 'en_US.UTF-8'},
+        force_lang="en_US",
path: plugins/modules/systemd_info.py
user: login: russoz
body: And:
1. the `force_lang` parameter is going to supersede anything you put in the environment (or the other way around, but anyways, one of them overrides the other)
2. you don't really need to force one thing or the other, CmdRunner has reasonable smart defaults in that regard. I would suggest you not to specify anything, unless we bump into issue.
3. Some people will not have `en_US` locale installed in their computers, so probably not a good idea. OTOH, everyone should have the locale `C` (which boils down to the same thing as `en_US` in many systems)
created_at: 2025-03-09T13:57:23Z
author_association: COLLABORATOR
diff_hunk: @@ -195,11 +205,70 @@
   }
 '''
 
+import fnmatch
 from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt
+
+
+def run_version(module, systemctl_bin):
+    runner = CmdRunner(
+        module,
+        command=systemctl_bin,
+        arg_formats=dict(
+            version=cmd_runner_fmt.as_fixed("--version")
+        ),
+        default_args_order="version",
+        environ_update={'LC_ALL': 'en_US.UTF-8', 'LANG': 'en_US.UTF-8'},
+        force_lang="en_US",
+        check_rc=True,
+    )
+    with runner("version") as ctx:
+        rc, stdout, stderr = ctx.run()
+    return stdout.strip()
+
+
+def list_units(module, systemctl_bin):
+    runner = CmdRunner(
+        module,
+        command=systemctl_bin,
+        arg_formats=dict(
+            list_units=cmd_runner_fmt.as_fixed("list-units"),
+            no_pager=cmd_runner_fmt.as_fixed("--no-pager"),
+            typ=cmd_runner_fmt.as_fixed("--type"),
+            types=cmd_runner_fmt.as_fixed("service,target,socket,mount"),
+            all=cmd_runner_fmt.as_fixed("--all"),
+            plain=cmd_runner_fmt.as_fixed("--plain"),
+            no_legend=cmd_runner_fmt.as_fixed("--no-legend"),
+        ),
path: plugins/modules/systemd_info.py
user: login: NomakCooper
body: I tried looking at the modules you mentioned. 
However, they don’t use **`CmdRunner`** directly but instead use a dedicated **`module_utils`** specifically created for that module.

To apply the same concept here, I would need to create a dedicated **`module_utils`** runner for **`systemd`**, specifically for systemd_info
created_at: 2025-03-09T14:27:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,11 +205,70 @@
   }
 '''
 
+import fnmatch
 from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt
+
+
+def run_version(module, systemctl_bin):
+    runner = CmdRunner(
+        module,
+        command=systemctl_bin,
+        arg_formats=dict(
+            version=cmd_runner_fmt.as_fixed("--version")
+        ),
+        default_args_order="version",
+        environ_update={'LC_ALL': 'en_US.UTF-8', 'LANG': 'en_US.UTF-8'},
+        force_lang="en_US",
+        check_rc=True,
+    )
+    with runner("version") as ctx:
+        rc, stdout, stderr = ctx.run()
+    return stdout.strip()
+
+
+def list_units(module, systemctl_bin):
+    runner = CmdRunner(
+        module,
+        command=systemctl_bin,
+        arg_formats=dict(
+            list_units=cmd_runner_fmt.as_fixed("list-units"),
+            no_pager=cmd_runner_fmt.as_fixed("--no-pager"),
+            typ=cmd_runner_fmt.as_fixed("--type"),
+            types=cmd_runner_fmt.as_fixed("service,target,socket,mount"),
+            all=cmd_runner_fmt.as_fixed("--all"),
+            plain=cmd_runner_fmt.as_fixed("--plain"),
+            no_legend=cmd_runner_fmt.as_fixed("--no-legend"),
+        ),
path: plugins/modules/systemd_info.py
user: login: NomakCooper
body: after looking at the **`pipx`** module, I wrote **`plugins/module_utils/systemd_info.py`** with a dedicated runner that includes the possible **`systemctl`** arguments used by **`systemd_info`**. I then updated the module to use **`systemd_info_runner`**.

like:
```python
def run_version(runner):
    with runner("version") as ctx:
        rc, stdout, stderr = ctx.run()
    return stdout.strip()


def list_units(runner):
    context = "list_units no_pager typ types all plain no_legend"
    with runner(context) as ctx:
        rc, stdout, stderr = ctx.run()
    return stdout.strip()


def show_unit_properties(runner, prop_list, unit):
    props_str = ",".join(prop_list)
    context = "show p props dashdash unit"
    with runner(context) as ctx:
        rc, stdout, stderr = ctx.run(props=props_str, unit=unit)
    return stdout.strip()
```
created_at: 2025-03-09T16:35:33Z
author_association: CONTRIBUTOR
path: plugins/module_utils/systemd_info.py
user: login: russoz
body: If I  may suggest, name this file `systemd.py`, so that any other module related to systemd might benefit from that in the future.
created_at: 2025-03-10T20:01:46Z
author_association: COLLABORATOR
diff_hunk: @@ -195,11 +205,29 @@
   }
 '''
 
+import fnmatch
 from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.community.general.plugins.module_utils.systemd_info import systemd_info_runner
+
+
+def run_version(runner):
path: plugins/modules/systemd_info.py
user: login: russoz
body: minor nit: wouldn't `get_version()` depict a more accurate description of what the function does? I mean, it is plain to see, but as far as naming goes... :-)
created_at: 2025-03-10T20:01:53Z
author_association: COLLABORATOR
path: plugins/module_utils/systemd_info.py
user: login: NomakCooper
body: I was thinking of implementing it just for this module, but if it can be useful to other contributors, then sure.
created_at: 2025-03-10T20:10:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,11 +205,29 @@
   }
 '''
 
+import fnmatch
 from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.community.general.plugins.module_utils.systemd_info import systemd_info_runner
+
+
+def run_version(runner):
path: plugins/modules/systemd_info.py
user: login: NomakCooper
body: Yes, maybe that's better, more intuitive. I got a bit too carried away by cmd(run)ner. 😄
created_at: 2025-03-10T20:10:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025, Marco Noce <nce.marco@gmail.com>
+# 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.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt
+
+
+def systemd_runner(module, command, **kwargs):
+    arg_formats = dict(
+        version=cmd_runner_fmt.as_fixed("--version"),
+        list_units=cmd_runner_fmt.as_fixed("list-units"),
+        no_pager=cmd_runner_fmt.as_fixed("--no-pager"),
path: plugins/module_utils/systemd.py
user: login: russoz
body: These (and others, whenever it makes sense) can be combined as:
```suggestion
        list_units=cmd_runner_fmt.as_fixed(["list-units", "--no-pager"]),
```
created_at: 2025-03-11T19:19:45Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025, Marco Noce <nce.marco@gmail.com>
+# 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.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt
+
+
+def systemd_runner(module, command, **kwargs):
+    arg_formats = dict(
+        version=cmd_runner_fmt.as_fixed("--version"),
+        list_units=cmd_runner_fmt.as_fixed("list-units"),
+        no_pager=cmd_runner_fmt.as_fixed("--no-pager"),
+        typ=cmd_runner_fmt.as_fixed("--type"),
+        types=cmd_runner_fmt.as_func(lambda v: [] if not v else [",".join(v)]),
+        all=cmd_runner_fmt.as_fixed("--all"),
+        plain=cmd_runner_fmt.as_fixed("--plain"),
+        no_legend=cmd_runner_fmt.as_fixed("--no-legend"),
+        show=cmd_runner_fmt.as_fixed("show"),
+        p=cmd_runner_fmt.as_fixed("-p"),
+        props=cmd_runner_fmt.as_list(),
path: plugins/module_utils/systemd.py
user: login: russoz
body: The `p` arg is kinda artificial, it is actually about the `props`. In that case, it might be better off having one format:
```suggestion
        props=cmd_runner_fmt.as_func(lambda v: [] if not v else ["-p", ",".join(v)]),
```
and then ...
created_at: 2025-03-11T19:23:03Z
author_association: COLLABORATOR
diff_hunk: @@ -195,11 +205,29 @@
   }
 '''
 
+import fnmatch
 from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.community.general.plugins.module_utils.systemd import systemd_runner
+
+
+def get_version(runner):
+    with runner("version") as ctx:
+        rc, stdout, stderr = ctx.run()
+    return stdout.strip()
 
 
-def run_command(module, cmd):
-    rc, stdout, stderr = module.run_command(cmd, check_rc=True)
+def list_units(runner, types_value):
+    context = "list_units no_pager typ types all plain no_legend"
+    with runner(context) as ctx:
+        rc, stdout, stderr = ctx.run(types=types_value)
+    return stdout.strip()
+
+
+def show_unit_properties(runner, prop_list, unit):
+    props_str = ",".join(prop_list)
+    context = "show p props dashdash unit"
+    with runner(context) as ctx:
+        rc, stdout, stderr = ctx.run(props=props_str, unit=unit)
path: plugins/modules/systemd_info.py
user: login: russoz
body: ... here it would become:
```suggestion
    context = "show props dashdash unit"
    with runner(context) as ctx:
        rc, stdout, stderr = ctx.run(props=props_list, unit=unit)
```
created_at: 2025-03-11T19:24:00Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025, Marco Noce <nce.marco@gmail.com>
+# 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.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt
+
+
+def systemd_runner(module, command, **kwargs):
+    arg_formats = dict(
+        version=cmd_runner_fmt.as_fixed("--version"),
+        list_units=cmd_runner_fmt.as_fixed(["list-units", "--no-pager"]),
+        typ=cmd_runner_fmt.as_fixed("--type"),
+        types=cmd_runner_fmt.as_func(lambda v: [] if not v else [",".join(v)]),
path: plugins/module_utils/systemd.py
user: login: russoz
body: Wouldn't these two lines be the same case as `p` and `props`? 
created_at: 2025-03-12T09:10:11Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025, Marco Noce <nce.marco@gmail.com>
+# 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.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt
+
+
+def systemd_runner(module, command, **kwargs):
+    arg_formats = dict(
+        version=cmd_runner_fmt.as_fixed("--version"),
+        list_units=cmd_runner_fmt.as_fixed(["list-units", "--no-pager"]),
+        typ=cmd_runner_fmt.as_fixed("--type"),
+        types=cmd_runner_fmt.as_func(lambda v: [] if not v else [",".join(v)]),
path: plugins/module_utils/systemd.py
user: login: NomakCooper
body: They could become a single arg.

like:
```python
types=cmd_runner_fmt.as_func(lambda v: [] if not v else ["--type", ",".join(v)]),
```
created_at: 2025-03-12T14:23:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: NomakCooper
created_at: 2025-03-02T23:28:36Z
body: I have added commits with some changes.

- Using wildcard expressions in a task increases its execution time, so I had to make changes to the code to make it as fast as possible.

- I made changes for better management to make future maintenance of the code easier.

- The changes made do not change the usage or the result of the module.
author_association: CONTRIBUTOR
user: login: NomakCooper
created_at: 2025-03-09T09:49:14Z
body: @russoz thank you so much for the comments and suggestions.

I've made some changes:

- Removed redundancies in **`base_props`** and introduced the new **`get_category_base_props()`**
- Removed **`.keys()`** from the **`units_info`** and **`resolved_units`** lists.
- Replaced the more common **`fnmatch.fnmatch`** with **`fnmatch.filter`**.
- Replaced **`run_command`** with **`CmdRunner`** and set locale to **`en_US`**.
- Added a new line in the **`unitname`** description specifying that wildcard expressions are supported from **`community.general 10.5.0`**.

By introducing these changes, I aimed to make the code more readable and easier to maintain.

Replacing **`run_command`** with **`CmdRunner`** gave me some issues because the module uses three different **`systemctl`** syntaxes. 
So, I decided to create three separate functions that use **`CmdRunner`**. This way, however, the **`main()`** function is cleaner.
author_association: CONTRIBUTOR
user: login: NomakCooper
created_at: 2025-03-09T11:13:08Z
body: @russoz 

In the open issues, I found #4896, which was opened as a **Feature Idea** in 2022.

What is described in the issue is currently handled by **`systemd_info`**, except for **`timers`**.

I can use this PR to also extend support for the timer category with the **`base_props`** **`FragmentPath`**, **`UnitFileState`**, and **`UnitFilePreset`**
author_association: CONTRIBUTOR
user: login: ansibullbot
created_at: 2025-03-09T17:06:09Z
body: The test `ansible-test sanity --test pep8` [[explain](https://docs.ansible.com/ansible-core/devel/dev_guide/testing/sanity/pep8.html)] failed with 11 errors:
```
plugins/module_utils/systemd_info.py:14:15: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:16:16: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:17:11: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:18:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:19:11: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:20:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:22:12: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:23:9: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:24:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:25:16: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:26:12: E241: multiple spaces after ':'
```
<!-- job_id: 139584 -->
<!-- job_id: bf32dd15bdc13ef566a5fb926d786106 -->
<!-- job_id: None -->
The test `ansible-test sanity --test pep8` [[explain](https://docs.ansible.com/ansible-core/devel/dev_guide/testing/sanity/pep8.html)] failed with 11 errors:
```
plugins/module_utils/systemd_info.py:14:15: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:16:16: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:17:11: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:18:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:19:11: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:20:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:22:12: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:23:9: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:24:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:25:16: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:26:12: E241: multiple spaces after ':'
```
<!-- job_id: 139584 -->
<!-- job_id: bf32dd15bdc13ef566a5fb926d786106 -->
<!-- job_id: None -->
The test `ansible-test sanity --test pep8` [[explain](https://docs.ansible.com/ansible-core/devel/dev_guide/testing/sanity/pep8.html)] failed with 11 errors:
```
plugins/module_utils/systemd_info.py:14:15: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:16:16: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:17:11: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:18:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:19:11: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:20:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:22:12: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:23:9: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:24:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:25:16: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:26:12: E241: multiple spaces after ':'
```
<!-- job_id: 139584 -->
<!-- job_id: bf32dd15bdc13ef566a5fb926d786106 -->
<!-- job_id: None -->
The test `ansible-test sanity --test pep8` [[explain](https://docs.ansible.com/ansible-core/devel/dev_guide/testing/sanity/pep8.html)] failed with 11 errors:
```
plugins/module_utils/systemd_info.py:14:15: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:16:16: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:17:11: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:18:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:19:11: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:20:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:22:12: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:23:9: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:24:13: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:25:16: E241: multiple spaces after ':'
plugins/module_utils/systemd_info.py:26:12: E241: multiple spaces after ':'
```
<!-- job_id: 139584 -->
<!-- job_id: bf32dd15bdc13ef566a5fb926d786106 -->
<!-- job_id: None -->

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: shippable_test_result --->
author_association: COLLABORATOR
user: login: NomakCooper
created_at: 2025-03-09T17:26:26Z
body: @russoz as you suggested, I changed the **`CmdRunner`**.

I introduced a new **`plugins/module_utils/systemd_info.py`** with a dedicated runner that includes all the args used by **`systemd_info`**.

In the module, functions now call the runner, passing arguments like units, props, types, etc.

For the unit **`types`**, I used **`cmd_runner_fmt.as_func`**. 
This way, the supported categories can be managed directly in the module without needing to modify the runner.

I also removed **`environ_update`** and **`force_lang`**
author_association: CONTRIBUTOR
user: login: NomakCooper
created_at: 2025-03-10T20:44:35Z
body: @russoz done

- The runner is no longer specifically tied to **`systemd_info`**; I’ve renamed both the file and the runner.
- Changed **`run_version()`** to **`get_version()`**.

For the issue https://github.com/ansible-collections/community.general/issues/4896 I previously linked in the comments, what do we want to do?

I'm not sure if it's possible to use this PR to also add support for **`timer`** units.
Also, I’m not sure if a review from Felix is needed 🙂

Obviously, thanks as always!
author_association: CONTRIBUTOR
user: login: russoz
created_at: 2025-03-10T21:32:04Z
body: Hi, is there anything different about thw timers, in terms of the general logic of the module? Does it require creating additional logic, or is it just creating the new category and parameters? 
author_association: COLLABORATOR
user: login: NomakCooper
created_at: 2025-03-10T21:46:23Z
body: Timer units have various properties, but many of them are variable since they depend on how the timers are configured.

However, every timer unit shares the basic properties already present in the module for **`target`** and **`socket`**.

So, it would be enough to add a new entry in **`base_props`**, adjust **`unit_types`** list, **`determine_category()`**, and, of course, update the **`documentation`** and **`changelogs/fragments`**.

If desired, one or two additional tasks could be added to the **`integration`** test to verify **`timer`** units.
author_association: CONTRIBUTOR
user: login: NomakCooper
created_at: 2025-03-11T07:06:07Z
body: Additionally, there's another thing we forgot. 
Since we added the new **`module_utils/systemd.py`**, I think it's necessary to update **`.github/BOTMETA.yml`** as well.
author_association: CONTRIBUTOR
user: login: russoz
created_at: 2025-03-11T19:10:23Z
body: > Timer units have various properties, but many of them are variable since they depend on how the timers are configured.
> 
> However, every timer unit shares the basic properties already present in the module for **`target`** and **`socket`**.
> 
> So, it would be enough to add a new entry in **`base_props`**, adjust **`unit_types`** list, **`determine_category()`**, and, of course, update the **`documentation`** and **`changelogs/fragments`**.
> 
> If desired, one or two additional tasks could be added to the **`integration`** test to verify **`timer`** units.

I am happy with the PR as-is (well, I am about to make another suggestion on the arg_formats, but minor stuff), so it is kind of your call to make: would you prefer to get this one merged first and then raise a second PR, or would you rather put that extra feature in this PR as well?
author_association: COLLABORATOR
user: login: NomakCooper
created_at: 2025-03-11T19:14:41Z
body: In this PR, many changes have already been made, so I prefer to keep this PR focused on wildcard expression support and extend timer unit support in a new PR.
author_association: CONTRIBUTOR
user: login: NomakCooper
created_at: 2025-03-11T19:35:35Z
body: > Hi there, as mentioned, a couple of suggestions on the arg formats, but even without these adjustments, LGTM

OK It makes sense., this way some args are combined since they are related to each other.
I'll try making these changes and running the tests.

While I'm at it, I'll also update the **`changelogs/fragments`** with a clearer and more precise note.

Do I also need to add **`module_utils/systemd`** to **`.github/BOTMETA.yml`**?
author_association: CONTRIBUTOR
user: login: russoz
created_at: 2025-03-11T19:47:27Z
body: > OK It makes sense., this way some args are combined since they are related to each other. I'll try making these changes and running the tests.
> 
> While I'm at it, I'll also update the **`changelogs/fragments`** with a clearer and more precise note.

Awesome, thanks! :-)

> Do I also need to add **`module_utils/systemd`** to **`.github/BOTMETA.yml`**?

Yes. That file basically drives the bot, so whenever some issue or PR mention that file, the bot will mention your GH user, so that you get notified.
author_association: COLLABORATOR
user: login: NomakCooper
created_at: 2025-03-11T20:09:27Z
body: @russoz updated, the module works well, and the integration test ran without any issues.

- Combined the **`arg_formats`** and updated **`list_units()`** and **`show_unit_properties()`**.
- Improved the note in **`changelogs/fragments`**.
- Added a new entry in **`BOTMETA.yml`** for **`module_utils/systemd.py`**.
author_association: CONTRIBUTOR
user: login: NomakCooper
created_at: 2025-03-12T14:39:06Z
body: @russoz I merged the **`typ`** into **`types`** arg and updated **`list_units()`**.
author_association: CONTRIBUTOR
user: login: patchback[bot]
created_at: 2025-03-15T06:36:20Z
body: 
### Backport to stable-10: 💚 backport PR created

✅ Backport PR branch: `patchback/backports/stable-10/3bd0ab4a49a10473f8a3758d4670c4b543f0eb2e/pr-9821`

Backported as https://github.com/ansible-collections/community.general/pull/9883



<sub><sup>
--
🤖 @patchback
I'm built with [octomachinery](https://octomachinery.dev) and
my source is open — https://github.com/sanitizers/patchback-github-app.
</sup></sub>


author_association: NONE
user: login: felixfontein
created_at: 2025-03-15T06:36:30Z
body: @NomakCooper thanks for your contribution!
@russoz thanks for reviewing!
author_association: COLLABORATOR