xref: /aosp_15_r20/external/pigweed/pw_build/py/pw_build/generate_python_requirements.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"""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