| #!/usr/bin/env fuchsia-vendored-python |
| # Copyright 2026 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import argparse |
| import collections |
| import configparser |
| import json |
| import os |
| import shutil |
| import subprocess |
| import sys |
| from pathlib import Path |
| |
| from depfile import DepFile |
| |
| |
| def main() -> int: |
| """Builds a Python wheel from GN-provided python_library and C extension metadata. |
| |
| This script reads JSON metadata generated by GN describing Python library sources, |
| shared libraries, and data files. It stages these files into a temporary directory, |
| generates declarative setup.cfg and minimal setup.py files, invokes `pip wheel` to |
| produce the wheel package, and writes a Ninja depfile tracking all input dependencies. |
| """ |
| parser = argparse.ArgumentParser("Builds a Python wheel") |
| parser.add_argument("--target_name", required=True) |
| parser.add_argument("--gen_dir", type=Path, required=True) |
| parser.add_argument( |
| "--library_infos", type=argparse.FileType("r"), required=True |
| ) |
| parser.add_argument("--pyproject_toml", type=Path, required=True) |
| parser.add_argument("--depfile", type=Path, required=True) |
| parser.add_argument("--stamp_file", type=Path, required=True) |
| parser.add_argument("--wheel_file", type=Path, required=True) |
| parser.add_argument("--python_interpreter", required=True) |
| parser.add_argument("--version", default="0.0.1") |
| parser.add_argument("--package_name", required=True) |
| parser.add_argument("--packages", nargs="*", default=[]) |
| args = parser.parse_args() |
| |
| with args.library_infos as f: |
| infos = json.load(f) |
| |
| staged_dir = args.gen_dir / f"{args.target_name}_wheel_staged" |
| if staged_dir.is_dir(): |
| shutil.rmtree(staged_dir) |
| staged_dir.mkdir(parents=True, exist_ok=True) |
| |
| # Copy pyproject.toml to the staging directory. |
| # Note: The wheel build generates a setuptools-based setup.cfg / setup.py, assuming |
| # setuptools (setuptools.build_meta) as the build backend in pyproject.toml. |
| # Other backends (e.g. hatchling, flit) will ignore or conflict with setup.py. |
| shutil.copy2(args.pyproject_toml, staged_dir / "pyproject.toml") |
| |
| inputs: list[str] = [ |
| str(args.pyproject_toml), |
| args.library_infos.name, |
| ] |
| outputs: list[str] = [ |
| str(args.stamp_file), |
| str(args.wheel_file), |
| str(staged_dir / "pyproject.toml"), |
| str(staged_dir / "setup.cfg"), |
| str(staged_dir / "setup.py"), |
| ] |
| packages: list[str] = list(args.packages) |
| package_data: collections.defaultdict[ |
| str, list[str] |
| ] = collections.defaultdict(list) |
| for lib_info in infos: |
| lib_name = lib_info["library_name"] |
| dest_lib_dir = staged_dir / lib_name.replace(".", os.sep) |
| dest_lib_dir.mkdir(parents=True, exist_ok=True) |
| |
| src_root = Path(lib_info["source_root"]) |
| for src in lib_info["sources"]: |
| src_path = src_root / src |
| dest_path = dest_lib_dir / src |
| dest_path.parent.mkdir(parents=True, exist_ok=True) |
| shutil.copy2(src_path, dest_path) |
| inputs.append(str(src_path)) |
| outputs.append(str(dest_path)) |
| |
| if "shared_libs" in lib_info: |
| for shlib in lib_info["shared_libs"]: |
| shlib_path = Path(shlib) |
| dest_path = dest_lib_dir / shlib_path.name |
| shutil.copy2(shlib_path, dest_path) |
| inputs.append(str(shlib_path)) |
| outputs.append(str(dest_path)) |
| package_data[lib_name].append(shlib_path.name) |
| |
| if lib_info.get("data_sources") and lib_info.get("data_package_name"): |
| data_pkg_name = lib_info["data_package_name"] |
| data_pkg_rel_dir = data_pkg_name.replace(".", os.sep) |
| dest_data_dir = dest_lib_dir / data_pkg_rel_dir |
| dest_data_dir.mkdir(parents=True, exist_ok=True) |
| init_file = dest_data_dir / "__init__.py" |
| init_file.touch() |
| outputs.append(str(init_file)) |
| full_data_pkg = f"{lib_name}.{data_pkg_name}" |
| if full_data_pkg not in packages: |
| packages.append(full_data_pkg) |
| for data_src in lib_info["data_sources"]: |
| data_src_path = Path(data_src) |
| dest_path = dest_data_dir / data_src_path.name |
| shutil.copy2(data_src_path, dest_path) |
| inputs.append(str(data_src_path)) |
| outputs.append(str(dest_path)) |
| rel_data_path = ( |
| f"{data_pkg_name.replace('.', '/')}/{data_src_path.name}" |
| ) |
| package_data[lib_name].append(rel_data_path) |
| |
| has_shared_libs = any( |
| f.endswith(".so") for files in package_data.values() for f in files |
| ) |
| |
| # Generate declarative setup.cfg configuration via configparser. |
| config = configparser.ConfigParser() |
| config["metadata"] = { |
| "name": args.package_name, |
| "version": args.version, |
| } |
| config["options"] = {} |
| if packages: |
| config["options"]["packages"] = "\n" + "\n".join(packages) |
| if package_data: |
| config["options.package_data"] = { |
| pkg: "\n" + "\n".join(files) for pkg, files in package_data.items() |
| } |
| with open(staged_dir / "setup.cfg", "w", encoding="utf-8") as f: |
| config.write(f) |
| |
| # Generate minimal setup.py providing the custom Distribution class for binary extensions. |
| setup_py_content = f"""from setuptools import setup |
| from setuptools.dist import Distribution |
| |
| |
| class BinaryDistribution(Distribution): |
| def is_pure(self) -> bool: |
| return {not has_shared_libs} |
| |
| def has_ext_modules(self) -> bool: |
| return {has_shared_libs} |
| |
| |
| setup(distclass=BinaryDistribution) |
| """ |
| with open(staged_dir / "setup.py", "w", encoding="utf-8") as f: |
| f.write(setup_py_content) |
| |
| wheel_out_dir = args.gen_dir / f"{args.target_name}_wheel_out" |
| if wheel_out_dir.is_dir(): |
| shutil.rmtree(wheel_out_dir) |
| wheel_out_dir.mkdir(parents=True, exist_ok=True) |
| |
| cmd_env = os.environ.copy() |
| cmd_env["SETUPTOOLS_SCM_PRETEND_VERSION"] = args.version |
| cmd_env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" |
| # Ensure pip does not implicitly read host/user-level configuration (~/.config/pip/pip.conf). |
| cmd_env["PIP_CONFIG_FILE"] = os.devnull |
| # Ensure bit-for-bit reproducible wheel zip archive timestamps (1980-01-01 00:00:00 UTC). |
| cmd_env["SOURCE_DATE_EPOCH"] = "315532800" |
| try: |
| subprocess.run( |
| [ |
| args.python_interpreter, |
| "-m", |
| "pip", |
| "wheel", |
| "--no-cache-dir", |
| "--no-deps", |
| "--no-index", |
| "--no-build-isolation", |
| "-w", |
| str(wheel_out_dir), |
| str(staged_dir), |
| ], |
| env=cmd_env, |
| capture_output=True, |
| text=True, |
| check=True, |
| ) |
| |
| # Locate the generated wheel and move it to the declared wheel output path. |
| # Note: The wheel filename conforms to the PEP 427 naming convention |
| # ({distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl). |
| wheels = list(wheel_out_dir.glob("*.whl")) |
| if not wheels: |
| print( |
| f"No wheel generated in '{wheel_out_dir}'.", |
| file=sys.stderr, |
| ) |
| return 1 |
| if len(wheels) > 1: |
| print( |
| f"Expected exactly one wheel in '{wheel_out_dir}', found: {[w.name for w in wheels]}", |
| file=sys.stderr, |
| ) |
| return 1 |
| generated_wheel = wheels[0] |
| args.wheel_file.parent.mkdir(parents=True, exist_ok=True) |
| if args.wheel_file.exists() or args.wheel_file.is_symlink(): |
| args.wheel_file.unlink() |
| shutil.move(generated_wheel, args.wheel_file) |
| |
| dep_file = DepFile.from_deps(str(args.stamp_file), sorted(set(inputs))) |
| for out in sorted(set(outputs)): |
| dep_file.add_output(out) |
| args.depfile.parent.mkdir(parents=True, exist_ok=True) |
| with open(args.depfile, "w", encoding="utf-8") as f: |
| dep_file.write_to(f) |
| |
| args.stamp_file.touch() |
| return 0 |
| except subprocess.CalledProcessError as e: |
| print( |
| f"Failed to build wheel (exit code {e.returncode}). Staged files retained at '{staged_dir}' for debugging.", |
| file=sys.stderr, |
| ) |
| if e.stdout: |
| print(e.stdout, file=sys.stderr) |
| if e.stderr: |
| print(e.stderr, file=sys.stderr) |
| return e.returncode |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |