diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 4095986151..100dddae63 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -387,6 +387,8 @@ files: $module_utils/pipx.py: labels: pipx maintainers: russoz + $module_utils/pkg_req.py: + maintainers: russoz $module_utils/python_runner.py: maintainers: russoz $module_utils/puppet.py: diff --git a/changelogs/fragments/10031-pipx-python-version.yml b/changelogs/fragments/10031-pipx-python-version.yml new file mode 100644 index 0000000000..d18932eb2c --- /dev/null +++ b/changelogs/fragments/10031-pipx-python-version.yml @@ -0,0 +1,8 @@ +minor_changes: + - pipx module_utils - filtering application list by name now happens in the modules (https://github.com/ansible-collections/community.general/pull/10031). + - pipx_info - filtering application list by name now happens in the module (https://github.com/ansible-collections/community.general/pull/10031). + - > + pipx - parameter ``name`` now accepts Python package specifiers + (https://github.com/ansible-collections/community.general/issues/7815, https://github.com/ansible-collections/community.general/pull/10031). +deprecated_features: + - pipx module_utils - function ``make_process_list()`` is deprecated and will be removed in community.general 13.0.0 (https://github.com/ansible-collections/community.general/pull/10031). diff --git a/plugins/module_utils/pipx.py b/plugins/module_utils/pipx.py index de43f80b40..bb37712c21 100644 --- a/plugins/module_utils/pipx.py +++ b/plugins/module_utils/pipx.py @@ -71,36 +71,51 @@ def pipx_runner(module, command, **kwargs): return runner -def make_process_list(mod_helper, **kwargs): - def process_list(rc, out, err): - if not out: - return [] +def _make_entry(venv_name, venv, include_injected, include_deps): + entry = { + 'name': venv_name, + 'version': venv['metadata']['main_package']['package_version'], + 'pinned': venv['metadata']['main_package'].get('pinned'), + } + if include_injected: + entry['injected'] = {k: v['package_version'] for k, v in venv['metadata']['injected_packages'].items()} + if include_deps: + entry['dependencies'] = list(venv['metadata']['main_package']['app_paths_of_dependencies']) + return entry - results = [] + +def make_process_dict(include_injected, include_deps=False): + def process_dict(rc, out, err): + if not out: + return {} + + results = {} raw_data = json.loads(out) + for venv_name, venv in raw_data['venvs'].items(): + results[venv_name] = _make_entry(venv_name, venv, include_injected, include_deps) + + return results, raw_data + + return process_dict + + +def make_process_list(mod_helper, **kwargs): + # + # ATTENTION! + # + # The function `make_process_list()` is deprecated and will be removed in community.general 13.0.0 + # + process_dict = make_process_dict(mod_helper, **kwargs) + + def process_list(rc, out, err): + res_dict, raw_data = process_dict(rc, out, err) + if kwargs.get("include_raw"): mod_helper.vars.raw_output = raw_data - if kwargs["name"]: - if kwargs["name"] in raw_data['venvs']: - data = {kwargs["name"]: raw_data['venvs'][kwargs["name"]]} - else: - data = {} - else: - data = raw_data['venvs'] - - for venv_name, venv in data.items(): - entry = { - 'name': venv_name, - 'version': venv['metadata']['main_package']['package_version'], - 'pinned': venv['metadata']['main_package'].get('pinned'), - } - if kwargs.get("include_injected"): - entry['injected'] = {k: v['package_version'] for k, v in venv['metadata']['injected_packages'].items()} - if kwargs.get("include_deps"): - entry['dependencies'] = list(venv['metadata']['main_package']['app_paths_of_dependencies']) - results.append(entry) - - return results - + return [ + entry + for name, entry in res_dict.items() + if name == kwargs.get("name") + ] return process_list diff --git a/plugins/module_utils/pkg_req.py b/plugins/module_utils/pkg_req.py new file mode 100644 index 0000000000..8e82ffd360 --- /dev/null +++ b/plugins/module_utils/pkg_req.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025, Alexei Znamensky +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from ansible.module_utils.six import raise_from + +from ansible_collections.community.general.plugins.module_utils import deps + + +with deps.declare("packaging"): + from packaging.requirements import Requirement + from packaging.version import parse as parse_version, InvalidVersion + + +class PackageRequirement: + def __init__(self, module, name): + self.module = module + self.parsed_name, self.requirement = self._parse_spec(name) + + def _parse_spec(self, name): + """ + Parse a package name that may include version specifiers using PEP 508. + Returns a tuple of (name, requirement) where requirement is of type packaging.requirements.Requirement and it may be None. + + Example inputs: + "package" + "package>=1.0" + "package>=1.0,<2.0" + "package[extra]>=1.0" + "package[foo,bar]>=1.0,!=1.5" + + :param name: Package name with optional version specifiers and extras + :return: Tuple of (name, requirement) + :raises ValueError: If the package specification is invalid + """ + if not name: + return name, None + + # Quick check for simple package names + if not any(c in name for c in '>=3.0.27). + - Please note that when you use O(state=present) and O(name) with version specifiers, contrary to the behavior of C(pipx), + this module honors the version specifier and installs a version of the application that satisfies it. If you want + to ensure the reinstallation of the application even when the version specifier is met, then you must use O(force=true), + or perhaps use O(state=upgrade) instead. + - Use O(source) for installing from URLs or directories. source: type: str description: @@ -69,6 +75,7 @@ options: - The value of this option is passed as-is to C(pipx). - O(name) is still required when using O(source) to establish the application name without fetching the package from a remote source. + - The module is not idempotent when using O(source). install_apps: description: - Add apps from the injected packages. @@ -92,6 +99,7 @@ options: description: - Force modification of the application's virtual environment. See C(pipx) for details. - Only used when O(state=install), O(state=upgrade), O(state=upgrade_all), O(state=latest), or O(state=inject). + - The module is not idempotent when O(force=true). type: bool default: false include_injected: @@ -144,10 +152,10 @@ options: 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 first implementation does not verify whether a specified version constraint has been installed or not. Hence, when - using version operators, C(pipx) module will always try to execute the operation, even when the application was previously - installed. This feature will be added in the future. +requirements: + - When using O(name) with version specifiers, the Python package C(packaging) is required. + - If the package C(packaging) is at a version lesser than C(22.0.0), it will fail silently when processing invalid specifiers, + like C(tox<<<<4.0). author: - "Alexei Znamensky (@russoz)" """ @@ -201,7 +209,8 @@ version: from ansible_collections.community.general.plugins.module_utils.module_helper import StateModuleHelper -from ansible_collections.community.general.plugins.module_utils.pipx import pipx_runner, pipx_common_argspec, make_process_list +from ansible_collections.community.general.plugins.module_utils.pipx import pipx_runner, pipx_common_argspec, make_process_dict +from ansible_collections.community.general.plugins.module_utils.pkg_req import PackageRequirement from ansible.module_utils.facts.compat import ansible_facts @@ -258,18 +267,13 @@ class PipX(StateModuleHelper): use_old_vardict = False def _retrieve_installed(self): - name = _make_name(self.vars.name, self.vars.suffix) - output_process = make_process_list(self, include_injected=True, name=name) - installed = self.runner('_list global', output_process=output_process).run() + output_process = make_process_dict(include_injected=True) + installed, dummy = self.runner('_list global', output_process=output_process).run() - if name is not None: - app_list = [app for app in installed if app['name'] == name] - if app_list: - return {name: app_list[0]} - else: - return {} + if self.app_name is None: + return installed - return installed + return {k: v for k, v in installed.items() if k == self.app_name} def __init_module__(self): if self.vars.executable: @@ -279,6 +283,11 @@ class PipX(StateModuleHelper): self.command = [facts['python']['executable'], '-m', 'pipx'] self.runner = pipx_runner(self.module, self.command) + pkg_req = PackageRequirement(self.module, self.vars.name) + self.parsed_name = pkg_req.parsed_name + self.parsed_req = pkg_req.requirement + self.app_name = _make_name(self.parsed_name, self.vars.suffix) + self.vars.set('application', self._retrieve_installed(), change=True, diff=True) with self.runner("version") as ctx: @@ -295,12 +304,27 @@ class PipX(StateModuleHelper): self.vars.set('run_info', ctx.run_info, verbosity=4) def state_install(self): - if not self.vars.application or self.vars.force: - self.changed = True - args_order = 'state global index_url install_deps force python system_site_packages editable pip_args suffix name_source' - with self.runner(args_order, check_mode_skip=True) as ctx: - ctx.run(name_source=[self.vars.name, self.vars.source]) - self._capture_results(ctx) + # If we have a version spec and no source, use the version spec as source + if self.parsed_req and not self.vars.source: + self.vars.source = self.vars.name + + if self.vars.application.get(self.app_name): + is_installed = True + version_match = self.vars.application[self.app_name]['version'] in self.parsed_req.specifier if self.parsed_req else True + force = self.vars.force or (not version_match) + else: + is_installed = False + version_match = False + force = self.vars.force + + if is_installed and version_match and not force: + return + + self.changed = True + args_order = 'state global index_url install_deps force python system_site_packages editable pip_args suffix name_source' + with self.runner(args_order, check_mode_skip=True) as ctx: + ctx.run(name_source=[self.parsed_name, self.vars.source], force=force) + self._capture_results(ctx) state_present = state_install diff --git a/plugins/modules/pipx_info.py b/plugins/modules/pipx_info.py index 91d2fdb21c..05160caeed 100644 --- a/plugins/modules/pipx_info.py +++ b/plugins/modules/pipx_info.py @@ -126,7 +126,7 @@ version: """ from ansible_collections.community.general.plugins.module_utils.module_helper import ModuleHelper -from ansible_collections.community.general.plugins.module_utils.pipx import pipx_runner, pipx_common_argspec, make_process_list +from ansible_collections.community.general.plugins.module_utils.pipx import pipx_runner, pipx_common_argspec, make_process_dict from ansible.module_utils.facts.compat import ansible_facts @@ -158,9 +158,20 @@ class PipXInfo(ModuleHelper): self.vars.version = out.strip() def __run__(self): - output_process = make_process_list(self, **self.vars.as_dict()) + output_process = make_process_dict(self.vars.include_injected, self.vars.include_deps) with self.runner('_list global', output_process=output_process) as ctx: - self.vars.application = ctx.run() + applications, raw_data = ctx.run() + if self.vars.include_raw: + self.vars.raw_output = raw_data + + if self.vars.name: + self.vars.application = [ + v + for k, v in applications.items() + if k == self.vars.name + ] + else: + self.vars.application = list(applications.values()) self._capture_results(ctx) def _capture_results(self, ctx): diff --git a/tests/integration/targets/pipx/tasks/main.yml b/tests/integration/targets/pipx/tasks/main.yml index 04086d80cd..174ec9793a 100644 --- a/tests/integration/targets/pipx/tasks/main.yml +++ b/tests/integration/targets/pipx/tasks/main.yml @@ -6,7 +6,7 @@ - name: Determine pipx level block: - name: Install pipx>=1.7.0 - pip: + ansible.builtin.pip: name: pipx>=1.7.0 - name: Set has_pipx170 fact true ansible.builtin.set_fact: @@ -16,9 +16,24 @@ ansible.builtin.set_fact: has_pipx170: false - name: Install pipx (no version spec) - pip: + ansible.builtin.pip: name: pipx +- name: Determine packaging level + block: + - name: Install packaging>=22.0 + ansible.builtin.pip: + name: packaging>=22.0 + - name: Set has_packaging22 fact true + ansible.builtin.set_fact: + has_packaging22: true + rescue: + - name: Set has_packaging22 fact false + ansible.builtin.set_fact: + has_packaging22: false + - name: Install has_packaging (no version spec) + ansible.builtin.pip: + name: packaging ############################################################################## - name: ensure application tox is uninstalled @@ -208,26 +223,31 @@ community.general.pipx: state: absent name: tox - register: uninstall_tox_again + register: uninstall_tox_latest_yet_again - name: check assertions tox latest assert: that: - install_tox_latest is changed + - "'tox' in install_tox_latest.application" + - install_tox_latest.application.tox.version != '3.24.0' - uninstall_tox_latest is changed + - "'tox' not in uninstall_tox_latest.application" - install_tox_324_for_latest is changed + - "'tox' in install_tox_324_for_latest.application" - 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 + - "'tox' in install_tox_latest_with_preinstall.application" + - install_tox_latest_with_preinstall.application.tox.version != '3.24.0' - 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 - - install_tox_with_deps is changed - - install_tox_with_deps.application.tox.version == latest_tox_version - - uninstall_tox_again is changed - - "'tox' not in uninstall_tox_again.application" + - "'tox' not in uninstall_tox_latest_again.application" + +############################################################################## +# Test version specifiers in name parameter +- name: Run version specifier tests + ansible.builtin.include_tasks: testcase-10031-version-specs.yml ############################################################################## diff --git a/tests/integration/targets/pipx/tasks/testcase-10031-version-specs.yml b/tests/integration/targets/pipx/tasks/testcase-10031-version-specs.yml new file mode 100644 index 0000000000..e018720bd5 --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-10031-version-specs.yml @@ -0,0 +1,83 @@ +--- +# 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 + +############################################################################## +# Test version specifiers in name parameter + +- name: Ensure tox is uninstalled + community.general.pipx: + state: absent + name: tox + register: uninstall_tox + +- name: Install tox with version specifier in name + community.general.pipx: + name: tox>=3.22.0,<3.27.0 + register: install_tox_version + +- name: Install tox with same version specifier (idempotency check) + community.general.pipx: + name: tox>=3.22.0,<3.27.0 + register: install_tox_version_again + +- name: Ensure tox is uninstalled again + community.general.pipx: + state: absent + name: tox + +- name: Install tox with extras and version + community.general.pipx: + name: "tox[testing]>=3.22.0,<3.27.0" + register: install_tox_extras + ignore_errors: true # Some versions might not have this extra + +- name: Install tox with higher version specifier + community.general.pipx: + name: "tox>=3.27.0" + register: install_tox_higher_version + +- name: Install tox with higher version specifier (force) + community.general.pipx: + name: "tox>=3.27.0" + force: true + register: install_tox_higher_version_force + +- name: Cleanup tox + community.general.pipx: + state: absent + name: tox + register: uninstall_tox_final + +- name: Check version specifier assertions + assert: + that: + - install_tox_version is changed + - "'tox' in install_tox_version.application" + - "install_tox_version.application.tox.version is version('3.22.0', '>=')" + - "install_tox_version.application.tox.version is version('3.27.0', '<')" + - install_tox_version_again is not changed + - "'tox' in install_tox_extras.application" + - "install_tox_extras.application.tox.version is version('3.22.0', '>=')" + - "install_tox_extras.application.tox.version is version('3.27.0', '<')" + - install_tox_higher_version is changed + - install_tox_higher_version_force is changed + - uninstall_tox_final is changed + - "'tox' not in uninstall_tox_final.application" + +- name: If packaging is recent + when: + - has_packaging22 + block: + - name: Install tox with invalid version specifier + community.general.pipx: + name: "tox>>>>>3.27.0" + register: install_tox_invalid + ignore_errors: true + + - name: Check version specifier assertions + assert: + that: + - install_tox_invalid is failed + - "'Invalid package specification' in install_tox_invalid.msg"