mirror of https://github.com/pyodide/pyodide.git
922 lines
28 KiB
Python
Executable File
922 lines
28 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""
|
|
Builds a Pyodide package.
|
|
"""
|
|
|
|
import argparse
|
|
import cgi
|
|
import fnmatch
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import sysconfig
|
|
import textwrap
|
|
from contextlib import contextmanager
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from textwrap import dedent
|
|
from types import TracebackType
|
|
from typing import Any, Generator, NoReturn, TextIO
|
|
from urllib import request
|
|
|
|
from . import pywasmcross
|
|
from .common import find_matching_wheels
|
|
|
|
|
|
@contextmanager
|
|
def chdir(new_dir: Path) -> Generator[None, None, None]:
|
|
orig_dir = Path.cwd()
|
|
try:
|
|
os.chdir(new_dir)
|
|
yield
|
|
finally:
|
|
os.chdir(orig_dir)
|
|
|
|
|
|
from . import common
|
|
from .io import parse_package_config
|
|
|
|
|
|
def _make_whlfile(*args, owner=None, group=None, **kwargs):
|
|
return shutil._make_zipfile(*args, **kwargs) # type: ignore[attr-defined]
|
|
|
|
|
|
shutil.register_archive_format("whl", _make_whlfile, description="Wheel file")
|
|
shutil.register_unpack_format(
|
|
"whl", [".whl", ".wheel"], shutil._unpack_zipfile, description="Wheel file" # type: ignore[attr-defined]
|
|
)
|
|
|
|
|
|
def exit_with_stdio(result: subprocess.CompletedProcess[str]) -> NoReturn:
|
|
if result.stdout:
|
|
print(" stdout:")
|
|
print(textwrap.indent(result.stdout, " "))
|
|
if result.stderr:
|
|
print(" stderr:")
|
|
print(textwrap.indent(result.stderr, " "))
|
|
raise SystemExit(result.returncode)
|
|
|
|
|
|
class BashRunnerWithSharedEnvironment:
|
|
"""Run multiple bash scripts with persistent environment.
|
|
|
|
Environment is stored to "env" member between runs. This can be updated
|
|
directly to adjust the environment, or read to get variables.
|
|
"""
|
|
|
|
def __init__(self, env=None):
|
|
if env is None:
|
|
env = dict(os.environ)
|
|
|
|
self._reader: TextIO | None
|
|
self._fd_write: int | None
|
|
self.env: dict[str, str] = env
|
|
|
|
def __enter__(self) -> "BashRunnerWithSharedEnvironment":
|
|
fd_read, self._fd_write = os.pipe()
|
|
self._reader = os.fdopen(fd_read, "r")
|
|
return self
|
|
|
|
def run(self, cmd, **opts):
|
|
"""Run a bash script. Any keyword arguments are passed on to subprocess.run."""
|
|
assert self._fd_write is not None
|
|
assert self._reader is not None
|
|
|
|
write_env_pycode = ";".join(
|
|
[
|
|
"import os",
|
|
"import json",
|
|
f'os.write({self._fd_write}, json.dumps(dict(os.environ)).encode() + b"\\n")',
|
|
]
|
|
)
|
|
write_env_shell_cmd = f"{sys.executable} -c '{write_env_pycode}'"
|
|
full_cmd = f"{cmd}\n{write_env_shell_cmd}"
|
|
result = subprocess.run(
|
|
["bash", "-ce", full_cmd], pass_fds=[self._fd_write], env=self.env, **opts
|
|
)
|
|
if result.returncode != 0:
|
|
print("ERROR: bash command failed")
|
|
print(textwrap.indent(cmd, " "))
|
|
exit_with_stdio(result)
|
|
|
|
self.env = json.loads(self._reader.readline())
|
|
return result
|
|
|
|
def __exit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc_val: BaseException | None,
|
|
exc_tb: TracebackType | None,
|
|
) -> None:
|
|
"""Free the file descriptors."""
|
|
|
|
if self._fd_write:
|
|
os.close(self._fd_write)
|
|
self._fd_write = None
|
|
if self._reader:
|
|
self._reader.close()
|
|
self._reader = None
|
|
|
|
|
|
@contextmanager
|
|
def get_bash_runner():
|
|
PYODIDE_ROOT = os.environ["PYODIDE_ROOT"]
|
|
env = {
|
|
key: os.environ[key]
|
|
for key in [
|
|
# TODO: Stabilize and document more of these in meta-yaml.md
|
|
"PATH",
|
|
"PYTHONPATH",
|
|
"PYODIDE_ROOT",
|
|
"PYTHONINCLUDE",
|
|
"NUMPY_LIB",
|
|
"PYODIDE_PACKAGE_ABI",
|
|
"HOME",
|
|
"HOSTINSTALLDIR",
|
|
"TARGETINSTALLDIR",
|
|
"SYSCONFIG_NAME",
|
|
"HOSTSITEPACKAGES",
|
|
"PYMAJOR",
|
|
"PYMINOR",
|
|
"PYMICRO",
|
|
"CPYTHONBUILD",
|
|
"SIDE_MODULE_CFLAGS",
|
|
"SIDE_MODULE_LDFLAGS",
|
|
"STDLIB_MODULE_CFLAGS",
|
|
"UNISOLATED_PACKAGES",
|
|
"WASM_LIBRARY_DIR",
|
|
"WASM_PKG_CONFIG_PATH",
|
|
"CARGO_BUILD_TARGET",
|
|
"CARGO_HOME",
|
|
"CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_LINKER",
|
|
"RUSTFLAGS",
|
|
"PYO3_CONFIG_FILE",
|
|
]
|
|
} | {"PYODIDE": "1"}
|
|
if "PYODIDE_JOBS" in os.environ:
|
|
env["PYODIDE_JOBS"] = os.environ["PYODIDE_JOBS"]
|
|
|
|
env["PKG_CONFIG_PATH"] = env["WASM_PKG_CONFIG_PATH"]
|
|
if "PKG_CONFIG_PATH" in os.environ:
|
|
env["PKG_CONFIG_PATH"] += f":{os.environ['PKG_CONFIG_PATH']}"
|
|
|
|
with BashRunnerWithSharedEnvironment(env=env) as b:
|
|
b.run(
|
|
f"source {PYODIDE_ROOT}/emsdk/emsdk/emsdk_env.sh", stderr=subprocess.DEVNULL
|
|
)
|
|
yield b
|
|
|
|
|
|
def check_checksum(archive: Path, source_metadata: dict[str, Any]) -> None:
|
|
"""
|
|
Checks that an archive matches the checksum in the package metadata.
|
|
|
|
|
|
Parameters
|
|
----------
|
|
archive
|
|
the path to the archive we wish to checksum
|
|
source_metadata
|
|
The source section from meta.yaml.
|
|
"""
|
|
checksum_keys = {"md5", "sha256"}.intersection(source_metadata)
|
|
if not checksum_keys:
|
|
return
|
|
elif len(checksum_keys) != 1:
|
|
raise ValueError(
|
|
"Only one checksum should be included in a package "
|
|
"setup; found {}.".format(checksum_keys)
|
|
)
|
|
checksum_algorithm = checksum_keys.pop()
|
|
checksum = source_metadata[checksum_algorithm]
|
|
CHUNK_SIZE = 1 << 16
|
|
h = getattr(hashlib, checksum_algorithm)()
|
|
with open(archive, "rb") as fd:
|
|
while True:
|
|
chunk = fd.read(CHUNK_SIZE)
|
|
h.update(chunk)
|
|
if len(chunk) < CHUNK_SIZE:
|
|
break
|
|
if h.hexdigest() != checksum:
|
|
raise ValueError(f"Invalid {checksum_algorithm} checksum")
|
|
|
|
|
|
def trim_archive_extension(tarballname):
|
|
for extension in [
|
|
".tar.gz",
|
|
".tgz",
|
|
".tar",
|
|
".tar.bz2",
|
|
".tbz2",
|
|
".tar.xz",
|
|
".txz",
|
|
".zip",
|
|
".whl",
|
|
]:
|
|
if tarballname.endswith(extension):
|
|
return tarballname[: -len(extension)]
|
|
return tarballname
|
|
|
|
|
|
def download_and_extract(
|
|
buildpath: Path, srcpath: Path, src_metadata: dict[str, Any]
|
|
) -> None:
|
|
"""
|
|
Download the source from specified in the meta data, then checksum it, then
|
|
extract the archive into srcpath.
|
|
|
|
Parameters
|
|
----------
|
|
|
|
buildpath
|
|
The path to the build directory. Generally will be
|
|
$(PYOIDE_ROOT)/packages/<package-name>/build/.
|
|
|
|
srcpath
|
|
The place we want the source to end up. Will generally be
|
|
$(PYOIDE_ROOT)/packages/<package-name>/build/<package-name>-<package-version>.
|
|
|
|
src_metadata
|
|
The source section from meta.yaml.
|
|
"""
|
|
response = request.urlopen(src_metadata["url"])
|
|
_, parameters = cgi.parse_header(response.headers.get("Content-Disposition", ""))
|
|
if "filename" in parameters:
|
|
tarballname = parameters["filename"]
|
|
else:
|
|
tarballname = Path(response.geturl()).name
|
|
|
|
tarballpath = buildpath / tarballname
|
|
if not tarballpath.is_file():
|
|
os.makedirs(tarballpath.parent, exist_ok=True)
|
|
with open(tarballpath, "wb") as f:
|
|
f.write(response.read())
|
|
try:
|
|
check_checksum(tarballpath, src_metadata)
|
|
except Exception:
|
|
tarballpath.unlink()
|
|
raise
|
|
|
|
if tarballpath.suffix == ".whl":
|
|
os.makedirs(srcpath / "dist")
|
|
shutil.copy(tarballpath, srcpath / "dist")
|
|
return
|
|
|
|
if not srcpath.is_dir():
|
|
shutil.unpack_archive(tarballpath, buildpath)
|
|
|
|
extract_dir_name = src_metadata.get("extract_dir")
|
|
if not extract_dir_name:
|
|
extract_dir_name = trim_archive_extension(tarballname)
|
|
|
|
shutil.move(buildpath / extract_dir_name, srcpath)
|
|
|
|
|
|
def prepare_source(
|
|
pkg_root: Path, buildpath: Path, srcpath: Path, src_metadata: dict[str, Any]
|
|
) -> None:
|
|
"""
|
|
Figure out from the "source" key in the package metadata where to get the source
|
|
from, then get the source into srcpath (or somewhere else, if it goes somewhere
|
|
else, returns where it ended up).
|
|
|
|
Parameters
|
|
----------
|
|
pkg_root
|
|
The path to the root directory for the package. Generally
|
|
$PYODIDE_ROOT/packages/<PACKAGES>
|
|
|
|
buildpath
|
|
The path to the build directory. Generally will be
|
|
$(PYOIDE_ROOT)/packages/<PACKAGE>/build/.
|
|
|
|
srcpath
|
|
The default place we want the source to end up. Will generally be
|
|
$(PYOIDE_ROOT)/packages/<package-name>/build/<package-name>-<package-version>.
|
|
|
|
src_metadata
|
|
The source section from meta.yaml.
|
|
|
|
Returns
|
|
-------
|
|
The location where the source ended up. TODO: None, actually?
|
|
"""
|
|
if buildpath.resolve().is_dir():
|
|
shutil.rmtree(buildpath)
|
|
os.makedirs(buildpath)
|
|
|
|
if "url" in src_metadata:
|
|
download_and_extract(buildpath, srcpath, src_metadata)
|
|
return
|
|
if "path" not in src_metadata:
|
|
raise ValueError(
|
|
"Incorrect source provided. Either a url or a path must be provided."
|
|
)
|
|
|
|
srcdir = Path(src_metadata["path"]).resolve()
|
|
|
|
if not srcdir.is_dir():
|
|
raise ValueError(f"path={srcdir} must point to a directory that exists")
|
|
|
|
shutil.copytree(srcdir, srcpath)
|
|
|
|
|
|
def patch(pkg_root: Path, srcpath: Path, src_metadata: dict[str, Any]) -> None:
|
|
"""
|
|
Apply patches to the source.
|
|
|
|
Parameters
|
|
----------
|
|
pkg_root
|
|
The path to the root directory for the package. Generally
|
|
$PYODIDE_ROOT/packages/<PACKAGES>
|
|
|
|
srcpath
|
|
The path to the source. We extract the source into the build directory, so it
|
|
will be something like
|
|
$(PYOIDE_ROOT)/packages/<PACKAGE>/build/<PACKAGE>-<VERSION>.
|
|
|
|
src_metadata
|
|
The "source" key from meta.yaml.
|
|
"""
|
|
if (srcpath / ".patched").is_file():
|
|
return
|
|
|
|
patches = src_metadata.get("patches", [])
|
|
extras = src_metadata.get("extras", [])
|
|
if not patches and not extras:
|
|
return
|
|
|
|
# We checked these in check_package_config.
|
|
assert "url" in src_metadata
|
|
assert not src_metadata["url"].endswith(".whl")
|
|
|
|
# Apply all the patches
|
|
with chdir(srcpath):
|
|
for patch in patches:
|
|
result = subprocess.run(
|
|
["patch", "-p1", "--binary", "--verbose", "-i", pkg_root / patch],
|
|
check=False,
|
|
encoding="utf-8",
|
|
)
|
|
if result.returncode != 0:
|
|
print(f"ERROR: Patch {pkg_root/patch} failed")
|
|
exit_with_stdio(result)
|
|
|
|
# Add any extra files
|
|
for src, dst in extras:
|
|
shutil.copyfile(pkg_root / src, srcpath / dst)
|
|
|
|
with open(srcpath / ".patched", "wb") as fd:
|
|
fd.write(b"\n")
|
|
|
|
|
|
def unpack_wheel(path):
|
|
with chdir(path.parent):
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "wheel", "unpack", path.name],
|
|
check=False,
|
|
encoding="utf-8",
|
|
)
|
|
if result.returncode != 0:
|
|
print(f"ERROR: Unpacking wheel {path.name} failed")
|
|
exit_with_stdio(result)
|
|
|
|
|
|
def pack_wheel(path):
|
|
with chdir(path.parent):
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "wheel", "pack", path.name],
|
|
check=False,
|
|
encoding="utf-8",
|
|
)
|
|
if result.returncode != 0:
|
|
print(f"ERROR: Packing wheel {path} failed")
|
|
exit_with_stdio(result)
|
|
|
|
|
|
def compile(
|
|
name: str,
|
|
srcpath: Path,
|
|
build_metadata: dict[str, Any],
|
|
bash_runner: BashRunnerWithSharedEnvironment,
|
|
*,
|
|
target_install_dir: str,
|
|
) -> None:
|
|
"""
|
|
Runs pywasmcross for the package. The effect of this is to first run setup.py
|
|
with compiler wrappers subbed in, which don't actually build the package but
|
|
write the compile commands to build.log. Then we walk over build log and invoke
|
|
the same set of commands but with some flags munged around or removed to make it
|
|
work with emcc.
|
|
|
|
In any case, only works for Python packages, not libraries or shared libraries
|
|
which don't have a setup.py.
|
|
|
|
Parameters
|
|
----------
|
|
srcpath
|
|
The path to the source. We extract the source into the build directory, so it
|
|
will be something like
|
|
$(PYOIDE_ROOT)/packages/<PACKAGE>/build/<PACKAGE>-<VERSION>.
|
|
|
|
build_metadata
|
|
The build section from meta.yaml.
|
|
|
|
bash_runner
|
|
The runner we will use to execute our bash commands. Preserves environment
|
|
variables from one invocation to the next.
|
|
|
|
target_install_dir
|
|
The path to the target Python installation
|
|
|
|
"""
|
|
# This function runs setup.py. library and sharedlibrary don't have setup.py
|
|
if build_metadata.get("sharedlibrary"):
|
|
return
|
|
|
|
replace_libs = ";".join(build_metadata.get("replace-libs", []))
|
|
with chdir(srcpath):
|
|
pywasmcross.compile(
|
|
env=bash_runner.env,
|
|
pkgname=name,
|
|
backend_flags=build_metadata["backend-flags"],
|
|
cflags=build_metadata["cflags"],
|
|
cxxflags=build_metadata["cxxflags"],
|
|
ldflags=build_metadata["ldflags"],
|
|
target_install_dir=target_install_dir,
|
|
replace_libs=replace_libs,
|
|
)
|
|
|
|
|
|
def replace_so_abi_tags(wheel_dir: Path) -> None:
|
|
"""Replace native abi tag with emscripten abi tag in .so file names"""
|
|
build_soabi = sysconfig.get_config_var("SOABI")
|
|
assert build_soabi
|
|
ext_suffix = sysconfig.get_config_var("EXT_SUFFIX")
|
|
assert ext_suffix
|
|
build_triplet = "-".join(build_soabi.split("-")[2:])
|
|
host_triplet = common.get_make_flag("PLATFORM_TRIPLET")
|
|
for file in wheel_dir.glob(f"**/*{ext_suffix}"):
|
|
file.rename(file.with_name(file.name.replace(build_triplet, host_triplet)))
|
|
|
|
|
|
def package_wheel(
|
|
pkg_name: str,
|
|
pkg_root: Path,
|
|
srcpath: Path,
|
|
build_metadata: dict[str, Any],
|
|
bash_runner: BashRunnerWithSharedEnvironment,
|
|
host_install_dir: str,
|
|
) -> None:
|
|
"""Package a wheel
|
|
|
|
This unpacks the wheel, unvendors tests if necessary, runs and "build.post"
|
|
script, and then repacks the wheel.
|
|
|
|
Parameters
|
|
----------
|
|
pkg_name
|
|
The name of the package
|
|
|
|
pkg_root
|
|
The path to the root directory for the package. Generally
|
|
$PYODIDE_ROOT/packages/<PACKAGES>
|
|
|
|
srcpath
|
|
The path to the source. We extract the source into the build directory,
|
|
so it will be something like
|
|
$(PYOIDE_ROOT)/packages/<PACKAGE>/build/<PACKAGE>-<VERSION>.
|
|
|
|
build_metadata
|
|
The build section from meta.yaml.
|
|
|
|
bash_runner
|
|
The runner we will use to execute our bash commands. Preserves
|
|
environment variables from one invocation to the next.
|
|
"""
|
|
if build_metadata.get("sharedlibrary"):
|
|
return
|
|
|
|
distdir = srcpath / "dist"
|
|
wheel, *rest = find_matching_wheels(distdir.glob("*.whl"))
|
|
if rest:
|
|
raise Exception(
|
|
f"Unexpected number of wheels {len(rest) + 1} when building {pkg_name}"
|
|
)
|
|
print(f"Unpacking wheel to {str(wheel)}")
|
|
unpack_wheel(wheel)
|
|
wheel.unlink()
|
|
name, ver, _ = wheel.name.split("-", 2)
|
|
wheel_dir_name = f"{name}-{ver}"
|
|
wheel_dir = distdir / wheel_dir_name
|
|
|
|
# update so abi tags after build is complete but before running post script
|
|
# to maximize sanity.
|
|
replace_so_abi_tags(wheel_dir)
|
|
|
|
post = build_metadata.get("post")
|
|
if post:
|
|
print("Running post script in ", str(Path.cwd().absolute()))
|
|
bash_runner.env.update({"PKGDIR": str(pkg_root), "WHEELDIR": str(wheel_dir)})
|
|
result = bash_runner.run(post)
|
|
if result.returncode != 0:
|
|
print("ERROR: post failed")
|
|
exit_with_stdio(result)
|
|
|
|
python_dir = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
|
host_site_packages = Path(host_install_dir) / f"lib/{python_dir}/site-packages"
|
|
if build_metadata.get("cross-build-env"):
|
|
subprocess.check_call(
|
|
["pip", "install", "-t", str(host_site_packages), f"{name}=={ver}"]
|
|
)
|
|
|
|
cross_build_files: list[str] | None = build_metadata.get("cross-build-files")
|
|
if cross_build_files:
|
|
for file in cross_build_files:
|
|
shutil.copy((wheel_dir / file), host_site_packages / file)
|
|
|
|
test_dir = distdir / "tests"
|
|
nmoved = 0
|
|
if build_metadata.get("unvendor-tests", True):
|
|
nmoved = unvendor_tests(wheel_dir, test_dir)
|
|
if nmoved:
|
|
with chdir(distdir):
|
|
shutil.make_archive(f"{pkg_name}-tests", "tar", test_dir)
|
|
pack_wheel(wheel_dir)
|
|
# wheel_dir causes pytest collection failures for in-tree packages like
|
|
# micropip. To prevent these, we get rid of wheel_dir after repacking the
|
|
# wheel.
|
|
shutil.rmtree(wheel_dir)
|
|
shutil.rmtree(test_dir, ignore_errors=True)
|
|
|
|
|
|
def unvendor_tests(install_prefix: Path, test_install_prefix: Path) -> int:
|
|
"""Unvendor test files and folders
|
|
|
|
This function recursively walks through install_prefix and moves anything
|
|
that looks like a test folder under test_install_prefix.
|
|
|
|
|
|
Parameters
|
|
----------
|
|
install_prefix
|
|
the folder where the package was installed
|
|
test_install_prefix
|
|
the folder where to move the tests. If it doesn't exist, it will be
|
|
created.
|
|
|
|
Returns
|
|
-------
|
|
n_moved
|
|
number of files or folders moved
|
|
"""
|
|
n_moved = 0
|
|
out_files = []
|
|
shutil.rmtree(test_install_prefix, ignore_errors=True)
|
|
for root, _dirs, files in os.walk(install_prefix):
|
|
root_rel = Path(root).relative_to(install_prefix)
|
|
if root_rel.name == "__pycache__" or root_rel.name.endswith(".egg_info"):
|
|
continue
|
|
if root_rel.name in ["test", "tests"]:
|
|
# This is a test folder
|
|
(test_install_prefix / root_rel).parent.mkdir(exist_ok=True, parents=True)
|
|
shutil.move(install_prefix / root_rel, test_install_prefix / root_rel)
|
|
n_moved += 1
|
|
continue
|
|
out_files.append(root)
|
|
for fpath in files:
|
|
if (
|
|
fnmatch.fnmatchcase(fpath, "test_*.py")
|
|
or fnmatch.fnmatchcase(fpath, "*_test.py")
|
|
or fpath == "conftest.py"
|
|
):
|
|
(test_install_prefix / root_rel).mkdir(exist_ok=True, parents=True)
|
|
shutil.move(
|
|
install_prefix / root_rel / fpath,
|
|
test_install_prefix / root_rel / fpath,
|
|
)
|
|
n_moved += 1
|
|
|
|
return n_moved
|
|
|
|
|
|
def create_packaged_token(buildpath: Path) -> None:
|
|
(buildpath / ".packaged").write_text("\n")
|
|
|
|
|
|
def run_script(
|
|
buildpath: Path,
|
|
srcpath: Path,
|
|
build_metadata: dict[str, Any],
|
|
bash_runner: BashRunnerWithSharedEnvironment,
|
|
) -> None:
|
|
"""
|
|
Run the build script indicated in meta.yaml
|
|
|
|
Parameters
|
|
----------
|
|
buildpath
|
|
the package build path. Usually `packages/<name>/build`
|
|
|
|
srcpath
|
|
the package source path. Usually
|
|
`packages/<name>/build/<name>-<version>`.
|
|
|
|
build_metadata
|
|
The build section from meta.yaml.
|
|
|
|
bash_runner
|
|
The runner we will use to execute our bash commands. Preserves environment
|
|
variables from one invocation to the next.
|
|
"""
|
|
script = build_metadata.get("script")
|
|
if not script:
|
|
return
|
|
|
|
with chdir(srcpath):
|
|
result = bash_runner.run(script)
|
|
if result.returncode != 0:
|
|
print("ERROR: script failed")
|
|
exit_with_stdio(result)
|
|
|
|
|
|
def needs_rebuild(
|
|
pkg_root: Path, buildpath: Path, source_metadata: dict[str, Any]
|
|
) -> bool:
|
|
"""
|
|
Determines if a package needs a rebuild because its meta.yaml, patches, or
|
|
sources are newer than the `.packaged` thunk.
|
|
|
|
pkg_root
|
|
The path to the root directory for the package. Generally
|
|
$PYODIDE_ROOT/packages/<PACKAGES>
|
|
|
|
buildpath
|
|
The path to the build directory. Generally will be
|
|
$(PYOIDE_ROOT)/packages/<PACKAGE>/build/.
|
|
|
|
src_metadata
|
|
The source section from meta.yaml.
|
|
"""
|
|
packaged_token = buildpath / ".packaged"
|
|
if not packaged_token.is_file():
|
|
return True
|
|
|
|
package_time = packaged_token.stat().st_mtime
|
|
|
|
def source_files():
|
|
yield pkg_root / "meta.yaml"
|
|
yield from (
|
|
pkg_root / patch_path for patch_path in source_metadata.get("patches", [])
|
|
)
|
|
yield from (
|
|
pkg_root / patch_path
|
|
for [patch_path, _] in source_metadata.get("extras", [])
|
|
)
|
|
src_path = source_metadata.get("path")
|
|
if src_path:
|
|
yield from (pkg_root / src_path).resolve().glob("**/*")
|
|
|
|
for source_file in source_files():
|
|
source_file = Path(source_file)
|
|
if source_file.stat().st_mtime > package_time:
|
|
return True
|
|
return False
|
|
|
|
|
|
def build_package(
|
|
pkg_root: Path,
|
|
pkg: dict[str, Any],
|
|
*,
|
|
target_install_dir: str,
|
|
host_install_dir: str,
|
|
force_rebuild: bool,
|
|
continue_: bool,
|
|
) -> None:
|
|
"""
|
|
Build the package. The main entrypoint in this module.
|
|
|
|
pkg_root
|
|
The path to the root directory for the package. Generally
|
|
$PYODIDE_ROOT/packages/<PACKAGES>
|
|
|
|
pkg
|
|
The package metadata parsed from the meta.yaml file in pkg_root
|
|
|
|
target_install_dir
|
|
The path to the target Python installation
|
|
|
|
host_install_dir
|
|
Directory for installing built host packages.
|
|
"""
|
|
pkg_metadata = pkg["package"]
|
|
source_metadata = pkg["source"]
|
|
build_metadata = pkg["build"]
|
|
name = pkg_metadata["name"]
|
|
version = pkg_metadata["version"]
|
|
build_dir = pkg_root / "build"
|
|
src_dir_name: str = f"{name}-{version}"
|
|
srcpath = build_dir / src_dir_name
|
|
|
|
url = source_metadata.get("url")
|
|
finished_wheel = url and url.endswith(".whl")
|
|
script = build_metadata.get("script")
|
|
library = build_metadata.get("library", False)
|
|
sharedlibrary = build_metadata.get("sharedlibrary", False)
|
|
post = build_metadata.get("post")
|
|
|
|
# These are validated in io.check_package_config
|
|
# If any of these assertions fail, the code path through here might get a
|
|
# bit weird
|
|
assert not (library and sharedlibrary)
|
|
if finished_wheel:
|
|
assert not script
|
|
assert not library
|
|
assert not sharedlibrary
|
|
if post:
|
|
assert not library
|
|
assert not sharedlibrary
|
|
|
|
if not force_rebuild and not needs_rebuild(pkg_root, build_dir, source_metadata):
|
|
return
|
|
|
|
if continue_ and not srcpath.exists():
|
|
raise OSError(
|
|
"Cannot find source for rebuild. Expected to find the source "
|
|
f"directory at the path {srcpath}, but that path does not exist."
|
|
)
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
tee = subprocess.Popen(["tee", pkg_root / "build.log"], stdin=subprocess.PIPE)
|
|
# Cause tee's stdin to get a copy of our stdin/stdout (as well as that
|
|
# of any child processes we spawn)
|
|
os.dup2(tee.stdin.fileno(), sys.stdout.fileno()) # type: ignore[union-attr]
|
|
os.dup2(tee.stdin.fileno(), sys.stderr.fileno()) # type: ignore[union-attr]
|
|
|
|
with chdir(pkg_root), get_bash_runner() as bash_runner:
|
|
bash_runner.env["PKG_VERSION"] = version
|
|
bash_runner.env["PKG_BUILD_DIR"] = str(srcpath)
|
|
if not continue_:
|
|
prepare_source(pkg_root, build_dir, srcpath, source_metadata)
|
|
patch(pkg_root, srcpath, source_metadata)
|
|
|
|
run_script(build_dir, srcpath, build_metadata, bash_runner)
|
|
|
|
if library:
|
|
create_packaged_token(build_dir)
|
|
return
|
|
|
|
if not sharedlibrary and not finished_wheel:
|
|
compile(
|
|
name,
|
|
srcpath,
|
|
build_metadata,
|
|
bash_runner,
|
|
target_install_dir=target_install_dir,
|
|
)
|
|
if not sharedlibrary:
|
|
package_wheel(
|
|
name, pkg_root, srcpath, build_metadata, bash_runner, host_install_dir
|
|
)
|
|
|
|
shutil.rmtree(pkg_root / "dist", ignore_errors=True)
|
|
shutil.copytree(srcpath / "dist", pkg_root / "dist")
|
|
|
|
if sharedlibrary:
|
|
shutil.make_archive(f"{name}-{version}", "zip", pkg_root / "dist")
|
|
|
|
create_packaged_token(build_dir)
|
|
|
|
|
|
def make_parser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
|
|
parser.description = (
|
|
"Build a pyodide package.\n\n"
|
|
"Note: this is a private endpoint that should not be used "
|
|
"outside of the Pyodide Makefile."
|
|
)
|
|
parser.add_argument(
|
|
"package", type=str, nargs=1, help="Path to meta.yaml package description"
|
|
)
|
|
parser.add_argument(
|
|
"--cflags",
|
|
type=str,
|
|
nargs="?",
|
|
default=common.get_make_flag("SIDE_MODULE_CFLAGS"),
|
|
help="Extra compiling flags",
|
|
)
|
|
parser.add_argument(
|
|
"--cxxflags",
|
|
type=str,
|
|
nargs="?",
|
|
default=common.get_make_flag("SIDE_MODULE_CXXFLAGS"),
|
|
help="Extra C++ specific compiling flags",
|
|
)
|
|
parser.add_argument(
|
|
"--ldflags",
|
|
type=str,
|
|
nargs="?",
|
|
default=common.get_make_flag("SIDE_MODULE_LDFLAGS"),
|
|
help="Extra linking flags",
|
|
)
|
|
parser.add_argument(
|
|
"--target-install-dir",
|
|
type=str,
|
|
nargs="?",
|
|
default=common.get_make_flag("TARGETINSTALLDIR"),
|
|
help="The path to the target Python installation",
|
|
)
|
|
parser.add_argument(
|
|
"--host-install-dir",
|
|
type=str,
|
|
nargs="?",
|
|
default=common.get_make_flag("HOSTINSTALLDIR"),
|
|
help=(
|
|
"Directory for installing built host packages. Defaults to setup.py "
|
|
"default. Set to 'skip' to skip installation. Installation is "
|
|
"needed if you want to build other packages that depend on this one."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--force-rebuild",
|
|
action="store_true",
|
|
help=(
|
|
"Force rebuild of package regardless of whether it appears to have been updated"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--continue",
|
|
dest="continue_",
|
|
action="store_true",
|
|
help=(
|
|
dedent(
|
|
"""
|
|
Continue a build from the middle. For debugging. Implies "--force-rebuild".
|
|
"""
|
|
).strip()
|
|
),
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(args):
|
|
continue_ = not not args.continue_
|
|
# --continue implies --force-rebuild
|
|
force_rebuild = args.force_rebuild or continue_
|
|
|
|
meta_file = Path(args.package[0]).resolve()
|
|
|
|
pkg_root = meta_file.parent
|
|
pkg = parse_package_config(meta_file)
|
|
|
|
pkg["source"] = pkg.get("source", {})
|
|
pkg["build"] = pkg.get("build", {})
|
|
build_metadata = pkg["build"]
|
|
build_metadata["backend-flags"] = build_metadata.get("backend-flags", "")
|
|
build_metadata["cflags"] = build_metadata.get("cflags", "")
|
|
build_metadata["cxxflags"] = build_metadata.get("cxxflags", "")
|
|
build_metadata["ldflags"] = build_metadata.get("ldflags", "")
|
|
|
|
build_metadata["cflags"] += f" {args.cflags}"
|
|
build_metadata["cxxflags"] += f" {args.cxxflags}"
|
|
build_metadata["ldflags"] += f" {args.ldflags}"
|
|
|
|
name = pkg["package"]["name"]
|
|
t0 = datetime.now()
|
|
print("[{}] Building package {}...".format(t0.strftime("%Y-%m-%d %H:%M:%S"), name))
|
|
success = True
|
|
try:
|
|
build_package(
|
|
pkg_root,
|
|
pkg,
|
|
target_install_dir=args.target_install_dir,
|
|
host_install_dir=args.host_install_dir,
|
|
force_rebuild=force_rebuild,
|
|
continue_=continue_,
|
|
)
|
|
|
|
except Exception:
|
|
success = False
|
|
raise
|
|
finally:
|
|
t1 = datetime.now()
|
|
datestamp = "[{}]".format(t1.strftime("%Y-%m-%d %H:%M:%S"))
|
|
total_seconds = f"{(t1 - t0).total_seconds():.1f}"
|
|
status = "Succeeded" if success else "Failed"
|
|
print(
|
|
f"{datestamp} {status} building package {name} in {total_seconds} seconds."
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = make_parser(argparse.ArgumentParser())
|
|
args = parser.parse_args()
|
|
main(args)
|