1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-03-21 20:59:10 +00:00
This commit is contained in:
Mariam Ahhttouche 2026-03-19 22:48:54 +01:00 committed by GitHub
commit f57843c1e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 655 additions and 0 deletions

3
.github/BOTMETA.yml vendored
View file

@ -1440,6 +1440,9 @@ files:
$modules/utm_proxy_exception.py:
keywords: sophos utm
maintainers: $team_e_spirit RickS-C137
$modules/uv_python.py:
keywords: uv python
maintainers: mriamah
$modules/vdo.py:
maintainers: rhawalsh bgurney-rh
$modules/vertica_:

View file

@ -0,0 +1,361 @@
#!/usr/bin/python
# Copyright (c) 2026 Mariam Ahhttouche <mariam.ahhttouche@gmail.com>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: uv_python
short_description: Manage Python versions and installations using the C(uv) Python package manager
description:
- Install, uninstall or upgrade Python versions managed by C(uv).
version_added: "12.5.0"
requirements:
- C(uv) must be installed and available in E(PATH) and must be at least 0.8.0.
- Python version must be at least 3.9.
extends_documentation_fragment:
- community.general.attributes
attributes:
check_mode:
support: full
diff_mode:
support: none
options:
version:
description:
- Python version to manage.
- "Not all canonical Python versions are supported in this release. Valid version numbers consist of two or three dot-separated
numeric components, with an optional 'pre-release' tag at the end such as V(3.12), V(3.12.3), V(3.15.0a5)."
- Advanced uv selectors such as V(>=3.12,<3.13) or V(cpython@3.12) are not supported in this release.
- When you specify only a major.minor version, make sure the number is enclosed in quotes so that it gets parsed correctly.
Note that in case only a major.minor version are specified behavior depends on the O(state) parameter.
type: str
required: true
state:
description:
- Desired state of the specified Python version.
- "V(present) ensures the specified version is installed. If you specify a full patch version (for example O(version=3.12.3)),
that exact version is be installed if not already present. If you only specify a minor version (for example V(3.12)),
the latest available patch version for that minor release is installed only if no patch version for that minor release
is currently installed (including patch versions not managed by C(uv)). RV(python_versions) and RV(python_paths)
lengths are always equal to one for this state."
- "V(absent) ensures the specified version is removed. If you specify a full patch version, only that exact patch version
is removed. If you only specify a minor version (for example V(3.12)), all installed patch versions for that minor
release are removed. If you specify a version that is not installed, no changes are made. RV(python_versions) and
RV(python_paths) lengths can be higher or equal to one in this state."
- V(latest) ensures the latest available patch version for the specified version is installed. If you only specify
a minor version (for example V(3.12)), the latest available patch version for that minor release is always installed.
If another patch version is already installed but is not the latest, the latest patch version is installed. The latest
patch version installed depends on the C(uv) version, since available Python versions are frozen per C(uv) release.
RV(python_versions) and RV(python_paths) lengths are always equal to one in this state. This state does not use C(uv python upgrade).
type: str
choices: [present, absent, latest]
default: present
seealso:
- name: C(uv) documentation
description: Python versions management with C(uv).
link: https://docs.astral.sh/uv/concepts/python-versions/
- name: C(uv) CLI documentation
description: C(uv) CLI reference guide.
link: https://docs.astral.sh/uv/reference/cli/#uv-python
author: Mariam Ahhttouche (@mriamah)
"""
EXAMPLES = r"""
- name: Install Python 3.14
community.general.uv_python:
version: "3.14"
- name: Upgrade Python 3.14
community.general.uv_python:
version: "3.14"
state: latest
- name: Remove Python 3.13.5
community.general.uv_python:
version: 3.13.5
state: absent
"""
RETURN = r"""
python_versions:
description: List of Python versions changed.
returned: success
type: list
elements: str
sample:
- "3.13.5"
python_paths:
description: List of installation paths of Python versions changed.
returned: success
type: list
elements: str
sample:
- "/root/.local/share/uv/python/cpython-3.13.5-linux-x86_64-gnu/bin/python3.13"
stdout:
description: Stdout of the executed command.
returned: success
type: str
sample: ""
stderr:
description: Stderr of the executed command.
returned: success
type: str
sample: ""
rc:
description: Return code of the executed command.
returned: success
type: int
sample: 0
"""
import json
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.compat.version import LooseVersion, StrictVersion
MINIMUM_UV_VERSION = "0.8.0"
class UV:
"""
Module for managing Python versions and installations using "uv python" command
"""
def __init__(self, module: AnsibleModule) -> None:
self.module = module
self.bin_path = self.module.get_bin_path("uv", required=True)
self._ensure_min_uv_version()
try:
self.python_version = StrictVersion(module.params["version"])
self.python_version_str = str(self.python_version)
except (ValueError, AttributeError):
self.module.fail_json(
msg=(
"Unsupported version format. Valid version numbers consist of two or three dot-separated numeric components"
" with an optional 'pre-release' tag on the end (for example 3.12, 3.12.3, 3.15.0a5) are supported in this release."
)
)
def _ensure_min_uv_version(self) -> None:
cmd = [self.bin_path, "--version", "--color", "never"]
dummy_rc, out, dummy_err = self.module.run_command(cmd, check_rc=True)
detected = out.strip().split()[-1]
if LooseVersion(detected) < LooseVersion(MINIMUM_UV_VERSION):
self.module.fail_json(
msg=f"uv_python module requires uv >= {MINIMUM_UV_VERSION}",
detected_version=detected,
required_version=MINIMUM_UV_VERSION,
)
def install_python(self) -> tuple[bool, str, str, int, list[str], list[str]]:
"""
Runs command 'uv python install X.Y.Z' which installs specified python version.
If patch version is not specified uv installs latest available patch version.
Returns:
- boolean to indicate if method changed state
- command's stdout
- command's stderr
- command's return code
- list of installed versions
- list of installation paths for each installed version
"""
find_rc, existing_version, dummy_err = self._find_python("--show-version")
if find_rc == 0:
dummy_rc, version_path, dummy_err = self._find_python()
return False, "", "", 0, [existing_version], [version_path]
if self.module.check_mode:
latest_version, dummy_path = self._get_latest_patch_release("--managed-python")
# when uv does not find any available patch version the install command will fail
if not latest_version:
self.module.fail_json(msg=(f"Version {self.python_version_str} is not available."))
return True, "", "", 0, [latest_version], [""]
rc, out, err = self._exec(self.python_version_str, "install", check_rc=True)
latest_version, path = self._get_latest_patch_release("--only-installed", "--managed-python")
return True, out, err, rc, [latest_version], [path]
def uninstall_python(self) -> tuple[bool, str, str, int, list, list]:
"""
Runs command 'uv python uninstall X.Y.Z' which removes specified python version from environment.
If patch version is not specified all correspending installed patch versions are removed.
Returns:
tuple [bool, str, str, int, list, list]
- boolean to indicate if method changed state
- command's stdout
- command's stderr
- command's return code
- list of uninstalled versions
- list of previous installation paths for each uninstalled version
"""
installed_versions, install_paths = self._get_installed_versions("--managed-python")
if not installed_versions:
return False, "", "", 0, [], []
if self.module.check_mode:
return True, "", "", 0, installed_versions, install_paths
rc, out, err = self._exec(self.python_version_str, "uninstall", check_rc=True)
return True, out, err, rc, installed_versions, install_paths
def upgrade_python(self) -> tuple[bool, str, str, int, list, list]:
"""
Runs command 'uv python install X.Y.Z' with latest patch version available.
Returns:
tuple [bool, str, str, int, list, list]
- boolean to indicate if method changed state
- command's stdout
- command's stderr
- command's return code
- list of installed versions
- list of installation paths for each installed version
"""
rc, installed_version_str, dummy_err = self._find_python("--show-version")
installed_version = self._parse_version(installed_version_str)
latest_version_str, dummy_path = self._get_latest_patch_release("--managed-python")
if not latest_version_str:
self.module.fail_json(msg=f"Version {self.python_version_str} is not available.")
if rc == 0 and installed_version >= StrictVersion(latest_version_str):
dummy_rc, install_path, dummy_err = self._find_python()
return False, "", "", rc, [installed_version_str], [install_path]
if self.module.check_mode:
return True, "", "", 0, [latest_version_str], []
# it's possible to have latest version already installed but not used as default
# so in this case 'uv python install' will set latest version as default
rc, out, err = self._exec(latest_version_str, "install", check_rc=True)
latest_version_str, latest_path = self._get_latest_patch_release("--only-installed", "--managed-python")
return True, out, err, rc, [latest_version_str], [latest_path]
def _exec(self, python_version: str, command, *args, check_rc=False) -> tuple[int, str, str]:
"""
Execute a uv python subcommand.
Args:
python_version: Python version specifier (e.g. "3.12", "3.12.3").
command (str): uv python subcommand (e.g. "install", "uninstall", "find").
*args: Additional positional arguments passed to the command.
check_rc (bool): Whether to fail if the command exits with non-zero return code.
Returns:
tuple[int, str, str]:
A tuple containing (rc, stdout, stderr).
"""
cmd = [self.bin_path, "python", command, python_version, "--color", "never", *args]
rc, out, err = self.module.run_command(cmd, check_rc=check_rc)
return rc, out, err
def _find_python(self, *args, check_rc=False) -> tuple[int, str, str]:
"""
Runs command 'uv python find' which returns path of installed patch releases for a given python version.
If multiple patch versions are installed, "uv python find" returns the one used by default
if inside a virtualenv otherwise it returns latest installed patch version.
Args:
*args: Additional positional arguments passed to _exec.
check_rc (bool): Whether to fail if the command exits with non-zero return code.
Returns:
tuple[int, str, str]:
A tuple containing (rc, stdout, stderr).
"""
rc, out, err = self._exec(self.python_version_str, "find", *args, check_rc=check_rc)
if rc == 0:
out = out.strip()
return rc, out, err
def _list_python(self, *args, check_rc=False) -> tuple[int, list, str]:
"""
Runs command 'uv python list' (which returns list of installed patch releases for a given python version).
Official documentation https://docs.astral.sh/uv/reference/cli/#uv-python-list
Args:
*args: Additional positional arguments passed to _exec.
check_rc (bool): Whether to fail if the command exits with non-zero return code.
Returns:
tuple[int, list, str]
A tuple containing (rc, stdout, stderr).
"""
rc, out, err = self._exec(self.python_version_str, "list", "--output-format", "json", *args, check_rc=check_rc)
pythons_installed = []
try:
pythons_installed = json.loads(out)
except json.decoder.JSONDecodeError:
# This happens when no version is found
pass
return rc, pythons_installed, err
def _get_latest_patch_release(self, *args) -> tuple[str, str]:
"""
Returns latest available patch release for a given python version.
Args:
*args: Additional positional arguments passed to _list_python.
Returns:
tuple[str, str]:
- latest found patch version in format X.Y.Z
- installation path of latest patch version if version exists
"""
latest_version = path = ""
# 'uv python list' returns versions in descending order but we sort them just in case future uv behavior changes
dummy_rc, results, dummy_err = self._list_python(*args)
valid_results = self._filter_valid_versions(results)
if valid_results:
version = max(valid_results, key=lambda result: result["parsed_version"])
latest_version = version.get("version", "")
path = version.get("path", "")
return latest_version, path
def _get_installed_versions(self, *args) -> tuple[list, list]:
"""
Returns installed patch releases for a given python version.
Args:
*args: Additional positional arguments passed to _list_python.
Returns:
tuple[list, list]:
- list of latest found patch versions
- list of installation paths of installed versions
"""
dummy_rc, results, dummy_err = self._list_python("--only-installed", *args)
if results:
return [result.get("version") for result in results], [result.get("path") for result in results]
return [], []
def _filter_valid_versions(self, results):
valid_results = []
for result in results:
version = result.get("version", "")
try:
result["parsed_version"] = StrictVersion(version)
valid_results.append(result)
except ValueError:
self.module.debug(f"Found {version} available, but it's not yet supported by uv_python module.")
return valid_results
@staticmethod
def _parse_version(version_str):
try:
return StrictVersion(version_str)
except ValueError:
return StrictVersion("0")
def main():
module = AnsibleModule(
argument_spec=dict(
version=dict(type="str", required=True),
state=dict(type="str", default="present", choices=["present", "absent", "latest"]),
),
supports_check_mode=True,
)
result = dict(changed=False, stdout="", stderr="", rc=0, python_versions=[], python_paths=[], failed=False)
state = module.params["state"]
exec_result = {}
uv = UV(module)
if state == "present":
exec_result = dict(zip(["changed", "stdout", "stderr", "rc", "python_versions", "python_paths"], uv.install_python()))
elif state == "absent":
exec_result = dict(zip(["changed", "stdout", "stderr", "rc", "python_versions", "python_paths"], uv.uninstall_python()))
elif state == "latest":
exec_result = dict(zip(["changed", "stdout", "stderr", "rc", "python_versions", "python_paths"], uv.upgrade_python()))
result.update(exec_result)
module.exit_json(**result)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,10 @@
# 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
azp/posix/2
destructive
skip/macos # TODO executables are installed in /Library/Frameworks/Python.framework/Versions/3.x/bin, which isn't part of $PATH
skip/windows
skip/freebsd
skip/rhel

View file

@ -0,0 +1,281 @@
---
####################################################################
# WARNING: These are designed specifically for Ansible tests #
# and should not be used as examples of how to write Ansible roles #
####################################################################
# 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: Install uv python package
ansible.builtin.pip:
name: uv
- name: Check if Python 3.14 exists already
command: uv python find 3.14
ignore_errors: true
register: check_python_314_exists
changed_when: false
- name: Install Python 3.14 in check mode
uv_python:
version: 3.14
state: present
check_mode: true
register: install_check_mode
- name: Verify Python 3.14 installation in check mode
assert:
that:
- install_check_mode.changed == check_python_314_exists.failed
- install_check_mode.failed is false
- install_check_mode.python_versions | length >= 1
- name: Install Python 3.14
uv_python:
version: 3.14
state: present
register: install_python
- name: Verify Python 3.14 installation
assert:
that:
- install_python.changed == check_python_314_exists.failed
- install_python.failed is false
- install_python.python_versions | length >= 1
- install_python.python_paths | length >= 1
- name: Re-install Python 3.14
uv_python:
version: 3.14
state: present
register: reinstall_python
- name: Verify Python 3.14 re-installation
assert:
that:
- reinstall_python.changed is false
- reinstall_python.failed is false
- reinstall_python.python_versions | length >= 1
- reinstall_python.python_paths | length >= 1
- name: Check if Python 3.13.5 exists already
command: uv python find 3.13.5
ignore_errors: true
register: check_python_3135_exists
changed_when: false
- name: Install Python 3.13.5
uv_python:
version: 3.13.5
register: install_python_3135
- name: Verify Python 3.13.5 installation
assert:
that:
- install_python_3135.changed == check_python_3135_exists.failed
- install_python_3135.failed is false
- '"3.13.5" in install_python_3135.python_versions'
- install_python_3135.python_paths | length >= 1
- name: Re-install Python 3.13.5
uv_python:
version: 3.13.5
state: present
register: reinstall_python
- name: Verify Python 3.13.5 installation
assert:
that:
- reinstall_python.changed is false
- reinstall_python.failed is false
- '"3.13.5" in reinstall_python.python_versions'
- name: Remove uninstalled Python 3.10 # removes latest patch version for 3.10 if exists
uv_python:
version: "3.10"
state: absent
register: remove_python
- name: Verify Python 3.10 deletion
assert:
that:
- remove_python.changed is false
- remove_python.failed is false
- remove_python.python_versions | length == 0
- name: Remove Python 3.13.5 in check mode
uv_python:
version: 3.13.5
state: absent
check_mode: true
register: remove_python_in_check_mode
- name: Verify Python 3.13.5 deletion in check mode
assert:
that:
- remove_python_in_check_mode.changed == install_python_3135.changed
- remove_python_in_check_mode.failed is false
- name: Additional check for Python 3.13.5 deletion in check mode
when: install_python_3135.changed is true
assert:
that:
- '"3.13.5" in remove_python_in_check_mode.python_versions'
- remove_python_in_check_mode.python_paths | length >= 1
- name: Remove Python 3.13.5
uv_python:
version: 3.13.5
state: absent
register: remove_python
- name: Verify Python 3.13.5 deletion
assert:
that:
- remove_python.changed == install_python_3135.changed
- remove_python.failed is false
- name: Additional check for Python 3.13.5 deletion
when: install_python_3135.changed is true
assert:
that:
- remove_python.python_paths | length >= 1
- '"3.13.5" in remove_python.python_versions'
- name: Remove Python 3.13.5 again
uv_python:
version: 3.13.5
state: absent
register: remove_python
- name: Verify Python 3.13.5 deletion again
assert:
that:
- remove_python.changed is false
- remove_python.failed is false
- remove_python.python_versions | length == 0
- remove_python.python_paths | length == 0
- name: Upgrade Python 3.13
uv_python:
version: 3.13
state: latest
register: upgrade_python
- name: Verify Python 3.13 upgrade
assert:
that:
- upgrade_python.changed is true
- upgrade_python.failed is false
- upgrade_python.python_versions | length >= 1
- upgrade_python.python_paths | length >= 1
- name: Upgrade Python 3.13 in check mode
uv_python:
version: 3.13
state: latest
check_mode: true
register: upgrade_python
- name: Verify Python 3.13 upgrade in check mode
assert:
that:
- upgrade_python.changed is false
- upgrade_python.failed is false
- upgrade_python.python_versions | length >= 1
- name: Upgrade Python 3.13 again
uv_python:
version: 3.13
state: latest
check_mode: true
register: upgrade_python
- name: Verify Python 3.13 upgrade again
assert:
that:
- upgrade_python.changed is false
- upgrade_python.failed is false
- upgrade_python.python_versions | length >= 1
- name: Install unsupported Python version in check mode
uv_python:
version: 2.0
state: present
register: unsupported_python_check_mode
ignore_errors: true
check_mode: true
- name: Verify unsupported Python 2.0 install in check mode
assert:
that:
- unsupported_python_check_mode.changed is false
- unsupported_python_check_mode.failed is true
- '"Version 2.0 is not available." in unsupported_python_check_mode.msg'
- name: Install unsupported Python version
uv_python:
state: present
version: 2.0
register: unsupported_python
ignore_errors: true
- name: Verify unsupported Python 2.0 install
assert:
that:
- unsupported_python.changed is false
- unsupported_python.failed is true
- name: Install unsupported Python version with latest state
uv_python:
version: 2.0
state: latest
register: unsupported_python
ignore_errors: true
- name: Verify Python 2.0 install with latest state
assert:
that:
- unsupported_python.changed is false
- unsupported_python.failed is true
- name: Delete Python 3.13 version
uv_python:
version: 3.13
state: absent
register: uninstall_result
- name: Verify Python 3.13 deletion
assert:
that:
- uninstall_result.changed is true
- uninstall_result.failed is false
- '"3.13.12" in uninstall_result.python_versions'
- name: No specified version
uv_python:
state: latest
version: ""
ignore_errors: true
register: no_version
- name: Verify failure when no version is specified
assert:
that:
- no_version.failed is true
- '"Unsupported version format" in no_version.msg'
- name: Unsupported version format given
uv_python:
state: latest
version: "3.8.post2"
ignore_errors: true
register: wrong_version
- name: Verify failure when unsupported version format is specified
assert:
that:
- wrong_version.failed is true
- '"Unsupported version format" in wrong_version.msg'