1#!/usr/bin/env python3
2"""Install all the required Python packages, with the minimum Python version.
3"""
4
5# Copyright The Mbed TLS Contributors
6# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
7
8import argparse
9import os
10import re
11import subprocess
12import sys
13import tempfile
14import typing
15
16from typing import List, Optional
17
18import framework_scripts_path # pylint: disable=unused-import
19from mbedtls_framework import typing_util
20
21def pylint_doesn_t_notice_that_certain_types_are_used_in_annotations(
22        _list: List[typing.Any],
23) -> None:
24    pass
25
26
27class Requirements:
28    """Collect and massage Python requirements."""
29
30    def __init__(self) -> None:
31        self.requirements = [] #type: List[str]
32
33    def adjust_requirement(self, req: str) -> str:
34        """Adjust a requirement to the minimum specified version."""
35        # allow inheritance #pylint: disable=no-self-use
36        # If a requirement specifies a minimum version, impose that version.
37        split_req = req.split(';', 1)
38        split_req[0] = re.sub(r'>=|~=', r'==', split_req[0])
39        return ';'.join(split_req)
40
41    def add_file(self, filename: str) -> None:
42        """Add requirements from the specified file.
43
44        This method supports a subset of pip's requirement file syntax:
45        * One requirement specifier per line, which is passed to
46          `adjust_requirement`.
47        * Comments (``#`` at the beginning of the line or after whitespace).
48        * ``-r FILENAME`` to include another file.
49        """
50        for line in open(filename):
51            line = line.strip()
52            line = re.sub(r'(\A|\s+)#.*', r'', line)
53            if not line:
54                continue
55            m = re.match(r'-r\s+', line)
56            if m:
57                nested_file = os.path.join(os.path.dirname(filename),
58                                           line[m.end(0):])
59                self.add_file(nested_file)
60                continue
61            self.requirements.append(self.adjust_requirement(line))
62
63    def write(self, out: typing_util.Writable) -> None:
64        """List the gathered requirements."""
65        for req in self.requirements:
66            out.write(req + '\n')
67
68    def install(
69            self,
70            pip_general_options: Optional[List[str]] = None,
71            pip_install_options: Optional[List[str]] = None,
72    ) -> None:
73        """Call pip to install the requirements."""
74        if pip_general_options is None:
75            pip_general_options = []
76        if pip_install_options is None:
77            pip_install_options = []
78        with tempfile.TemporaryDirectory() as temp_dir:
79            # This is more complicated than it needs to be for the sake
80            # of Windows. Use a temporary file rather than the command line
81            # to avoid quoting issues. Use a temporary directory rather
82            # than NamedTemporaryFile because with a NamedTemporaryFile on
83            # Windows, the subprocess can't open the file because this process
84            # has an exclusive lock on it.
85            req_file_name = os.path.join(temp_dir, 'requirements.txt')
86            with open(req_file_name, 'w') as req_file:
87                self.write(req_file)
88            subprocess.check_call([sys.executable, '-m', 'pip'] +
89                                  pip_general_options +
90                                  ['install'] + pip_install_options +
91                                  ['-r', req_file_name])
92
93DEFAULT_REQUIREMENTS_FILE = 'ci.requirements.txt'
94
95def main() -> None:
96    """Command line entry point."""
97    parser = argparse.ArgumentParser(description=__doc__)
98    parser.add_argument('--no-act', '-n',
99                        action='store_true',
100                        help="Don't act, just print what will be done")
101    parser.add_argument('--pip-install-option',
102                        action='append', dest='pip_install_options',
103                        help="Pass this option to pip install")
104    parser.add_argument('--pip-option',
105                        action='append', dest='pip_general_options',
106                        help="Pass this general option to pip")
107    parser.add_argument('--user',
108                        action='append_const', dest='pip_install_options',
109                        const='--user',
110                        help="Install to the Python user install directory"
111                             " (short for --pip-install-option --user)")
112    parser.add_argument('files', nargs='*', metavar='FILE',
113                        help="Requirement files"
114                             " (default: {} in the script's directory)" \
115                             .format(DEFAULT_REQUIREMENTS_FILE))
116    options = parser.parse_args()
117    if not options.files:
118        options.files = [os.path.join(os.path.dirname(__file__),
119                                      DEFAULT_REQUIREMENTS_FILE)]
120    reqs = Requirements()
121    for filename in options.files:
122        reqs.add_file(filename)
123    reqs.write(sys.stdout)
124    if not options.no_act:
125        reqs.install(pip_general_options=options.pip_general_options,
126                     pip_install_options=options.pip_install_options)
127
128if __name__ == '__main__':
129    main()
130