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"""Generate a requirements.txt for a pw_python_venv.""" 15 16import argparse 17import configparser 18from pathlib import Path 19import sys 20 21try: 22 from pw_build.python_package import load_packages 23 from pw_build.create_python_tree import update_config_with_packages 24except ImportError: 25 # Load from python_package from this directory if pw_build is not available. 26 from python_package import load_packages # type: ignore 27 from create_python_tree import update_config_with_packages # type: ignore 28 29 30def _parse_args() -> argparse.Namespace: 31 parser = argparse.ArgumentParser(description=__doc__) 32 parser.add_argument( 33 '--python-dep-list-files', 34 type=Path, 35 required=True, 36 help=( 37 'Path to a text file containing the list of Python package ' 38 'metadata json files.' 39 ), 40 ) 41 parser.add_argument( 42 '--gn-root-build-dir', 43 type=Path, 44 required=True, 45 help='Path to the root gn build dir.', 46 ) 47 parser.add_argument( 48 '--output-requirement-file', 49 type=Path, 50 required=True, 51 help='requirement file to generate', 52 ) 53 parser.add_argument( 54 '--gn-packages', 55 required=True, 56 help=( 57 'Comma separated list of GN python package ' 58 'targets to check for requirements.' 59 ), 60 ) 61 parser.add_argument( 62 '--exclude-transitive-deps', 63 action='store_true', 64 help='Exclude checking transitive deps of the specified --gn-packages', 65 ) 66 parser.add_argument( 67 '--constraint-files', 68 nargs='*', 69 type=Path, 70 help='Optional constraint files to include as requirements.', 71 ) 72 return parser.parse_args() 73 74 75class NoMatchingGnPythonDependency(Exception): 76 """An error occurred while processing a Python dependency.""" 77 78 79def main( 80 python_dep_list_files: Path, 81 gn_root_build_dir: Path, 82 output_requirement_file: Path, 83 constraint_files: list[Path], 84 gn_packages: str, 85 exclude_transitive_deps: bool, 86) -> int: 87 """Check Python package setup.cfg correctness.""" 88 89 # Split the comma separated string and remove leading slashes. 90 gn_target_names = [ 91 target.lstrip('/') 92 for target in gn_packages.split(',') 93 if target # The last target may be an empty string. 94 ] 95 for i, gn_target in enumerate(gn_target_names): 96 # Remove metadata subtarget if present. 97 python_package_target = gn_target.replace('._package_metadata(', '(', 1) 98 # Split on the first paren to ignore the toolchain. 99 gn_target_names[i] = python_package_target.split('(')[0] 100 101 py_packages = load_packages([python_dep_list_files], ignore_missing=False) 102 103 target_py_packages = py_packages 104 if exclude_transitive_deps: 105 target_py_packages = [] 106 for pkg in py_packages: 107 valid_target = [ 108 target in pkg.gn_target_name for target in gn_target_names 109 ] 110 if not any(valid_target): 111 continue 112 target_py_packages.append(pkg) 113 114 if not target_py_packages: 115 gn_targets_to_include = '\n'.join(gn_target_names) 116 declared_py_deps = '\n'.join(pkg.gn_target_name for pkg in py_packages) 117 raise NoMatchingGnPythonDependency( 118 'No matching GN Python dependency found.\n' 119 'GN Targets to include:\n' 120 f'{gn_targets_to_include}\n\n' 121 'Declared Python Dependencies:\n' 122 f'{declared_py_deps}\n\n' 123 ) 124 125 config = configparser.ConfigParser() 126 config['options'] = {} 127 update_config_with_packages( 128 config=config, python_packages=target_py_packages 129 ) 130 131 output = ( 132 '# Auto-generated requirements.txt from the following packages:\n#\n' 133 ) 134 output += '\n'.join( 135 '# ' + pkg.gn_target_name 136 for pkg in sorted( 137 target_py_packages, key=lambda pkg: pkg.gn_target_name 138 ) 139 ) 140 output += '\n\n' 141 output += '# Constraint files:\n' 142 143 for constraint_file in constraint_files: 144 parent_count = len( 145 output_requirement_file.parent.absolute() 146 .relative_to(gn_root_build_dir.absolute()) 147 .parents 148 ) 149 150 relative_constraint_path = Path('../' * parent_count) / constraint_file 151 152 # NOTE: Must use as_posix() here or backslash paths will appear in the 153 # generated requirements.txt file on Windows. 154 output += f'-c {relative_constraint_path.as_posix()}\n' 155 156 output += config['options']['install_requires'] 157 output += '\n' 158 output_requirement_file.write_text(output) 159 160 return 0 161 162 163if __name__ == '__main__': 164 sys.exit(main(**vars(_parse_args()))) 165