diff --git a/plugins/modules/uv_python.py b/plugins/modules/uv_python.py index d98f70f0c0..20e723965e 100644 --- a/plugins/modules/uv_python.py +++ b/plugins/modules/uv_python.py @@ -116,6 +116,7 @@ LIB_IMP_ERR = None HAS_LIB = False try: from packaging.version import Version, InvalidVersion + HAS_LIB = True except: LIB_IMP_ERR = traceback.format_exc() @@ -126,38 +127,38 @@ MINIMUM_UV_VERSION = "0.8.0" class UV: """ - Module for managing Python versions and installations using "uv python" command + Module for managing Python versions and installations using "uv python" command """ def __init__(self, module): - self.module = module - self._ensure_min_uv_version() - python_version = module.params["version"] - try: - self.python_version = Version(python_version) - self.python_version_str = self.python_version.__str__() - except InvalidVersion: - self.module.fail_json( - msg="Unsupported version format. Only canonical Python versions (e.g. 3, 3.12, 3.12.3, 3.15.0a5) are supported in this release." - ) + self.module = module + self._ensure_min_uv_version() + python_version = module.params["version"] + try: + self.python_version = Version(python_version) + self.python_version_str = self.python_version.__str__() + except InvalidVersion: + self.module.fail_json( + msg="Unsupported version format. Only canonical Python versions (e.g. 3, 3.12, 3.12.3, 3.15.0a5) are supported in this release." + ) def _ensure_min_uv_version(self): - cmd = [self.module.get_bin_path("uv", required=True), "--version", "--color", "never"] - _, out, _ = 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, - ) + cmd = [self.module.get_bin_path("uv", required=True), "--version", "--color", "never"] + _, out, _ = 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): - """ + """ 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: - tuple [bool, str, str, int, list, list] + Returns: + tuple [bool, str, str, int, list, list] - boolean to indicate if method changed state - command's stdout - command's stderr @@ -168,27 +169,27 @@ class UV: AnsibleModuleFailJson: If the install command exits with a non-zero return code. If specified version is not available for download. - """ - find_rc, existing_version, _ = self._find_python("--show-version") - if find_rc == 0: - _, version_path, _ = self._find_python() - return False, "", "", 0, [existing_version], [version_path] - if self.module.check_mode: - latest_version, _ = 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] + """ + find_rc, existing_version, _ = self._find_python("--show-version") + if find_rc == 0: + _, version_path, _ = self._find_python() + return False, "", "", 0, [existing_version], [version_path] + if self.module.check_mode: + latest_version, _ = 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): - """ + """ 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] + Returns: + tuple [bool, str, str, int, list, list] - boolean to indicate if method changed state - command's stdout - command's stderr @@ -198,20 +199,20 @@ class UV: Raises: AnsibleModuleFailJson: If the uninstall command exits with a non-zero return code. - """ - 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 + """ + 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): - """ + """ Runs command 'uv python install X.Y.Z' with latest patch version available. - Returns: - tuple [bool, str, str, int, list, list] + Returns: + tuple [bool, str, str, int, list, list] - boolean to indicate if method changed state - command's stdout - command's stderr @@ -222,25 +223,25 @@ class UV: AnsibleModuleFailJson: If the install command exits with a non-zero return code. If resolved patch version is not available for download. - """ - rc, installed_version_str, _ = self._find_python("--show-version") - installed_version = self._parse_version(installed_version_str) - latest_version_str, _ = 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 >= Version(latest_version_str): - _, install_path, _ = 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] + """ + rc, installed_version_str, _ = self._find_python("--show-version") + installed_version = self._parse_version(installed_version_str) + latest_version_str, _ = 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 >= Version(latest_version_str): + _, install_path, _ = 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, command, *args, check_rc=False): - """ + """ Execute a uv python subcommand. Args: python_version (str): Python version specifier (e.g. "3.12", "3.12.3"). @@ -253,15 +254,15 @@ class UV: Raises: AnsibleModuleFailJson: If check_rc is True and the command exits with a non-zero return code. - """ - cmd = [self.module.get_bin_path("uv", required=True), "python", command, python_version, "--color", "never", *args] - rc, out, err = self.module.run_command(cmd, check_rc=check_rc) - return rc, out, err + """ + cmd = [self.module.get_bin_path("uv", required=True), "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): - """ + """ 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 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. @@ -272,14 +273,14 @@ class UV: Raises: AnsibleModuleFailJson: If check_rc is True and the command exits with a non-zero return code. - """ - 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 + """ + 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): - """ + """ 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: @@ -291,17 +292,17 @@ class UV: Raises: AnsibleModuleFailJson: If check_rc is True and the command exits with a non-zero return code. - """ - rc, out, err = self._exec(self.python_version_str, "list", "--output-format", "json", *args, check_rc=check_rc) - try: - out = json.loads(out) - except json.decoder.JSONDecodeError: - # This happens when no version is found - pass - return rc, out, err + """ + rc, out, err = self._exec(self.python_version_str, "list", "--output-format", "json", *args, check_rc=check_rc) + try: + out = json.loads(out) + except json.decoder.JSONDecodeError: + # This happens when no version is found + pass + return rc, out, err def _get_latest_patch_release(self, *args): - """ + """ Returns latest available patch release for a given python version. Args: *args: Additional positional arguments passed to _list_python. @@ -309,18 +310,18 @@ class UV: tuple[str, str]: - latest found patch version in format X.Y.Z - installation path of latest patch version if version exists - """ - latest_version = path = "" - _, results, _ = self._list_python(*args) # uv returns versions in descending order but we sort them just in case future uv behavior changes - valid_results = self._parse_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 + """ + latest_version = path = "" + _, results, _ = self._list_python(*args) # uv returns versions in descending order but we sort them just in case future uv behavior changes + valid_results = self._parse_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): - """ + """ Returns installed patch releases for a given python version. Args: *args: Additional positional arguments passed to _list_python. @@ -328,65 +329,56 @@ class UV: tuple[list, list]: - list of latest found patch versions - list of installation paths of installed versions - """ - _, results, _ = self._list_python("--only-installed", *args) - if results: - return [result["version"] for result in results], [result["path"] for result in results] - return [], [] + """ + _, results, _ = self._list_python("--only-installed", *args) + if results: + return [result["version"] for result in results], [result["path"] for result in results] + return [], [] @staticmethod def _parse_versions(results): - valid_results =[] - for result in results: - try: - result["parsed_version"] = Version(result.get("version", "")) - valid_results.append(result) - except InvalidVersion: - continue - return valid_results + valid_results = [] + for result in results: + try: + result["parsed_version"] = Version(result.get("version", "")) + valid_results.append(result) + except InvalidVersion: + continue + return valid_results @staticmethod def _parse_version(version_str): - try: - return Version(version_str) - except InvalidVersion: - return Version("0") + try: + return Version(version_str) + except InvalidVersion: + return Version("0") def main(): module = AnsibleModule( argument_spec=dict( - version=dict(type='str', required=True), - state=dict(type='str', default='present', choices=['present', 'absent', 'latest']), + version=dict(type="str", required=True), + state=dict(type="str", default="present", choices=["present", "absent", "latest"]), ), - supports_check_mode=True + supports_check_mode=True, ) if not HAS_LIB: - module.fail_json(msg=missing_required_lib("packaging"), - exception=LIB_IMP_ERR) - - result = dict( - changed=False, - stdout="", - stderr="", - rc=0, - python_versions=[], - python_paths=[], - failed=False - ) + module.fail_json(msg=missing_required_lib("packaging"), exception=LIB_IMP_ERR) + + result = dict(changed=False, stdout="", stderr="", rc=0, python_versions=[], python_paths=[], failed=False) state = module.params["state"] uv = UV(module) if state == "present": - result["changed"], result["stdout"], result["stderr"], result["rc"], result["python_versions"], result["python_paths"] = uv.install_python() + result["changed"], result["stdout"], result["stderr"], result["rc"], result["python_versions"], result["python_paths"] = uv.install_python() elif state == "absent": - result["changed"], result["stdout"], result["stderr"], result["rc"], result["python_versions"], result["python_paths"] = uv.uninstall_python() + result["changed"], result["stdout"], result["stderr"], result["rc"], result["python_versions"], result["python_paths"] = uv.uninstall_python() elif state == "latest": - result["changed"], result["stdout"], result["stderr"], result["rc"], result["python_versions"], result["python_paths"] = uv.upgrade_python() + result["changed"], result["stdout"], result["stderr"], result["rc"], result["python_versions"], result["python_paths"] = uv.upgrade_python() module.exit_json(**result) if __name__ == "__main__": - main() \ No newline at end of file + main()