bb15b750fb
复用现有 Docker 和 Nuitka 流程,隔离架构镜像并支持 aarch64 标签。已通过 47 项测试、代码及文档门禁,并完成 Python 3.10 ARM64 wheel 构建、隔离导入与内容审计。
220 lines
7.3 KiB
Python
220 lines
7.3 KiB
Python
"""Assemble and build the Nuitka wheel with setuptools."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
_BUILD_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(_BUILD_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_BUILD_ROOT))
|
|
|
|
from common.paths import sdk_dir
|
|
|
|
# PyPI rejects bare tags like "manylinux_x86_64"; require a glibc ABI version.
|
|
_MANYLINUX_TAG_RE = re.compile(
|
|
r"(?:manylinux2014|manylinux_2_(?:17|24|27|28|31|34|35))_(?:x86_64|aarch64)(?=\.|$)"
|
|
)
|
|
|
|
# Source of the PyPI project description (see read_long_description).
|
|
PYPI_README_RELPATH = Path("docs") / "pypi_readme.md"
|
|
|
|
|
|
def _license_text(project: dict) -> str | None:
|
|
"""Extract free-text license from either pyproject license form."""
|
|
declared = project.get("license")
|
|
if isinstance(declared, str):
|
|
return declared
|
|
if isinstance(declared, dict):
|
|
text = declared.get("text")
|
|
if isinstance(text, str):
|
|
return text
|
|
return None
|
|
|
|
|
|
def _person_strings(entries) -> list[str]:
|
|
"""Render PEP 621 author/maintainer tables as ``Name <email>`` strings."""
|
|
rendered: list[str] = []
|
|
for entry in entries or []:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
name = (entry.get("name") or "").strip()
|
|
email = (entry.get("email") or "").strip()
|
|
if name and email:
|
|
rendered.append(f"{name} <{email}>")
|
|
elif name or email:
|
|
rendered.append(name or email)
|
|
return rendered
|
|
|
|
|
|
def metadata_kwargs(project: dict) -> dict:
|
|
"""Translate a pyproject ``[project]`` table into ``setup()`` keywords.
|
|
|
|
The Nuitka wheel is built by a generated ``setup.py`` outside the normal
|
|
setuptools pipeline, so every PyPI-visible field has to be passed through
|
|
explicitly. Omitting them is what left the early PyPI releases with no
|
|
``Requires-Python``, classifiers, project URLs or license on the project page.
|
|
"""
|
|
authors = _person_strings(project.get("authors"))
|
|
maintainers = _person_strings(project.get("maintainers"))
|
|
candidates = {
|
|
"description": project.get("description"),
|
|
"python_requires": project.get("requires-python"),
|
|
"classifiers": list(project.get("classifiers") or []),
|
|
"project_urls": dict(project.get("urls") or {}),
|
|
"license": _license_text(project),
|
|
"author": authors[0] if authors else None,
|
|
"author_email": None,
|
|
"maintainer": maintainers[0] if maintainers else None,
|
|
}
|
|
return {key: value for key, value in candidates.items() if value}
|
|
|
|
|
|
def read_long_description(repo_root: Path) -> str | None:
|
|
"""Return the PyPI long description, or ``None`` when it is missing.
|
|
|
|
Deliberately not the repository ``README.md``: that file documents the
|
|
closed-source build/publishing layout, which should not end up on the PyPI
|
|
project page.
|
|
"""
|
|
path = Path(repo_root) / PYPI_README_RELPATH
|
|
if not path.is_file():
|
|
print(
|
|
f"[WARN] {PYPI_README_RELPATH.as_posix()} not found; the wheel will be "
|
|
"published without a long description."
|
|
)
|
|
return None
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def write_wheel_setup(
|
|
wheel_dir: Path,
|
|
*,
|
|
version: str,
|
|
package_data: dict[str, list[str]],
|
|
install_requires: list[str],
|
|
extras_require: dict[str, list[str]],
|
|
project: dict | None = None,
|
|
long_description: str | None = None,
|
|
) -> Path:
|
|
metadata_lines = ""
|
|
if project:
|
|
for key, value in sorted(metadata_kwargs(project).items()):
|
|
metadata_lines += f" {key}={value!r},\n"
|
|
if long_description:
|
|
metadata_lines += " long_description=_LONG_DESCRIPTION,\n"
|
|
metadata_lines += ' long_description_content_type="text/markdown",\n'
|
|
|
|
setup_py_path = wheel_dir / "setup.py"
|
|
setup_content = f"""
|
|
from setuptools import setup, Distribution
|
|
|
|
class BinaryDistribution(Distribution):
|
|
def has_ext_modules(self):
|
|
return True
|
|
|
|
{'_LONG_DESCRIPTION = ' + repr(long_description) if long_description else ''}
|
|
|
|
setup(
|
|
name="orisys",
|
|
version="{version}",
|
|
{metadata_lines} packages=['orisys', 'orisys.examples', 'orisys_blend'],
|
|
package_dir={{'': 'wheel_root'}},
|
|
package_data={package_data},
|
|
include_package_data=True,
|
|
install_requires={install_requires!r},
|
|
extras_require={extras_require!r},
|
|
distclass=BinaryDistribution,
|
|
zip_safe=False,
|
|
)
|
|
"""
|
|
setup_py_path.write_text(setup_content, encoding="utf-8")
|
|
return setup_py_path
|
|
|
|
|
|
def run_bdist_wheel(wheel_dir: Path) -> Path | None:
|
|
print("\n=== Building Wheel ===")
|
|
subprocess.check_call([sys.executable, "setup.py", "bdist_wheel"], cwd=wheel_dir)
|
|
wheels = sorted((wheel_dir / "dist").glob("*.whl"))
|
|
return wheels[0] if wheels else None
|
|
|
|
|
|
def _ensure_auditwheel() -> None:
|
|
try:
|
|
subprocess.check_call(
|
|
[sys.executable, "-m", "auditwheel", "--version"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
print("Installing auditwheel...")
|
|
subprocess.check_call([sys.executable, "-m", "pip", "install", "auditwheel", "-q"])
|
|
|
|
|
|
def is_compliant_manylinux_wheel(wheel_path: Path) -> bool:
|
|
"""True if the wheel filename uses a supported manylinux x86_64 or aarch64 tag."""
|
|
return bool(_MANYLINUX_TAG_RE.search(wheel_path.name))
|
|
|
|
|
|
def repair_linux_wheel(wheel_path: Path, repair_dir: Path) -> Path:
|
|
"""Run auditwheel show/repair and return a PyPI-compliant manylinux wheel.
|
|
|
|
Raises RuntimeError if repair fails or the result still lacks a valid tag.
|
|
On non-Linux platforms the input wheel is returned unchanged.
|
|
"""
|
|
if sys.platform != "linux":
|
|
print("Skipping auditwheel repair (not running on Linux).")
|
|
return wheel_path
|
|
|
|
_ensure_auditwheel()
|
|
repair_dir = repair_dir.resolve()
|
|
if repair_dir.exists():
|
|
shutil.rmtree(repair_dir)
|
|
repair_dir.mkdir(parents=True)
|
|
|
|
print("\n=== auditwheel show ===")
|
|
subprocess.check_call([sys.executable, "-m", "auditwheel", "show", str(wheel_path)])
|
|
|
|
print("\n=== auditwheel repair ===")
|
|
subprocess.check_call(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"auditwheel",
|
|
"repair",
|
|
str(wheel_path),
|
|
"--wheel-dir",
|
|
str(repair_dir),
|
|
]
|
|
)
|
|
|
|
repaired = sorted(repair_dir.glob("*.whl"))
|
|
if not repaired:
|
|
raise RuntimeError(f"auditwheel repair produced no wheels in {repair_dir}")
|
|
|
|
compliant = [path for path in repaired if is_compliant_manylinux_wheel(path)]
|
|
if not compliant:
|
|
names = ", ".join(path.name for path in repaired)
|
|
raise RuntimeError(
|
|
"auditwheel repair did not produce a PyPI-compliant manylinux tag. "
|
|
f"Got: {names}. Rebuild inside the manylinux_2_28 image for the "
|
|
"target architecture instead of a generic Linux host."
|
|
)
|
|
|
|
final = compliant[0]
|
|
print(f"Repaired wheel: {final.name}")
|
|
return final
|
|
|
|
|
|
def publish_wheel_to_sdk(wheel_path: Path, version: str) -> Path:
|
|
sdk_version_dir = sdk_dir(version)
|
|
sdk_version_dir.mkdir(parents=True, exist_ok=True)
|
|
dest_wheel = sdk_version_dir / wheel_path.name
|
|
if dest_wheel.exists():
|
|
dest_wheel.unlink()
|
|
shutil.copy2(str(wheel_path), str(dest_wheel))
|
|
return dest_wheel
|