From 43f8adf1a5c7184310bc6ef7deee6fd8ebb5d9c1 Mon Sep 17 00:00:00 2001 From: Alexei Znamensky <103110+russoz@users.noreply.github.com> Date: Sat, 7 Sep 2024 09:49:16 +1200 Subject: [PATCH] pipx: add new states (#8809) * ensure minimum version of pip * ensure pipx 1.7.0 is installed * pipx: add new states/params * add tests * add license to json file * Update plugins/modules/pipx.py Co-authored-by: Felix Fontein * fix uninject tests * add changelog frag * fix doc per review * refactor license out of pipx spec file * Update plugins/modules/pipx.py Co-authored-by: Felix Fontein * Update tests/integration/targets/pipx/files/spec.json.license Co-authored-by: Felix Fontein --------- Co-authored-by: Felix Fontein --- changelogs/fragments/8809-pipx-new-params.yml | 2 + plugins/module_utils/pipx.py | 5 + plugins/modules/pipx.py | 69 +++++++++++++- .../integration/targets/pipx/files/spec.json | 91 +++++++++++++++++++ .../targets/pipx/files/spec.json.license | 3 + tests/integration/targets/pipx/tasks/main.yml | 9 ++ .../pipx/tasks/testcase-8809-installall.yml | 59 ++++++++++++ .../targets/pipx/tasks/testcase-8809-pin.yml | 69 ++++++++++++++ .../pipx/tasks/testcase-8809-uninjectpkg.yml | 69 ++++++++++++++ .../targets/pipx/tasks/testcase-injectpkg.yml | 12 +-- 10 files changed, 377 insertions(+), 11 deletions(-) create mode 100644 changelogs/fragments/8809-pipx-new-params.yml create mode 100644 tests/integration/targets/pipx/files/spec.json create mode 100644 tests/integration/targets/pipx/files/spec.json.license create mode 100644 tests/integration/targets/pipx/tasks/testcase-8809-installall.yml create mode 100644 tests/integration/targets/pipx/tasks/testcase-8809-pin.yml create mode 100644 tests/integration/targets/pipx/tasks/testcase-8809-uninjectpkg.yml diff --git a/changelogs/fragments/8809-pipx-new-params.yml b/changelogs/fragments/8809-pipx-new-params.yml new file mode 100644 index 0000000000..775163e987 --- /dev/null +++ b/changelogs/fragments/8809-pipx-new-params.yml @@ -0,0 +1,2 @@ +minor_changes: + - pipx - added new states ``install_all``, ``uninject``, ``upgrade_shared``, ``pin``, and ``unpin`` (https://github.com/ansible-collections/community.general/pull/8809). diff --git a/plugins/module_utils/pipx.py b/plugins/module_utils/pipx.py index 054de886a4..9ae7b5381c 100644 --- a/plugins/module_utils/pipx.py +++ b/plugins/module_utils/pipx.py @@ -11,15 +11,20 @@ from ansible_collections.community.general.plugins.module_utils.cmd_runner impor _state_map = dict( install='install', + install_all='install-all', present='install', uninstall='uninstall', absent='uninstall', uninstall_all='uninstall-all', inject='inject', + uninject='uninject', upgrade='upgrade', + upgrade_shared='upgrade-shared', upgrade_all='upgrade-all', reinstall='reinstall', reinstall_all='reinstall-all', + pin='pin', + unpin='unpin', ) diff --git a/plugins/modules/pipx.py b/plugins/modules/pipx.py index 1a73ae00bd..38efc56ffc 100644 --- a/plugins/modules/pipx.py +++ b/plugins/modules/pipx.py @@ -26,13 +26,31 @@ attributes: options: state: type: str - choices: [present, absent, install, uninstall, uninstall_all, inject, upgrade, upgrade_all, reinstall, reinstall_all, latest] + choices: + - present + - absent + - install + - install_all + - uninstall + - uninstall_all + - inject + - uninject + - upgrade + - upgrade_shared + - upgrade_all + - reinstall + - reinstall_all + - latest + - pin + - unpin default: install description: - Desired state for the application. - The states V(present) and V(absent) are aliases to V(install) and V(uninstall), respectively. - The state V(latest) is equivalent to executing the task twice, with state V(install) and then V(upgrade). It was added in community.general 5.5.0. + - The states V(install_all), V(uninject), V(upgrade_shared), V(pin) and V(unpin) are only available in C(pipx>=1.6.0), + make sure to have a compatible version when using this option. These states have been added in community.general 9.4.0. name: type: str description: @@ -128,6 +146,13 @@ options: type: bool default: false version_added: 9.4.0 + spec_metadata: + description: + - Spec metadata file for O(state=install_all). + - This content of the file is usually generated with C(pipx list --json), and it can be obtained with M(community.general.pipx_info) + with O(community.general.pipx_info#module:include_raw=true) and obtaining the content from the RV(community.general.pipx_info#module:raw_output). + type: path + version_added: 9.4.0 notes: - This module requires C(pipx) version 0.16.2.1 or above. From community.general 11.0.0 onwards, the module will require C(pipx>=1.7.0). - Please note that C(pipx) requires Python 3.6 or above. @@ -201,8 +226,10 @@ class PipX(StateModuleHelper): output_params = ['name', 'source', 'index_url', 'force', 'installdeps'] argument_spec = dict( state=dict(type='str', default='install', - choices=['present', 'absent', 'install', 'uninstall', 'uninstall_all', - 'inject', 'upgrade', 'upgrade_all', 'reinstall', 'reinstall_all', 'latest']), + choices=[ + 'present', 'absent', 'install', 'install_all', 'uninstall', 'uninstall_all', 'inject', 'uninject', + 'upgrade', 'upgrade_shared', 'upgrade_all', 'reinstall', 'reinstall_all', 'latest', 'pin', 'unpin', + ]), name=dict(type='str'), source=dict(type='str'), install_apps=dict(type='bool', default=False), @@ -217,6 +244,7 @@ class PipX(StateModuleHelper): editable=dict(type='bool', default=False), pip_args=dict(type='str'), suffix=dict(type='str'), + spec_metadata=dict(type='path'), ) argument_spec["global"] = dict(type='bool', default=False) @@ -225,12 +253,15 @@ class PipX(StateModuleHelper): required_if=[ ('state', 'present', ['name']), ('state', 'install', ['name']), + ('state', 'install_all', ['spec_metadata']), ('state', 'absent', ['name']), ('state', 'uninstall', ['name']), ('state', 'upgrade', ['name']), ('state', 'reinstall', ['name']), ('state', 'latest', ['name']), ('state', 'inject', ['name', 'inject_packages']), + ('state', 'pin', ['name']), + ('state', 'unpin', ['name']), ], required_by=dict( suffix="name", @@ -284,8 +315,7 @@ class PipX(StateModuleHelper): self.vars.stdout = ctx.results_out self.vars.stderr = ctx.results_err self.vars.cmd = ctx.cmd - if self.verbosity >= 4: - self.vars.run_info = ctx.run_info + self.vars.set('run_info', ctx.run_info, verbosity=4) def state_install(self): if not self.vars.application or self.vars.force: @@ -297,6 +327,12 @@ class PipX(StateModuleHelper): state_present = state_install + def state_install_all(self): + self.changed = True + with self.runner('state global index_url force python system_site_packages editable pip_args spec_metadata', check_mode_skip=True) as ctx: + ctx.run(name_source=[self.vars.name, self.vars.source]) + self._capture_results(ctx) + def state_upgrade(self): name = _make_name(self.vars.name, self.vars.suffix) if not self.vars.application: @@ -336,6 +372,14 @@ class PipX(StateModuleHelper): ctx.run(name=name) self._capture_results(ctx) + def state_uninject(self): + name = _make_name(self.vars.name, self.vars.suffix) + if not self.vars.application: + self.do_raise("Trying to uninject packages into a non-existent application: {0}".format(name)) + with self.runner('state global name inject_packages', check_mode_skip=True) as ctx: + ctx.run(name=name) + self._capture_results(ctx) + def state_uninstall_all(self): with self.runner('state global', check_mode_skip=True) as ctx: ctx.run() @@ -353,6 +397,11 @@ class PipX(StateModuleHelper): ctx.run() self._capture_results(ctx) + def state_upgrade_shared(self): + with self.runner('state global pip_args', check_mode_skip=True) as ctx: + ctx.run() + self._capture_results(ctx) + def state_latest(self): if not self.vars.application or self.vars.force: self.changed = True @@ -365,6 +414,16 @@ class PipX(StateModuleHelper): ctx.run(state='upgrade') self._capture_results(ctx) + def state_pin(self): + with self.runner('state global name', check_mode_skip=True) as ctx: + ctx.run() + self._capture_results(ctx) + + def state_unpin(self): + with self.runner('state global name', check_mode_skip=True) as ctx: + ctx.run() + self._capture_results(ctx) + def main(): PipX.execute() diff --git a/tests/integration/targets/pipx/files/spec.json b/tests/integration/targets/pipx/files/spec.json new file mode 100644 index 0000000000..3c85125337 --- /dev/null +++ b/tests/integration/targets/pipx/files/spec.json @@ -0,0 +1,91 @@ +{ + "pipx_spec_version": "0.1", + "venvs": { + "black": { + "metadata": { + "injected_packages": {}, + "main_package": { + "app_paths": [ + { + "__Path__": "/home/az/.local/pipx/venvs/black/bin/black", + "__type__": "Path" + }, + { + "__Path__": "/home/az/.local/pipx/venvs/black/bin/blackd", + "__type__": "Path" + } + ], + "app_paths_of_dependencies": {}, + "apps": [ + "black", + "blackd" + ], + "apps_of_dependencies": [], + "include_apps": true, + "include_dependencies": false, + "man_pages": [], + "man_pages_of_dependencies": [], + "man_paths": [], + "man_paths_of_dependencies": {}, + "package": "black", + "package_or_url": "black", + "package_version": "24.8.0", + "pinned": false, + "pip_args": [], + "suffix": "" + }, + "pipx_metadata_version": "0.5", + "python_version": "Python 3.11.9", + "source_interpreter": { + "__Path__": "/home/az/.pyenv/versions/3.11.9/bin/python3.11", + "__type__": "Path" + }, + "venv_args": [] + } + }, + "pycowsay": { + "metadata": { + "injected_packages": {}, + "main_package": { + "app_paths": [ + { + "__Path__": "/home/az/.local/pipx/venvs/pycowsay/bin/pycowsay", + "__type__": "Path" + } + ], + "app_paths_of_dependencies": {}, + "apps": [ + "pycowsay" + ], + "apps_of_dependencies": [], + "include_apps": true, + "include_dependencies": false, + "man_pages": [ + "man6/pycowsay.6" + ], + "man_pages_of_dependencies": [], + "man_paths": [ + { + "__Path__": "/home/az/.local/pipx/venvs/pycowsay/share/man/man6/pycowsay.6", + "__type__": "Path" + } + ], + "man_paths_of_dependencies": {}, + "package": "pycowsay", + "package_or_url": "pycowsay", + "package_version": "0.0.0.2", + "pinned": false, + "pip_args": [], + "suffix": "" + }, + "pipx_metadata_version": "0.5", + "python_version": "Python 3.11.9", + "source_interpreter": { + "__Path__": "/home/az/.pyenv/versions/3.11.9/bin/python3.11", + "__type__": "Path" + }, + "venv_args": [] + } + }, + } +} diff --git a/tests/integration/targets/pipx/files/spec.json.license b/tests/integration/targets/pipx/files/spec.json.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/tests/integration/targets/pipx/files/spec.json.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/integration/targets/pipx/tasks/main.yml b/tests/integration/targets/pipx/tasks/main.yml index f1a993aa56..30e96ef1bf 100644 --- a/tests/integration/targets/pipx/tasks/main.yml +++ b/tests/integration/targets/pipx/tasks/main.yml @@ -247,3 +247,12 @@ block: - name: Include testcase for PR 8793 --global ansible.builtin.include_tasks: testcase-8793-global.yml + + - name: Include testcase for PR 8809 install-all + ansible.builtin.include_tasks: testcase-8809-install-all.yml + + - name: Include testcase for PR 8809 pin + ansible.builtin.include_tasks: testcase-8809-pin.yml + + - name: Include testcase for PR 8809 injectpkg + ansible.builtin.include_tasks: testcase-8809-uninjectpkg.yml diff --git a/tests/integration/targets/pipx/tasks/testcase-8809-installall.yml b/tests/integration/targets/pipx/tasks/testcase-8809-installall.yml new file mode 100644 index 0000000000..37816247c0 --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-8809-installall.yml @@ -0,0 +1,59 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Set up environment + environment: + PATH: /usr/local/bin:{{ ansible_env.PATH }} + block: + - name: Uninstall pycowsay and black + community.general.pipx: + state: uninstall + name: "{{ item }}" + loop: + - black + - pycowsay + + - name: Uninstall pycowsay and black (again) + community.general.pipx: + state: uninstall + name: "{{ item }}" + loop: + - black + - pycowsay + register: uninstall_all_1 + + - name: Use install-all + community.general.pipx: + state: install-all + spec_metadata: spec.json + register: install_all + + - name: Run pycowsay (should succeed) + ansible.builtin.command: pycowsay Moooooooo! + changed_when: false + register: what_the_cow_said + + - name: Which cow? + ansible.builtin.command: which pycowsay + changed_when: false + register: which_cow + + - name: Uninstall pycowsay and black (again) + community.general.pipx: + state: uninstall + name: "{{ item }}" + loop: + - black + - pycowsay + register: uninstall_all_2 + + - name: Assert uninstall-all + ansible.builtin.assert: + that: + - uninstall_all_1 is not changed + - install_all is changed + - "'Moooooooo!' in what_the_cow_said.stdout" + - "'/usr/local/bin/pycowsay' in which_cow.stdout" + - uninstall_all_2 is changed diff --git a/tests/integration/targets/pipx/tasks/testcase-8809-pin.yml b/tests/integration/targets/pipx/tasks/testcase-8809-pin.yml new file mode 100644 index 0000000000..89e4bb9dc6 --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-8809-pin.yml @@ -0,0 +1,69 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Set up environment + environment: + PATH: /usr/local/bin:{{ ansible_env.PATH }} + block: + - name: Uninstall pycowsay and black + community.general.pipx: + state: uninstall + name: pycowsay + + # latest is 0.0.0.2 + - name: Install pycowsay 0.0.0.1 + community.general.pipx: + state: install + name: pycowsay + source: pycowsay==0.0.0.1 + + - name: Pin cowsay + community.general.pipx: + state: pin + name: pycowsay + register: pin_cow + + - name: Upgrade pycowsay + community.general.pipx: + state: upgrade + name: pycowsay + + - name: Get pycowsay version + community.general.pipx_info: + name: pycowsay + register: cow_info_1 + + - name: Unpin cowsay + community.general.pipx: + state: unpin + name: pycowsay + register: unpin_cow + + - name: Upgrade pycowsay + community.general.pipx: + state: upgrade + name: pycowsay + + - name: Get pycowsay version + community.general.pipx_info: + name: pycowsay + register: cow_info_2 + + - name: Uninstall pycowsay and black (again) + community.general.pipx: + state: uninstall + name: "{{ item }}" + loop: + - black + - pycowsay + register: uninstall_all_2 + + - name: Assert uninstall-all + ansible.builtin.assert: + that: + - pin_cow is changed + - cow_info_1 == "0.0.0.1" + - unpin_cow is changed + - cow_info_2 != "0.0.0.1" diff --git a/tests/integration/targets/pipx/tasks/testcase-8809-uninjectpkg.yml b/tests/integration/targets/pipx/tasks/testcase-8809-uninjectpkg.yml new file mode 100644 index 0000000000..89e4bb9dc6 --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-8809-uninjectpkg.yml @@ -0,0 +1,69 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Set up environment + environment: + PATH: /usr/local/bin:{{ ansible_env.PATH }} + block: + - name: Uninstall pycowsay and black + community.general.pipx: + state: uninstall + name: pycowsay + + # latest is 0.0.0.2 + - name: Install pycowsay 0.0.0.1 + community.general.pipx: + state: install + name: pycowsay + source: pycowsay==0.0.0.1 + + - name: Pin cowsay + community.general.pipx: + state: pin + name: pycowsay + register: pin_cow + + - name: Upgrade pycowsay + community.general.pipx: + state: upgrade + name: pycowsay + + - name: Get pycowsay version + community.general.pipx_info: + name: pycowsay + register: cow_info_1 + + - name: Unpin cowsay + community.general.pipx: + state: unpin + name: pycowsay + register: unpin_cow + + - name: Upgrade pycowsay + community.general.pipx: + state: upgrade + name: pycowsay + + - name: Get pycowsay version + community.general.pipx_info: + name: pycowsay + register: cow_info_2 + + - name: Uninstall pycowsay and black (again) + community.general.pipx: + state: uninstall + name: "{{ item }}" + loop: + - black + - pycowsay + register: uninstall_all_2 + + - name: Assert uninstall-all + ansible.builtin.assert: + that: + - pin_cow is changed + - cow_info_1 == "0.0.0.1" + - unpin_cow is changed + - cow_info_2 != "0.0.0.1" diff --git a/tests/integration/targets/pipx/tasks/testcase-injectpkg.yml b/tests/integration/targets/pipx/tasks/testcase-injectpkg.yml index 60296024e4..63d33ba92c 100644 --- a/tests/integration/targets/pipx/tasks/testcase-injectpkg.yml +++ b/tests/integration/targets/pipx/tasks/testcase-injectpkg.yml @@ -3,17 +3,17 @@ # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later -- name: ensure application pylint is uninstalled +- name: Ensure application pylint is uninstalled community.general.pipx: name: pylint state: absent -- name: install application pylint +- name: Install application pylint community.general.pipx: name: pylint register: install_pylint -- name: inject packages +- name: Inject packages community.general.pipx: state: inject name: pylint @@ -21,7 +21,7 @@ - licenses register: inject_pkgs_pylint -- name: inject packages with apps +- name: Inject packages with apps community.general.pipx: state: inject name: pylint @@ -30,13 +30,13 @@ install_apps: true register: inject_pkgs_apps_pylint -- name: cleanup pylint +- name: Cleanup pylint community.general.pipx: state: absent name: pylint register: uninstall_pylint -- name: check assertions inject_packages +- name: Check assertions inject_packages assert: that: - install_pylint is changed