blob: 157abf6df7c5ee22666fe9553a6c7b1ecbb0b6d6 [file] [edit]
#!/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())