pipx: accept python version specs in parameter name (#10031)

* pipx: accept python version specs in parameter "name"

* pipx_info: adjustment for backward compatibility

* remove unnecessary comprehension

* remove f-str

* no shebang for module utils

* remove f-str

* fix syntax error

* fix pipx_info

* rollback adjustments in existing tests

* docs & test update

* add debugging tasks to int test

* integration test checks for version of packaging

* move assertion to block

* fix idempotency when using version specifier

* add changelog frag

* fix docs

* dial down the version of tox used in tests

To accommodate old Pythons

* Update plugins/modules/pipx.py

* Apply suggestions from code review

* refactor/rename package requirements code

* fix filename in BOTMETA

* Update plugins/modules/pipx.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/pipx.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* pipx mod utils: create make_process_dict and deprecate make_process_list

* pkg_req: make method private

* make_process_dict is simpler and more specialized

* ensure version specifiers are honored when state=install

* fix insanity

* pipx: reformat yaml blocks

* pipx: doc wordsmithing

---------

Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
Alexei Znamensky 2025-05-17 18:00:27 +12:00 committed by GitHub
parent 626ee3115d
commit 2b4cb6dabc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 302 additions and 64 deletions

2
.github/BOTMETA.yml vendored
View file

@ -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:

View file

@ -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).

View file

@ -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 []
results = []
raw_data = json.loads(out)
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():
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 kwargs.get("include_injected"):
if include_injected:
entry['injected'] = {k: v['package_version'] for k, v in venv['metadata']['injected_packages'].items()}
if kwargs.get("include_deps"):
if include_deps:
entry['dependencies'] = list(venv['metadata']['main_package']['app_paths_of_dependencies'])
results.append(entry)
return entry
return 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
return [
entry
for name, entry in res_dict.items()
if name == kwargs.get("name")
]
return process_list

View file

@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025, Alexei Znamensky <russoz@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.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 '>=<!~[]'):
return name.strip(), None
deps.validate(self.module, "packaging")
try:
req = Requirement(name)
return req.name, req
except Exception as e:
raise_from(ValueError("Invalid package specification for '{0}': {1}".format(name, e)), e)
def matches_version(self, version):
"""
Check if a version string fulfills a version specifier.
:param version: Version string to check
:return: True if version fulfills the requirement, False otherwise
:raises ValueError: If version is invalid
"""
# If no spec provided, any version is valid
if not self.requirement:
return True
try:
# Parse version string
ver = parse_version(version)
return ver in self.requirement.specifier
except InvalidVersion as e:
raise_from(ValueError("Invalid version '{0}': {1}".format(version, e)))

View file

@ -54,11 +54,17 @@ options:
name:
type: str
description:
- The name of the application. In C(pipx) documentation it is also referred to as the name of the virtual environment
where the application will be installed.
- The name of the application and also the name of the Python package being installed.
- In C(pipx) documentation it is also referred to as the name of the virtual environment where the application is installed.
- If O(name) is a simple package name without version specifiers, then that name is used as the Python package name
to be installed.
- Use O(source) for passing package specifications or installing from URLs or directories.
- Starting in community.general 10.7.0, you can use package specifiers when O(state=present) or O(state=install). For
example, O(name=tox<4.0.0) or O(name=tox>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,19 +267,14 @@ 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()
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 {}
output_process = make_process_dict(include_injected=True)
installed, dummy = self.runner('_list global', output_process=output_process).run()
if self.app_name is None:
return installed
return {k: v for k, v in installed.items() if k == self.app_name}
def __init_module__(self):
if self.vars.executable:
self.command = [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,11 +304,26 @@ 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:
# 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.vars.name, self.vars.source])
ctx.run(name_source=[self.parsed_name, self.vars.source], force=force)
self._capture_results(ctx)
state_present = state_install

View file

@ -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):

View file

@ -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
##############################################################################

View file

@ -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"