Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
yaml-paths: ".github/workflows/ci.yml"
release-notes-extra: |
CPython 3.12.3 distribution for Nanvix with pure Python pip packages.
docker-image: "ghcr.io/nanvix/toolchain-python@sha256:2ef7213bfff85e927f18d8f0fc53063b9c6ffed236a6b30c92f6b4e148c2dec4" # yamllint disable-line rule:line-length
docker-image: "ghcr.io/nanvix/nanvix-sdk-c-clang@sha256:f61737cb0780e6a2058c6d0bdf8ae5562db18de437173b2bcbbe6973abd3689f" # yamllint disable-line rule:line-length

Check warning on line 44 in .github/workflows/ci.yml

View workflow job for this annotation

GitHub Actions / ci / Format & Lint

44:129 [comments] too few spaces before comment
test-output-globs: '["nanvix_rootfs.img"]'
secrets:
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
Expand Down
2 changes: 1 addition & 1 deletion .nanvix/nanvix.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "nanvix-python"
version = "3.12.3"
nanvix-version = "0.17.9"
nanvix-version = "0.20.0"

[builds]
[builds.matrix]
Expand Down
5 changes: 2 additions & 3 deletions .nanvix/src/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import os
import shutil
import subprocess
import tempfile
import time
from pathlib import Path

Expand Down Expand Up @@ -56,7 +55,7 @@ def benchmark(self) -> None:
initrd = self._ensure_initrd(sysroot)

# Create a temp mount directory with a hello-world bootstrap.py
mount_dir = Path(tempfile.mkdtemp(prefix="nanvix-bench-"))
mount_dir = self._temporary_directory("nanvix-bench-")
(mount_dir / "bootstrap.py").write_text('print("hello")\n')
cmd = [
nanvixd_bin,
Expand All @@ -77,7 +76,7 @@ def benchmark(self) -> None:
'-c print("hello")',
]

tmp = Path(tempfile.gettempdir())
tmp = self._log_directory()
log_file = tmp / "benchmark.log"

log.info("running benchmark: hello world")
Expand Down
92 changes: 51 additions & 41 deletions .nanvix/src/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"""Build lifecycle for the nanvix-python ZScript.

Owns ramfs/initrd construction, site-packages installation, the PIL shim,
the openpyxl lxml patch, and the standalone-mode Docker-based .pyc
the openpyxl lxml patch, and the standalone-mode SDK-based .pyc
pre-compilation pipeline.
"""

Expand All @@ -24,7 +24,7 @@
from nanvix_zutil.helpers import InitRdArgs
from nanvix_zutil.paths import nanvix_root, repo_root, test_out

from .lib import LibMixin, mkramfs_binary, nanvixd_binary
from .lib import SDK_IMAGE, LibMixin, mkramfs_binary, nanvixd_binary


class BuildMixin(LibMixin):
Expand Down Expand Up @@ -84,7 +84,7 @@ def _ensure_ramfs(self, sysroot: Path) -> Path:
"""Validate that an up-to-date ramfs image exists.

Used by ``test``, ``release``, and ``benchmark``: never builds. Building
the ramfs requires Docker (for .pyc pre-compilation) and is therefore
the ramfs requires the SDK (for .pyc pre-compilation) and is therefore
confined to ``./z build`` via :meth:`_build_ramfs`. A missing or stale
image is fatal. In CI, this short-circuits if the expected ramfs image
is found.
Expand All @@ -111,7 +111,7 @@ def _ensure_ramfs(self, sysroot: Path) -> Path:
log.fatal(
"ramfs image missing or stale.",
code=EXIT_MISSING_DEP,
hint="Run `./z build` first (requires Docker).",
hint="Run `./z build` first (requires Docker and the pinned SDK).",
)

self._ramfs_img = img
Expand All @@ -120,7 +120,7 @@ def _ensure_ramfs(self, sysroot: Path) -> Path:
def _build_ramfs(self, sysroot: Path) -> Path:
"""Build (or reuse) a ramfs image for standalone mode.

Only ``./z build`` may call this: it invokes Docker via
Only ``./z build`` may call this: it invokes the SDK via
:meth:`_precompile_pyc` and is the sole producer of the ramfs.
"""
if self._ramfs_img and self._ramfs_img.is_file():
Expand Down Expand Up @@ -154,7 +154,7 @@ def _build_ramfs(self, sysroot: Path) -> Path:
shutil.copy2(src, stripped_root)

# _boot.pyc is compiled inside _create_stripped_sysroot via
# Docker (Python 3.12) and placed at the sysroot root.
# the SDK's host Python 3.12 and placed at the sysroot root.

# Generate build manifests for post-build inspection
self._write_build_manifests(sysroot, stripped, nanvix_root())
Expand Down Expand Up @@ -314,7 +314,7 @@ def _create_stripped_sysroot(self, src: Path, dst: Path) -> None:

# Remove native/build source from site-packages
for ext in ("*.pyx", "*.pxd", "*.c", "*.h", "*.cpp"):
for f in site_pkg.rglob(ext):
for f in pylib.rglob(ext):
f.unlink(missing_ok=True)
Comment on lines 315 to 318

# Remove non-Python assets that are dead weight at runtime
Expand Down Expand Up @@ -343,7 +343,7 @@ def _create_stripped_sysroot(self, src: Path, dst: Path) -> None:
for f in docutils_pkg.rglob(ext):
f.unlink(missing_ok=True)

# Pre-compile .py → .pyc using Docker toolchain (Python 3.12)
# Pre-compile .py → .pyc using the SDK host Python 3.12
# then strip .py sources so ramfs ships only bytecode.
#
# Place _boot.py inside pylib so it's compiled with the correct
Expand All @@ -367,22 +367,22 @@ def _create_stripped_sysroot(self, src: Path, dst: Path) -> None:
if boot_pyc_in_pylib.is_file():
shutil.move(str(boot_pyc_in_pylib), str(root / "_boot.pyc"))

def _precompile_pyc(self, pylib: Path) -> None:
"""Pre-compile .py to .pyc using Docker toolchain's Python 3.12.
def _precompile_pyc(
self,
pylib: Path,
*,
legacy: bool = True,
strip_sources: bool = True,
) -> None:
"""Pre-compile .py to .pyc using the pinned SDK's host Python 3.12.

Uses ``compileall -b`` to write .pyc alongside sources, then
removes .py files. To avoid slow volume-mount I/O on Windows,
the directory is tarred into the container and extracted back.
The directory is tarred into the container and extracted back to
avoid slow volume-mount I/O on Windows.

Hard-fails if Docker is unavailable: the standalone ramfs ships
.pyc-only contents (including ``/sysroot/_boot.pyc``), so a
Docker-less build would produce an unusable image. This is
only reachable from ``./z build`` via :meth:`_build_ramfs`;
``test``/``release``/``benchmark`` go through
:meth:`_ensure_ramfs` and never reach here.
Hard-fails if Docker is unavailable. The standalone ramfs ships
sourceless bytecode, while release bundles retain sources alongside
an opt-0 bytecode cache.
"""
_DOCKER_IMAGE = "ghcr.io/nanvix/toolchain-python:latest"

if shutil.which("docker") is None:
log.fatal(
"Docker is required to build the standalone ramfs "
Expand All @@ -391,14 +391,20 @@ def _precompile_pyc(self, pylib: Path) -> None:
hint="Install Docker and rerun `./z build`.",
)

log.info("pre-compiling .py to .pyc via Docker (Python 3.12)")
log.info("pre-compiling .py to .pyc with SDK host Python 3.12")

container_work = "/tmp/pylib"
container_work = "/work/pylib"
compile_flags = "-b " if legacy else ""
delete_sources = (
f"find {container_work} -name '*.py' -delete && " if strip_sources else ""
)
optimize = "-O " if strip_sources else ""
script = (
f"mkdir -p {container_work} && "
f"tar -xf - -C {container_work} && "
f"python3 -O -m compileall -b -q {container_work} && "
f"find {container_work} -name '*.py' -delete && "
f"/usr/bin/python3 {optimize}-m compileall "
f"{compile_flags}-q {container_work} && "
f"{delete_sources}"
f"tar -cf - -C {container_work} ."
)

Expand All @@ -416,7 +422,7 @@ def _precompile_pyc(self, pylib: Path) -> None:
"run",
"--rm",
"-i",
_DOCKER_IMAGE,
SDK_IMAGE,
"sh",
"-c",
script,
Expand All @@ -428,7 +434,7 @@ def _precompile_pyc(self, pylib: Path) -> None:
)
if result.returncode != 0:
log.fatal(
"Docker compileall failed (rc="
"SDK compileall failed (rc="
f"{result.returncode}): {result.stderr.decode(errors='replace').strip()}",
code=EXIT_BUILD_FAILURE,
hint="Inspect the Docker output above and rerun `./z build`.",
Expand All @@ -443,15 +449,16 @@ def _precompile_pyc(self, pylib: Path) -> None:

# Validate that the entry-point bytecode was produced; without
# it the standalone initrd cannot warm-start.
if not (pylib / "_boot.pyc").is_file():
if legacy and strip_sources and not (pylib / "_boot.pyc").is_file():
log.fatal(
"Docker compileall did not produce _boot.pyc",
"SDK compileall did not produce _boot.pyc",
code=EXIT_BUILD_FAILURE,
hint="Inspect the Docker output above and rerun `./z build`.",
)

count = sum(1 for _ in pylib.rglob("*.pyc"))
log.info(f"pre-compiled {count} .pyc files (source .py removed)")
suffix = " (source .py removed)" if strip_sources else ""
log.info(f"pre-compiled {count} .pyc files{suffix}")

def _cleanup_ramfs(self) -> None:
"""Remove intermediate ramfs build artifacts (keeps cached image)."""
Expand Down Expand Up @@ -549,6 +556,7 @@ def _install_site_packages(self, site_pkg: Path) -> None:
req_hash = h.hexdigest()

if sentinel.is_file() and sentinel.read_text().strip() == req_hash:
self._remove_site_package_build_artifacts(site_pkg)
log.info("site-packages already up-to-date, skipping pip install")
return

Expand All @@ -574,8 +582,16 @@ def _install_site_packages(self, site_pkg: Path) -> None:
pth = site_pkg / "distutils-precedence.pth"
pth.unlink(missing_ok=True)

self._remove_site_package_build_artifacts(site_pkg)
sentinel.write_text(req_hash)

@staticmethod
def _remove_site_package_build_artifacts(site_pkg: Path) -> None:
"""Remove native build inputs that are not usable at runtime."""
for pattern in ("*.a", "*.c", "*.cpp", "*.h", "*.pxd", "*.pyx"):
for path in site_pkg.rglob(pattern):
path.unlink(missing_ok=True)

def _install_pil_shim(self, site_pkg: Path) -> None:
"""Copy the pure-Python PIL shim into site-packages.

Expand Down Expand Up @@ -675,11 +691,6 @@ def _stage_release(self) -> None:
if pylib.is_dir():
shutil.copytree(pylib, lib_dir / "python3.12")

# Linker script
user_ld = sysroot / "lib" / "user.ld"
if user_ld.is_file():
shutil.copy2(user_ld, lib_dir)

# Clean build/test artifacts from bundle
log.info("release: cleaning build and test artifacts")
for p in bundle_dir.glob("test_*.py"):
Expand All @@ -693,12 +704,11 @@ def _stage_release(self) -> None:

# Precompile .py to .pyc
log.info("release: pre-compiling .pyc bytecode cache")
host_python = self._host_python()
if host_python:
subprocess.run(
[host_python, "-m", "compileall", "-q", str(lib_dir / "python3.12")],
capture_output=True,
)
self._precompile_pyc(
lib_dir / "python3.12",
legacy=False,
strip_sources=False,
)

# Build and include ramfs image for standalone mode
if mode == "standalone":
Expand Down
56 changes: 44 additions & 12 deletions .nanvix/src/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
import tempfile
from pathlib import Path

from nanvix_zutil import CFG_SYSROOT, TOOLCHAIN_CONTAINER_PATH, ZScript, log
from nanvix_zutil import CFG_SYSROOT, ZScript, log
from nanvix_zutil.exitcodes import EXIT_MISSING_DEP
from nanvix_zutil.paths import test_out

__all__ = (
"DEFAULT_TIMEOUT",
"IS_WINDOWS",
"LibMixin",
"SDK_IMAGE",
"mkramfs_binary",
"nanvixd_binary",
)
Expand All @@ -33,6 +35,11 @@

IS_WINDOWS = sys.platform == "win32"

SDK_IMAGE = (
"ghcr.io/nanvix/nanvix-sdk-c-clang"
"@sha256:f61737cb0780e6a2058c6d0bdf8ae5562db18de437173b2bcbbe6973abd3689f"
)


def nanvixd_binary() -> str:
"""Return the nanvixd binary name for the current host platform."""
Expand All @@ -47,6 +54,20 @@ def mkramfs_binary() -> str:
class LibMixin(ZScript):
"""Shared state + helpers for nanvix-python lifecycle mixins."""

# The downloaded sysroot is runtime-only. Build-time headers, libraries,
# startup objects, and linker scripts live exclusively in the SDK.
SYSROOT_REQUIRED_FILES: tuple[str, ...] = (
"bin/nanvixd.elf",
"bin/kernel.elf",
"bin/mkramfs.elf",
)
SYSROOT_REQUIRED_FILES_WINDOWS: tuple[str, ...] = (
"bin/nanvixd.exe",
"bin/kernel.elf",
"bin/mkramfs.exe",
)
SYSROOT_MULTI_PROCESS_FILES: tuple[str, ...] = ()

# Standalone / ramfs artefacts shared across build and test stages.
_ramfs_img: Path | None = None
_stripped_sysroot: Path | None = None
Expand All @@ -62,17 +83,19 @@ def _sysroot_path(self) -> Path:
)
return Path(sysroot)

def _toolchain_str(self) -> str:
return str(TOOLCHAIN_CONTAINER_PATH)
@staticmethod
def _temporary_directory(prefix: str) -> Path:
"""Create a temporary directory under the ignored test output."""
temp_root = test_out() / "temp"
temp_root.mkdir(parents=True, exist_ok=True)
return Path(tempfile.mkdtemp(prefix=prefix, dir=temp_root))

def _host_python(self) -> str | None:
toolchain_python = Path(self._toolchain_str()) / "bin" / "python3"
if toolchain_python.is_file():
return str(toolchain_python)
for name in ("python3", "python"):
if shutil.which(name):
return name
return None
@staticmethod
def _log_directory() -> Path:
"""Return the ignored directory used for transient command logs."""
logs = test_out() / "logs"
logs.mkdir(parents=True, exist_ok=True)
return logs

def _nanvix_run(
self,
Expand All @@ -97,7 +120,7 @@ def _nanvix_run(
hint="Run `./z build` first.",
)
initrd = self._ensure_initrd(sysroot)
mount_dir = Path(tempfile.mkdtemp(prefix="nanvix-mount-"))
mount_dir = self._temporary_directory("nanvix-mount-")
(mount_dir / "argv.txt").write_text(f"/sysroot/{script_path}\n")
cmd = [
nanvixd,
Expand Down Expand Up @@ -191,3 +214,12 @@ def _patch_openpyxl_lxml(self, site_pkg: Path) -> None: # pragma: no cover

def _stage_test_scripts(self, sysroot: Path) -> None: # pragma: no cover
raise NotImplementedError

def _precompile_pyc(
self,
pylib: Path,
*,
legacy: bool = True,
strip_sources: bool = True,
) -> None: # pragma: no cover
raise NotImplementedError
Loading
Loading