summaryrefslogtreecommitdiff
path: root/tools/lib/python/kdoc/python_version.py
blob: e83088013db2ee3940086a73924e338cac0f545e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (c) 2017-2025 Mauro Carvalho Chehab <mchehab+huawei@kernel.org>

"""
Handle Python version check logic.

Not all Python versions are supported by scripts. Yet, on some cases,
like during documentation build, a newer version of python could be
available.

This class allows checking if the minimal requirements are followed.

Better than that, PythonVersion.check_python() not only checks the minimal
requirements, but it automatically switches to a the newest available
Python version if present.

"""

import os
import re
import subprocess
import shlex
import sys

from glob import glob
from textwrap import indent

class PythonVersion:
    """
    Ancillary methods that checks for missing dependencies for different
    types of types, like binaries, python modules, rpm deps, etc.
    """

    def __init__(self, version):
        """Ïnitialize self.version tuple from a version string"""
        self.version = self.parse_version(version)

    @staticmethod
    def parse_version(version):
        """Convert a major.minor.patch version into a tuple"""
        return tuple(int(x) for x in version.split("."))

    @staticmethod
    def ver_str(version):
        """Returns a version tuple as major.minor.patch"""
        return ".".join([str(x) for x in version])

    @staticmethod
    def cmd_print(cmd, max_len=80):
        cmd_line = []

        for w in cmd:
            w = shlex.quote(w)

            if cmd_line:
                if not max_len or len(cmd_line[-1]) + len(w) < max_len:
                    cmd_line[-1] += " " + w
                    continue
                else:
                    cmd_line[-1] += " \\"
                    cmd_line.append(w)
            else:
                cmd_line.append(w)

        return "\n  ".join(cmd_line)

    def __str__(self):
        """Returns a version tuple as major.minor.patch from self.version"""
        return self.ver_str(self.version)

    @staticmethod
    def get_python_version(cmd):
        """
        Get python version from a Python binary. As we need to detect if
        are out there newer python binaries, we can't rely on sys.release here.
        """

        kwargs = {}
        if sys.version_info < (3, 7):
            kwargs['universal_newlines'] = True
        else:
            kwargs['text'] = True

        result = subprocess.run([cmd, "--version"],
                                stdout = subprocess.PIPE,
                                stderr = subprocess.PIPE,
                                **kwargs, check=False)

        version = result.stdout.strip()

        match = re.search(r"(\d+\.\d+\.\d+)", version)
        if match:
            return PythonVersion.parse_version(match.group(1))

        print(f"Can't parse version {version}")
        return (0, 0, 0)

    @staticmethod
    def find_python(min_version):
        """
        Detect if are out there any python 3.xy version newer than the
        current one.

        Note: this routine is limited to up to 2 digits for python3. We
        may need to update it one day, hopefully on a distant future.
        """
        patterns = [
            "python3.[0-9][0-9]",
            "python3.[0-9]",
        ]

        python_cmd = []

        # Seek for a python binary newer than min_version
        for path in os.getenv("PATH", "").split(":"):
            for pattern in patterns:
                for cmd in glob(os.path.join(path, pattern)):
                    if os.path.isfile(cmd) and os.access(cmd, os.X_OK):
                        version = PythonVersion.get_python_version(cmd)
                        if version >= min_version:
                            python_cmd.append((version, cmd))

        return sorted(python_cmd, reverse=True)

    @staticmethod
    def check_python(min_version, show_alternatives=False, bail_out=False,
                     success_on_error=False):
        """
        Check if the current python binary satisfies our minimal requirement
        for Sphinx build. If not, re-run with a newer version if found.
        """
        cur_ver = sys.version_info[:3]
        if cur_ver >= min_version:
            ver = PythonVersion.ver_str(cur_ver)
            return

        python_ver = PythonVersion.ver_str(cur_ver)

        available_versions = PythonVersion.find_python(min_version)
        if not available_versions:
            print(f"ERROR: Python version {python_ver} is not supported anymore\n")
            print("       Can't find a new version. This script may fail")
            return

        script_path = os.path.abspath(sys.argv[0])

        # Check possible alternatives
        if available_versions:
            new_python_cmd = available_versions[0][1]
        else:
            new_python_cmd = None

        if show_alternatives and available_versions:
            print("You could run, instead:")
            for _, cmd in available_versions:
                args = [cmd, script_path] + sys.argv[1:]

                cmd_str = indent(PythonVersion.cmd_print(args), "  ")
                print(f"{cmd_str}\n")

        if bail_out:
            msg = f"Python {python_ver} not supported. Bailing out"
            if success_on_error:
                print(msg, file=sys.stderr)
                sys.exit(0)
            else:
                sys.exit(msg)

        print(f"Python {python_ver} not supported. Changing to {new_python_cmd}")

        # Restart script using the newer version
        args = [new_python_cmd, script_path] + sys.argv[1:]

        try:
            os.execv(new_python_cmd, args)
        except OSError as e:
            sys.exit(f"Failed to restart with {new_python_cmd}: {e}")