xref: /aosp_15_r20/external/pigweed/pw_build/py/pw_build/pip_install_python_deps.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1# Copyright 2022 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Pip install Pigweed Python packages."""
15
16import argparse
17import logging
18from pathlib import Path
19import subprocess
20import shlex
21import sys
22
23try:
24    from pw_build.python_package import load_packages
25except ImportError:
26    # Load from python_package from this directory if pw_build is not available.
27    from python_package import load_packages  # type: ignore
28
29_LOG = logging.getLogger('pw_build.pip_install_python_deps')
30
31
32def _parse_args() -> tuple[argparse.Namespace, list[str]]:
33    parser = argparse.ArgumentParser(description=__doc__)
34    parser.add_argument(
35        '--python-dep-list-files',
36        type=Path,
37        required=True,
38        help=(
39            'Path to a text file containing the list of Python package '
40            'metadata json files.'
41        ),
42    )
43    parser.add_argument(
44        '--gn-packages',
45        required=True,
46        help=(
47            'Comma separated list of GN python package ' 'targets to install.'
48        ),
49    )
50    parser.add_argument(
51        '--editable-pip-install',
52        action='store_true',
53        help=(
54            'If true run the pip install command with the '
55            '\'--editable\' option.'
56        ),
57    )
58    return parser.parse_known_args()
59
60
61class NoMatchingGnPythonDependency(Exception):
62    """An error occurred while processing a Python dependency."""
63
64
65def main(
66    python_dep_list_files: Path,
67    editable_pip_install: bool,
68    gn_targets: list[str],
69    pip_args: list[str],
70) -> int:
71    """Find matching python packages to pip install.
72
73    Raises:
74      NoMatchingGnPythonDependency: if a given gn_target is missing.
75      FileNotFoundError: if a Python wheel was not found when using pip install
76          with --require-hashes.
77    """
78    pip_target_dirs: list[Path] = []
79
80    py_packages = load_packages([python_dep_list_files], ignore_missing=True)
81    for pkg in py_packages:
82        valid_target = [target in pkg.gn_target_name for target in gn_targets]
83        if not any(valid_target):
84            continue
85        top_level_source_dir = pkg.package_dir
86        pip_target_dirs.append(top_level_source_dir.parent)
87
88    if not pip_target_dirs:
89        raise NoMatchingGnPythonDependency(
90            'No matching GN Python dependency found to install.\n'
91            'GN Targets to pip install:\n' + '\n'.join(gn_targets) + '\n\n'
92            'Declared Python Dependencies:\n'
93            + '\n'.join(pkg.gn_target_name for pkg in py_packages)
94            + '\n\n'
95        )
96
97    for target in pip_target_dirs:
98        command_args = [sys.executable, "-m", "pip"]
99        command_args += pip_args
100        if editable_pip_install:
101            command_args.append('--editable')
102
103        if '--require-hashes' in pip_args:
104            build_wheel_path = target.with_suffix('._build_wheel')
105            req_file = build_wheel_path / 'requirements.txt'
106            req_file_str = str(req_file.resolve())
107            if not req_file.exists():
108                raise FileNotFoundError(
109                    'Missing Python wheel requirement file: ' + req_file_str
110                )
111            # Install the wheel requirement file
112            command_args.extend(['--requirement', req_file_str])
113            # Add the wheel dir to --find-links
114            command_args.extend(
115                ['--find-links', str(build_wheel_path.resolve())]
116            )
117            # Switch any constraint files to requirements. Hashes seem to be
118            # ignored in constraint files.
119            command_args = list(
120                '--requirement' if arg == '--constraint' else arg
121                for arg in command_args
122            )
123
124        else:
125            # Pass the target along to pip with no modifications.
126            command_args.append(str(target.resolve()))
127
128        quoted_command_args = ' '.join(shlex.quote(arg) for arg in command_args)
129        _LOG.info('Run ==> %s', quoted_command_args)
130
131        process = subprocess.run(
132            command_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
133        )
134        pip_output = process.stdout.decode()
135        if process.returncode != 0:
136            print(pip_output)
137            return process.returncode
138    return 0
139
140
141if __name__ == '__main__':
142    logging.basicConfig(format='%(message)s', level=logging.DEBUG)
143
144    # Parse this script's args and pass any remaining args to pip.
145    argparse_args, remaining_args_for_pip = _parse_args()
146
147    # Split the comma separated string and remove leading slashes.
148    gn_target_names = [
149        target.lstrip('/')
150        for target in argparse_args.gn_packages.split(',')
151        if target  # The last target may be an empty string.
152    ]
153
154    result = main(
155        python_dep_list_files=argparse_args.python_dep_list_files,
156        editable_pip_install=argparse_args.editable_pip_install,
157        gn_targets=gn_target_names,
158        pip_args=remaining_args_for_pip,
159    )
160    sys.exit(result)
161