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