Repository: ansible-collections/community.general

Issue:
pipx: Add `latest` option to the `state` parameter to keep packages up to date
### Summary

Currently the [community.general.pipx module](https://docs.ansible.com/ansible/devel/collections/community/general/pipx_module.html) does not support a `latest` option for the `state` parameter.

Such an option would be a good addition to the module to make it easier to keep packages up to date.

The supported `upgrade` option is similar, but differs from `latest` by erroring out when the given package is not installed at all.

A workaround exists: First use the pipx module with the `present` state option, then another task with the `upgrade` state option.

### Issue Type

Feature Idea

### Component Name

pipx

### Additional Information

This should work just like the apt module works:

```yaml
- name: Install with Pipx
  community.general.pipx:
    name: pre-commit
    state: latest
```

Current workaround:

```yaml
- name: Install with Pipx
  community.general.pipx:
    name: pre-commit
    state: present

- name: Install with Pipx
  community.general.pipx:
    name: pre-commit
    state: upgrade
```

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


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

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

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @russoz
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/plugins/modules/packaging/language/pipx.py b/plugins/modules/packaging/language/pipx.py
--- a/plugins/modules/packaging/language/pipx.py
+++ b/plugins/modules/packaging/language/pipx.py
@@ -19,11 +19,13 @@
 options:
     state:
         type: str
-        choices: [present, absent, install, uninstall, uninstall_all, inject, upgrade, upgrade_all, reinstall, reinstall_all]
+        choices: [present, absent, install, uninstall, uninstall_all, inject, upgrade, upgrade_all, reinstall, reinstall_all, latest]
         default: install
         description:
             - Desired state for the application.
             - The states C(present) and C(absent) are aliases to C(install) and C(uninstall), respectively.
+            - The state C(latest) is equivalent to executing the task twice, with state C(install) and then C(upgrade).
+              It was added in community.general 5.5.0.
     name:
         type: str
         description:
@@ -146,7 +148,7 @@ class PipX(StateModuleHelper):
         argument_spec=dict(
             state=dict(type='str', default='install',
                        choices=['present', 'absent', 'install', 'uninstall', 'uninstall_all',
-                                'inject', 'upgrade', 'upgrade_all', 'reinstall', 'reinstall_all']),
+                                'inject', 'upgrade', 'upgrade_all', 'reinstall', 'reinstall_all', 'latest']),
             name=dict(type='str'),
             source=dict(type='str'),
             install_deps=dict(type='bool', default=False),
@@ -166,6 +168,7 @@ class PipX(StateModuleHelper):
             ('state', 'uninstall', ['name']),
             ('state', 'upgrade', ['name']),
             ('state', 'reinstall', ['name']),
+            ('state', 'latest', ['name']),
             ('state', 'inject', ['name', 'inject_packages']),
         ],
         supports_check_mode=True,
@@ -279,6 +282,17 @@ def state_upgrade_all(self):
             ctx.run()
             self._capture_results(ctx)
 
+    def state_latest(self):
+        if not self.vars.application or self.vars.force:
+            self.changed = True
+            with self.runner('state index_url install_deps force python editable pip_args name_source', check_mode_skip=True) as ctx:
+                ctx.run(state='install', name_source=[self.vars.name, self.vars.source])
+                self._capture_results(ctx)
+
+        with self.runner('state index_url install_deps force editable pip_args name', check_mode_skip=True) as ctx:
+            ctx.run(state='upgrade')
+            self._capture_results(ctx)
+
 
 def main():
     PipX.execute()


Test patch:
diff --git a/tests/integration/targets/pipx/tasks/main.yml b/tests/integration/targets/pipx/tasks/main.yml
--- a/tests/integration/targets/pipx/tasks/main.yml
+++ b/tests/integration/targets/pipx/tasks/main.yml
@@ -20,6 +20,10 @@
     name: tox
   register: install_tox
 
+- name: set fact latest_tox_version
+  set_fact:
+    latest_tox_version: "{{ install_tox.application.tox.version }}"
+
 - name: install application tox again
   community.general.pipx:
     name: tox
@@ -40,12 +44,12 @@
 - name: check assertions tox
   assert:
     that:
-        - install_tox is changed
-        - "'tox' in install_tox.application"
-        - install_tox_again is not changed
-        - install_tox_again_force is changed
-        - uninstall_tox is changed
-        - "'tox' not in uninstall_tox.application"
+      - install_tox is changed
+      - "'tox' in install_tox.application"
+      - install_tox_again is not changed
+      - install_tox_again_force is changed
+      - uninstall_tox is changed
+      - "'tox' not in uninstall_tox.application"
 
 ##############################################################################
 - name: install application tox 3.24.0
@@ -66,7 +70,7 @@
   register: reinstall_noname
   ignore_errors: yes
 
-- name: upgrade tox 3.24.0
+- name: upgrade tox from 3.24.0
   community.general.pipx:
     name: tox
     state: upgrade
@@ -94,19 +98,78 @@
 - name: check assertions tox 3.24.0
   assert:
     that:
-        - install_tox_324 is changed
-        - "'tox' in install_tox_324.application"
-        - install_tox_324.application.tox.version == '3.24.0'
-        - reinstall_tox_324 is changed
-        - reinstall_tox_324.application.tox.version == '3.24.0'
-        - upgrade_tox_324 is changed
-        - upgrade_tox_324.application.tox.version != '3.24.0'
-        - downgrade_tox_324 is changed
-        - downgrade_tox_324.application.tox.version == '3.24.0'
-        - uninstall_tox_324 is changed
-        - "'tox' not in uninstall_tox_324.application"
-        - upgrade_noname is failed
-        - reinstall_noname is failed
+      - install_tox_324 is changed
+      - "'tox' in install_tox_324.application"
+      - install_tox_324.application.tox.version == '3.24.0'
+      - reinstall_tox_324 is changed
+      - reinstall_tox_324.application.tox.version == '3.24.0'
+      - upgrade_tox_324 is changed
+      - upgrade_tox_324.application.tox.version != '3.24.0'
+      - downgrade_tox_324 is changed
+      - downgrade_tox_324.application.tox.version == '3.24.0'
+      - uninstall_tox_324 is changed
+      - "'tox' not in uninstall_tox_324.application"
+      - upgrade_noname is failed
+      - reinstall_noname is failed
+
+##############################################################################
+- name: install application latest tox
+  community.general.pipx:
+    name: tox
+    state: latest
+  register: install_tox_latest
+
+- name: cleanup tox latest
+  community.general.pipx:
+    state: absent
+    name: tox
+  register: uninstall_tox_latest
+
+- name: install application tox 3.24.0 for latest
+  community.general.pipx:
+    name: tox
+    source: tox==3.24.0
+  register: install_tox_324_for_latest
+
+- name: install application latest tox
+  community.general.pipx:
+    name: tox
+    state: latest
+  register: install_tox_latest_with_preinstall
+
+- name: install application latest tox again
+  community.general.pipx:
+    name: tox
+    state: latest
+  register: install_tox_latest_with_preinstall_again
+
+- name: install application latest tox
+  community.general.pipx:
+    name: tox
+    state: latest
+    force: true
+  register: install_tox_latest_with_preinstall_again_force
+
+- name: cleanup tox latest again
+  community.general.pipx:
+    state: absent
+    name: tox
+  register: uninstall_tox_latest_again
+
+- name: check assertions tox latest
+  assert:
+    that:
+      - install_tox_latest is changed
+      - uninstall_tox_latest is changed
+      - install_tox_324_for_latest is changed
+      - install_tox_324_for_latest.application.tox.version == '3.24.0'
+      - install_tox_latest_with_preinstall is changed
+      - install_tox_latest_with_preinstall.application.tox.version == latest_tox_version
+      - install_tox_latest_with_preinstall_again is not changed
+      - install_tox_latest_with_preinstall_again.application.tox.version == latest_tox_version
+      - install_tox_latest_with_preinstall_again_force is changed
+      - install_tox_latest_with_preinstall_again_force.application.tox.version == latest_tox_version
+      - uninstall_tox_latest_again is changed
 
 ##############################################################################
 - name: ensure application ansible-lint is uninstalled
@@ -136,34 +199,34 @@
 - name: check assertions inject_packages
   assert:
     that:
-        - install_ansible_lint is changed
-        - inject_pkgs_ansible_lint is changed
-        - '"ansible-lint" in inject_pkgs_ansible_lint.application'
-        - '"licenses" in inject_pkgs_ansible_lint.application["ansible-lint"]["injected"]'
-        - uninstall_ansible_lint is changed
+      - install_ansible_lint is changed
+      - inject_pkgs_ansible_lint is changed
+      - '"ansible-lint" in inject_pkgs_ansible_lint.application'
+      - '"licenses" in inject_pkgs_ansible_lint.application["ansible-lint"]["injected"]'
+      - uninstall_ansible_lint is changed
 
 ##############################################################################
 - name: install jupyter - not working smoothly in freebsd
-  block:
-  - name: ensure application jupyter is uninstalled
-    community.general.pipx:
-      name: jupyter
-      state: absent
-
-  - name: install application jupyter
-    community.general.pipx:
-      name: jupyter
-      install_deps: true
-    register: install_jupyter
-
-  - name: cleanup jupyter
-    community.general.pipx:
-      state: absent
-      name: jupyter
-
-  - name: check assertions
-    assert:
-      that:
-        - install_jupyter is changed
-        - '"ipython" in install_jupyter.stdout'
   when: ansible_system != 'FreeBSD'
+  block:
+    - name: ensure application jupyter is uninstalled
+      community.general.pipx:
+        name: jupyter
+        state: absent
+
+    - name: install application jupyter
+      community.general.pipx:
+        name: jupyter
+        install_deps: true
+      register: install_jupyter
+
+    - name: cleanup jupyter
+      community.general.pipx:
+        state: absent
+        name: jupyter
+
+    - name: check assertions
+      assert:
+        that:
+          - install_jupyter is changed
+          - '"ipython" in install_jupyter.stdout'