xref: /linux/tools/lib/python/kdoc/python_version.py (revision f96163865a1346b199cc38e827269296f0f24ab0)
1#!/usr/bin/env python3
2# SPDX-License-Identifier: GPL-2.0-or-later
3# Copyright (c) 2017-2025 Mauro Carvalho Chehab <mchehab+huawei@kernel.org>
4
5"""
6Handle Python version check logic.
7
8Not all Python versions are supported by scripts. Yet, on some cases,
9like during documentation build, a newer version of python could be
10available.
11
12This class allows checking if the minimal requirements are followed.
13
14Better than that, PythonVersion.check_python() not only checks the minimal
15requirements, but it automatically switches to a the newest available
16Python version if present.
17
18"""
19
20import os
21import re
22import subprocess
23import shlex
24import sys
25
26from glob import glob
27from textwrap import indent
28
29class PythonVersion:
30    """
31    Ancillary methods that checks for missing dependencies for different
32    types of types, like binaries, python modules, rpm deps, etc.
33    """
34
35    def __init__(self, version):
36        """Ïnitialize self.version tuple from a version string"""
37        self.version = self.parse_version(version)
38
39    @staticmethod
40    def parse_version(version):
41        """Convert a major.minor.patch version into a tuple"""
42        return tuple(int(x) for x in version.split("."))
43
44    @staticmethod
45    def ver_str(version):
46        """Returns a version tuple as major.minor.patch"""
47        return ".".join([str(x) for x in version])
48
49    @staticmethod
50    def cmd_print(cmd, max_len=80):
51        cmd_line = []
52
53        for w in cmd:
54            w = shlex.quote(w)
55
56            if cmd_line:
57                if not max_len or len(cmd_line[-1]) + len(w) < max_len:
58                    cmd_line[-1] += " " + w
59                    continue
60                else:
61                    cmd_line[-1] += " \\"
62                    cmd_line.append(w)
63            else:
64                cmd_line.append(w)
65
66        return "\n  ".join(cmd_line)
67
68    def __str__(self):
69        """Returns a version tuple as major.minor.patch from self.version"""
70        return self.ver_str(self.version)
71
72    @staticmethod
73    def get_python_version(cmd):
74        """
75        Get python version from a Python binary. As we need to detect if
76        are out there newer python binaries, we can't rely on sys.release here.
77        """
78
79        kwargs = {}
80        if sys.version_info < (3, 7):
81            kwargs['universal_newlines'] = True
82        else:
83            kwargs['text'] = True
84
85        result = subprocess.run([cmd, "--version"],
86                                stdout = subprocess.PIPE,
87                                stderr = subprocess.PIPE,
88                                **kwargs, check=False)
89
90        version = result.stdout.strip()
91
92        match = re.search(r"(\d+\.\d+\.\d+)", version)
93        if match:
94            return PythonVersion.parse_version(match.group(1))
95
96        print(f"Can't parse version {version}")
97        return (0, 0, 0)
98
99    @staticmethod
100    def find_python(min_version):
101        """
102        Detect if are out there any python 3.xy version newer than the
103        current one.
104
105        Note: this routine is limited to up to 2 digits for python3. We
106        may need to update it one day, hopefully on a distant future.
107        """
108        patterns = [
109            "python3.[0-9][0-9]",
110            "python3.[0-9]",
111        ]
112
113        python_cmd = []
114
115        # Seek for a python binary newer than min_version
116        for path in os.getenv("PATH", "").split(":"):
117            for pattern in patterns:
118                for cmd in glob(os.path.join(path, pattern)):
119                    if os.path.isfile(cmd) and os.access(cmd, os.X_OK):
120                        version = PythonVersion.get_python_version(cmd)
121                        if version >= min_version:
122                            python_cmd.append((version, cmd))
123
124        return sorted(python_cmd, reverse=True)
125
126    @staticmethod
127    def check_python(min_version, show_alternatives=False, bail_out=False,
128                     success_on_error=False):
129        """
130        Check if the current python binary satisfies our minimal requirement
131        for Sphinx build. If not, re-run with a newer version if found.
132        """
133        cur_ver = sys.version_info[:3]
134        if cur_ver >= min_version:
135            ver = PythonVersion.ver_str(cur_ver)
136            return
137
138        python_ver = PythonVersion.ver_str(cur_ver)
139
140        available_versions = PythonVersion.find_python(min_version)
141        if not available_versions:
142            print(f"ERROR: Python version {python_ver} is not supported anymore\n")
143            print("       Can't find a new version. This script may fail")
144            return
145
146        script_path = os.path.abspath(sys.argv[0])
147
148        # Check possible alternatives
149        if available_versions:
150            new_python_cmd = available_versions[0][1]
151        else:
152            new_python_cmd = None
153
154        if show_alternatives and available_versions:
155            print("You could run, instead:")
156            for _, cmd in available_versions:
157                args = [cmd, script_path] + sys.argv[1:]
158
159                cmd_str = indent(PythonVersion.cmd_print(args), "  ")
160                print(f"{cmd_str}\n")
161
162        if bail_out:
163            msg = f"Python {python_ver} not supported. Bailing out"
164            if success_on_error:
165                print(msg, file=sys.stderr)
166                sys.exit(0)
167            else:
168                sys.exit(msg)
169
170        print(f"Python {python_ver} not supported. Changing to {new_python_cmd}")
171
172        # Restart script using the newer version
173        args = [new_python_cmd, script_path] + sys.argv[1:]
174
175        try:
176            os.execv(new_python_cmd, args)
177        except OSError as e:
178            sys.exit(f"Failed to restart with {new_python_cmd}: {e}")
179