diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 610449e482..4667b21139 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -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_: diff --git a/plugins/modules/uv_python.py b/plugins/modules/uv_python.py new file mode 100644 index 0000000000..2def83cbdf --- /dev/null +++ b/plugins/modules/uv_python.py @@ -0,0 +1,361 @@ +#!/usr/bin/python +# Copyright (c) 2026 Mariam Ahhttouche +# 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() diff --git a/tests/integration/targets/uv_python/aliases b/tests/integration/targets/uv_python/aliases new file mode 100644 index 0000000000..df78a209c4 --- /dev/null +++ b/tests/integration/targets/uv_python/aliases @@ -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 diff --git a/tests/integration/targets/uv_python/tasks/main.yaml b/tests/integration/targets/uv_python/tasks/main.yaml new file mode 100644 index 0000000000..95531c9e58 --- /dev/null +++ b/tests/integration/targets/uv_python/tasks/main.yaml @@ -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' \ No newline at end of file