xref: /linux/tools/lib/python/kdoc/python_version.py (revision 23b0f90ba871f096474e1c27c3d14f455189d2d9)
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        """
37        Ïnitialize self.version tuple from a version string.
38        """
39        self.version = self.parse_version(version)
40
41    @staticmethod
42    def parse_version(version):
43        """
44        Convert a major.minor.patch version into a tuple.
45        """
46        return tuple(int(x) for x in version.split("."))
47
48    @staticmethod
49    def ver_str(version):
50        """
51        Returns a version tuple as major.minor.patch.
52        """
53        return ".".join([str(x) for x in version])
54
55    @staticmethod
56    def cmd_print(cmd, max_len=80):
57        """
58        Outputs a command line, repecting maximum width.
59        """
60
61        cmd_line = []
62
63        for w in cmd:
64            w = shlex.quote(w)
65
66            if cmd_line:
67                if not max_len or len(cmd_line[-1]) + len(w) < max_len:
68                    cmd_line[-1] += " " + w
69                    continue
70                else:
71                    cmd_line[-1] += " \\"
72                    cmd_line.append(w)
73            else:
74                cmd_line.append(w)
75
76        return "\n  ".join(cmd_line)
77
78    def __str__(self):
79        """
80        Return a version tuple as major.minor.patch from self.version.
81        """
82        return self.ver_str(self.version)
83
84    @staticmethod
85    def get_python_version(cmd):
86        """
87        Get python version from a Python binary. As we need to detect if
88        are out there newer python binaries, we can't rely on sys.release here.
89        """
90
91        kwargs = {}
92        if sys.version_info < (3, 7):
93            kwargs['universal_newlines'] = True
94        else:
95            kwargs['text'] = True
96
97        result = subprocess.run([cmd, "--version"],
98                                stdout = subprocess.PIPE,
99                                stderr = subprocess.PIPE,
100                                **kwargs, check=False)
101
102        version = result.stdout.strip()
103
104        match = re.search(r"(\d+\.\d+\.\d+)", version)
105        if match:
106            return PythonVersion.parse_version(match.group(1))
107
108        print(f"Can't parse version {version}")
109        return (0, 0, 0)
110
111    @staticmethod
112    def find_python(min_version):
113        """
114        Detect if are out there any python 3.xy version newer than the
115        current one.
116
117        Note: this routine is limited to up to 2 digits for python3. We
118        may need to update it one day, hopefully on a distant future.
119        """
120        patterns = [
121            "python3.[0-9][0-9]",
122            "python3.[0-9]",
123        ]
124
125        python_cmd = []
126
127        # Seek for a python binary newer than min_version
128        for path in os.getenv("PATH", "").split(":"):
129            for pattern in patterns:
130                for cmd in glob(os.path.join(path, pattern)):
131                    if os.path.isfile(cmd) and os.access(cmd, os.X_OK):
132                        version = PythonVersion.get_python_version(cmd)
133                        if version >= min_version:
134                            python_cmd.append((version, cmd))
135
136        return sorted(python_cmd, reverse=True)
137
138    @staticmethod
139    def check_python(min_version, show_alternatives=False, bail_out=False,
140                     success_on_error=False):
141        """
142        Check if the current python binary satisfies our minimal requirement
143        for Sphinx build. If not, re-run with a newer version if found.
144        """
145        cur_ver = sys.version_info[:3]
146        if cur_ver >= min_version:
147            ver = PythonVersion.ver_str(cur_ver)
148            return
149
150        python_ver = PythonVersion.ver_str(cur_ver)
151
152        available_versions = PythonVersion.find_python(min_version)
153        if not available_versions:
154            print(f"ERROR: Python version {python_ver} is not supported anymore\n")
155            print("       Can't find a new version. This script may fail")
156            return
157
158        script_path = os.path.abspath(sys.argv[0])
159
160        # Check possible alternatives
161        if available_versions:
162            new_python_cmd = available_versions[0][1]
163        else:
164            new_python_cmd = None
165
166        if show_alternatives and available_versions:
167            print("You could run, instead:")
168            for _, cmd in available_versions:
169                args = [cmd, script_path] + sys.argv[1:]
170
171                cmd_str = indent(PythonVersion.cmd_print(args), "  ")
172                print(f"{cmd_str}\n")
173
174        if bail_out:
175            msg = f"Python {python_ver} not supported. Bailing out"
176            if success_on_error:
177                print(msg, file=sys.stderr)
178                sys.exit(0)
179            else:
180                sys.exit(msg)
181
182        print(f"Python {python_ver} not supported. Changing to {new_python_cmd}")
183
184        # Restart script using the newer version
185        args = [new_python_cmd, script_path] + sys.argv[1:]
186
187        try:
188            os.execv(new_python_cmd, args)
189        except OSError as e:
190            sys.exit(f"Failed to restart with {new_python_cmd}: {e}")
191