xref: /aosp_15_r20/external/pigweed/pw_build/py/pw_build/python_runner.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"""Script that preprocesses a Python command then runs it.
15
16This script evaluates expressions in the Python command's arguments then invokes
17the command.
18"""
19
20import argparse
21import atexit
22import json
23import logging
24import os
25from pathlib import Path
26import platform
27import shlex
28import subprocess
29import sys
30import time
31
32try:
33    from pw_build import gn_resolver
34    from pw_build.python_package import load_packages
35except (ImportError, ModuleNotFoundError):
36    # Load from python_package from this directory if pw_build is not available.
37    from python_package import load_packages  # type: ignore
38    import gn_resolver  # type: ignore
39
40if sys.platform != 'win32':
41    import fcntl  # pylint: disable=import-error
42
43    # TODO: b/227670947 - Support Windows.
44
45_LOG = logging.getLogger(__name__)
46_LOCK_ACQUISITION_TIMEOUT = 30 * 60  # 30 minutes in seconds
47
48# TODO(frolv): Remove these aliases once downstream projects are migrated.
49GnPaths = gn_resolver.GnPaths
50expand_expressions = gn_resolver.expand_expressions
51
52
53def _parse_args() -> argparse.Namespace:
54    """Parses arguments for this script, splitting out the command to run."""
55
56    parser = argparse.ArgumentParser(description=__doc__)
57    parser.add_argument(
58        '--gn-root',
59        type=Path,
60        required=True,
61        help=(
62            'Path to the root of the GN tree; '
63            'value of rebase_path("//", root_build_dir)'
64        ),
65    )
66    parser.add_argument(
67        '--current-path',
68        type=Path,
69        required=True,
70        help='Value of rebase_path(".", root_build_dir)',
71    )
72    parser.add_argument(
73        '--default-toolchain', required=True, help='Value of default_toolchain'
74    )
75    parser.add_argument(
76        '--current-toolchain', required=True, help='Value of current_toolchain'
77    )
78    parser.add_argument('--module', help='Run this module instead of a script')
79    parser.add_argument(
80        '--env',
81        action='append',
82        help='Environment variables to set as NAME=VALUE',
83    )
84    parser.add_argument(
85        '--touch',
86        type=Path,
87        help='File to touch after the command is run',
88    )
89    parser.add_argument(
90        '--capture-output',
91        action='store_true',
92        help='Capture subcommand output; display only on error',
93    )
94    parser.add_argument(
95        '--working-directory',
96        type=Path,
97        help='Change to this working directory before running the subcommand',
98    )
99    parser.add_argument(
100        '--python-dep-list-files',
101        nargs='+',
102        type=Path,
103        help='Paths to text files containing lists of Python package metadata '
104        'json files.',
105    )
106    parser.add_argument(
107        '--python-virtualenv-config',
108        type=Path,
109        help='Path to a virtualenv json config to use for this action.',
110    )
111    parser.add_argument(
112        '--command-launcher', help='Arguments to prepend to Python command'
113    )
114    parser.add_argument(
115        'original_cmd',
116        nargs=argparse.REMAINDER,
117        help='Python script with arguments to run',
118    )
119    parser.add_argument(
120        '--lockfile',
121        type=Path,
122        help=(
123            'Path to a pip lockfile. Any pip execution will acquire an '
124            'exclusive lock on it, any other module a shared lock.'
125        ),
126    )
127    return parser.parse_args()
128
129
130class LockAcquisitionTimeoutError(Exception):
131    """Raised on a timeout."""
132
133
134def acquire_lock(lockfile: Path, exclusive: bool):
135    """Attempts to acquire the lock.
136
137    Args:
138      lockfile: pathlib.Path to the lock.
139      exclusive: whether this needs to be an exclusive lock.
140
141    Raises:
142      LockAcquisitionTimeoutError: If the lock is not acquired after a
143        reasonable time.
144    """
145    if sys.platform == 'win32':
146        # No-op on Windows, which doesn't have POSIX file locking.
147        # TODO: b/227670947 - Get this working on Windows, too.
148        return
149
150    start_time = time.monotonic()
151    if exclusive:
152        # pylint: disable-next=used-before-assignment
153        lock_type = fcntl.LOCK_EX  # type: ignore[name-defined]
154    else:
155        # pylint: disable-next=used-before-assignment
156        lock_type = fcntl.LOCK_SH  # type: ignore[name-defined]
157    fd = os.open(lockfile, os.O_RDWR | os.O_CREAT)
158
159    # Make sure we close the file when the process exits. If we manage to
160    # acquire the lock below, closing the file will release it.
161    def cleanup():
162        os.close(fd)
163
164    atexit.register(cleanup)
165
166    backoff = 1
167    while time.monotonic() - start_time < _LOCK_ACQUISITION_TIMEOUT:
168        try:
169            # pylint: disable=used-before-assignment
170            fcntl.flock(  # type: ignore[name-defined]
171                fd, lock_type | fcntl.LOCK_NB  # type: ignore[name-defined]
172            )
173            # pylint: enable=used-before-assignment
174            return  # Lock acquired!
175        except BlockingIOError:
176            pass  # Keep waiting.
177
178        time.sleep(backoff * 0.05)
179        backoff += 1
180
181    raise LockAcquisitionTimeoutError(
182        f"Failed to acquire lock {lockfile} in {_LOCK_ACQUISITION_TIMEOUT}"
183    )
184
185
186class MissingPythonDependency(Exception):
187    """An error occurred while processing a Python dependency."""
188
189
190def _load_virtualenv_config(json_file_path: Path) -> tuple[str, str]:
191    with json_file_path.open() as json_fp:
192        json_dict = json.load(json_fp)
193    return json_dict.get('interpreter'), json_dict.get('path')
194
195
196def main(  # pylint: disable=too-many-arguments,too-many-branches,too-many-locals
197    gn_root: Path,
198    current_path: Path,
199    original_cmd: list[str],
200    default_toolchain: str,
201    current_toolchain: str,
202    module: str | None,
203    env: list[str] | None,
204    python_dep_list_files: list[Path],
205    python_virtualenv_config: Path | None,
206    capture_output: bool,
207    touch: Path | None,
208    working_directory: Path | None,
209    command_launcher: str | None,
210    lockfile: Path | None,
211) -> int:
212    """Script entry point."""
213
214    python_paths_list = []
215    if python_dep_list_files:
216        py_packages = load_packages(
217            python_dep_list_files,
218            # If this python_action has no gn python_deps this file will be
219            # empty.
220            ignore_missing=True,
221        )
222
223        for pkg in py_packages:
224            top_level_source_dir = pkg.package_dir
225            if not top_level_source_dir:
226                raise MissingPythonDependency(
227                    'Unable to find top level source dir for the Python '
228                    f'package "{pkg}"'
229                )
230            # Don't add this dir to the PYTHONPATH if no __init__.py exists.
231            init_py_files = top_level_source_dir.parent.glob('*/__init__.py')
232            if not any(init_py_files):
233                continue
234            python_paths_list.append(
235                gn_resolver.abspath(top_level_source_dir.parent)
236            )
237
238        # Sort the PYTHONPATH list, it will be in a different order each build.
239        python_paths_list = sorted(python_paths_list)
240
241    if not original_cmd or original_cmd[0] != '--':
242        _LOG.error('%s requires a command to run', sys.argv[0])
243        return 1
244
245    # GN build scripts are executed from the root build directory.
246    root_build_dir = gn_resolver.abspath(Path.cwd())
247
248    tool = current_toolchain if current_toolchain != default_toolchain else ''
249    paths = gn_resolver.GnPaths(
250        root=gn_resolver.abspath(gn_root),
251        build=root_build_dir,
252        cwd=gn_resolver.abspath(current_path),
253        toolchain=tool,
254    )
255
256    command = [sys.executable]
257
258    python_interpreter = None
259    python_virtualenv = None
260    if python_virtualenv_config:
261        python_interpreter, python_virtualenv = _load_virtualenv_config(
262            python_virtualenv_config
263        )
264
265    if python_interpreter is not None:
266        command = [str(root_build_dir / python_interpreter)]
267
268    if command_launcher is not None:
269        command = shlex.split(command_launcher) + command
270
271    if module is not None:
272        command += ['-m', module]
273
274    run_args: dict = dict()
275    # Always inherit the environtment by default. If PYTHONPATH or VIRTUALENV is
276    # set below then the environment vars must be copied in or subprocess.run
277    # will run with only the new updated variables.
278    run_args['env'] = os.environ.copy()
279
280    if env is not None:
281        environment = os.environ.copy()
282        environment.update((k, v) for k, v in (a.split('=', 1) for a in env))
283        run_args['env'] = environment
284
285    script_command = original_cmd[0]
286    if script_command == '--':
287        script_command = original_cmd[1]
288
289    is_pip_command = (
290        module == 'pip' or 'pip_install_python_deps.py' in script_command
291    )
292
293    existing_env = run_args['env'] if 'env' in run_args else os.environ.copy()
294    new_env = {}
295    if python_virtualenv:
296        new_env['VIRTUAL_ENV'] = str(root_build_dir / python_virtualenv)
297        bin_folder = 'Scripts' if platform.system() == 'Windows' else 'bin'
298        new_env['PATH'] = os.pathsep.join(
299            [
300                str(root_build_dir / python_virtualenv / bin_folder),
301                existing_env.get('PATH', ''),
302            ]
303        )
304
305    if python_virtualenv and python_paths_list and not is_pip_command:
306        python_path_prepend = os.pathsep.join(
307            str(p) for p in set(python_paths_list)
308        )
309
310        # Append the existing PYTHONPATH to the new one.
311        new_python_path = os.pathsep.join(
312            path_str
313            for path_str in [
314                python_path_prepend,
315                existing_env.get('PYTHONPATH', ''),
316            ]
317            if path_str
318        )
319
320        new_env['PYTHONPATH'] = new_python_path
321
322    if 'env' not in run_args:
323        run_args['env'] = {}
324    run_args['env'].update(new_env)
325
326    if capture_output:
327        # Combine stdout and stderr so that error messages are correctly
328        # interleaved with the rest of the output.
329        run_args['stdout'] = subprocess.PIPE
330        run_args['stderr'] = subprocess.STDOUT
331
332    # Build the command to run.
333    try:
334        for arg in original_cmd[1:]:
335            command += gn_resolver.expand_expressions(paths, arg)
336    except gn_resolver.ExpressionError as err:
337        _LOG.error('%s: %s', sys.argv[0], err)
338        return 1
339
340    if working_directory:
341        run_args['cwd'] = working_directory
342
343    # TODO: b/235239674 - Deprecate the --lockfile option as part of the Python
344    # GN template refactor.
345    if lockfile:
346        try:
347            acquire_lock(lockfile, is_pip_command)
348        except LockAcquisitionTimeoutError as exception:
349            _LOG.error('%s', exception)
350            return 1
351
352    _LOG.debug('RUN %s', ' '.join(shlex.quote(arg) for arg in command))
353
354    completed_process = subprocess.run(command, **run_args)
355
356    if completed_process.returncode != 0:
357        _LOG.debug(
358            'Command failed; exit code: %d', completed_process.returncode
359        )
360        if capture_output:
361            sys.stdout.buffer.write(completed_process.stdout)
362    elif touch:
363        # If a stamp file is provided and the command executed successfully,
364        # touch the stamp file to indicate a successful run of the command.
365        touch = touch.resolve()
366        _LOG.debug('TOUCH %s', touch)
367
368        # Create the parent directory in case GN / Ninja hasn't created it.
369        touch.parent.mkdir(parents=True, exist_ok=True)
370        touch.touch()
371
372    return completed_process.returncode
373
374
375if __name__ == '__main__':
376    sys.exit(main(**vars(_parse_args())))
377