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
4 changes: 2 additions & 2 deletions compat/data/compat.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema": "1",
"generated": "2026-10-26",
"commit": "161504b360b33ff28048889e8a8579a07184fccc",
"commit": "8188045c4bff110578c0e3c884e277c1573902ca",
"sources": {
"support_data": "scripts/support_data.toml",
"pins": "benchmarks/compat/top100/pins.json",
Expand Down Expand Up @@ -2207,7 +2207,7 @@
},
{
"date": "2026-10-26",
"commit": "161504b360b33ff28048889e8a8579a07184fccc",
"commit": "8188045c4bff110578c0e3c884e277c1573902ca",
"weighted_pass": 0.0,
"packages_full": 2,
"packages_partial": 7,
Expand Down
2 changes: 1 addition & 1 deletion compat/data/support.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema": "1",
"generated": "2026-10-26",
"commit": "161504b360b33ff28048889e8a8579a07184fccc",
"commit": "8188045c4bff110578c0e3c884e277c1573902ca",
"sources": {
"support_data": "scripts/support_data.toml",
"pins": "benchmarks/compat/top100/pins.json",
Expand Down
2 changes: 1 addition & 1 deletion compat/data/what_will_fail.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema": "1",
"generated": "2026-10-24",
"commit": "161504b360b33ff28048889e8a8579a07184fccc",
"commit": "8188045c4bff110578c0e3c884e277c1573902ca",
"sources": {
"corpus": "benchmarks/real_scripts/scripts",
"report": "benchmarks/real_scripts/REPORT.md"
Expand Down
63 changes: 60 additions & 3 deletions src/pyscriptc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from typing import Any

# boxed:begin
from pyscriptc import embed, runcache
from pyscriptc import embed, runcache, sitedeps, stdlibzip

# boxed:end
from pyscriptc.config import apply_to_args, entry_points, load_config
Expand Down Expand Up @@ -262,6 +262,15 @@ def main(argv: list[str] | None = None) -> int:
"libpython, bundle otherwise; the default). Also read from the `embed` key of "
"[tool.pyscriptc.island]",
)
compile_parser.add_argument(
"--embed-stdlib",
choices=stdlibzip.STDLIB_MODES,
default=None,
metavar="TRIM",
help="stdlib trim of a bundle: safe (the default; everything except test, idlelib, "
"tkinter, turtledemo, ensurepip, pydoc_data, __phello__) or minimal (an opt-in frozen "
"closure plus allowlist). Also read from the `stdlib` key of [tool.pyscriptc.island]",
)
# boxed:end
compile_parser.add_argument("--call", help="function the generated main calls")
compile_parser.add_argument(
Expand Down Expand Up @@ -1952,9 +1961,37 @@ def _compile_cmd(args: argparse.Namespace, state: dict[str, Any]) -> int:
# boxed:begin
# A bundle link writes a relocatable directory: the binary under
# `bin/`, the runtime under `lib/` (the relative rpath points there).
if getattr(args, "embed_python", None) == "bundle":
# The stdlib travels as a safe-trimmed zip of .pyc bodies (issue #659),
# and the compile first manifest-checks every stdlib import of the
# program against the zip the trim would produce, so a trimmed module
# fails the build (PS4301), never the run.
resolved_embed = getattr(args, "embed_python", None)
if resolved_embed != "host" and embed.resolve_mode(resolved_embed) == "bundle":
resolved_embed = "bundle"
# The direct third-party import roots of the program, for the bundle's
# site-packages tree (issue #660). A native-backed distribution among
# them refuses the compile (PS4302) before anything is staged.
dep_roots = sitedeps.third_party_roots(source_path) if resolved_embed == "bundle" else []
if resolved_embed == "bundle":
sitedeps.check_pure(dep_roots)
pack = embed.lock_pack()
staged = embed.stage_bundle(binary, embed.pack_dir(pack), embed.python_version(), binary)
pack_directory = embed.ensure_pack(pack)
embed_version = embed.python_version()
trim = getattr(args, "embed_stdlib", None) or stdlibzip.DEFAULT_MODE
carried = stdlibzip.kept_module_names(
stdlibzip.stdlib_sources(pack_directory / "lib" / f"python{embed_version}", trim)
)
stdlibzip.manifest_check(carried, _stdlib_imports(source_path), trim)
if resolved_embed == "bundle":
pack = embed.lock_pack()
staged = embed.stage_bundle(
binary,
embed.pack_dir(pack),
embed.python_version(),
binary,
stdlib_mode=getattr(args, "embed_stdlib", None) or stdlibzip.DEFAULT_MODE,
dependency_roots=dep_roots,
)
outputs["bin"] = str(staged)
# boxed:end
state["binary_bytes"] = binary.stat().st_size if binary.is_file() else None
Expand Down Expand Up @@ -1982,6 +2019,26 @@ def _compile_cmd(args: argparse.Namespace, state: dict[str, Any]) -> int:
dbg.write_dir(module, llvm_text, outputs, status)


def _stdlib_imports(source_path: Path) -> list[str]:
"""The absolute stdlib import names of the program, for the bundle manifest check.

Relative imports stay inside the program tree and third-party imports go
through the site-packages bundle (issue #660), so only stdlib roots reach
the check. `sys.stdlib_module_names` of the running interpreter answers
it: the compiler and the pack share the 3.14 stdlib.
"""

from pyscriptc.imports import collect_imports

names: set[str] = set()
for site in collect_imports(source_path):
if site["level"] > 0 or not site["name"]:
continue
if site["name"].split(".")[0] in sys.stdlib_module_names:
names.add(site["name"])
return sorted(names)


def _dependency_roots(
source_path: Path, config: Any, island_default: bool = False
) -> tuple[list[Path], list[Path], dict[str, dict[str, str]]]:
Expand Down
19 changes: 19 additions & 0 deletions src/pyscriptc/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ def fallback_definitions(self) -> bool:

def island_embed(self) -> str | None:
"""The `[tool.pyscriptc.island] embed` mode, or None when unset."""

if isinstance(self.island, dict):
value = self.island.get("embed")
if value is not None:
Expand All @@ -188,6 +189,20 @@ def island_embed(self) -> str | None:
return str(value)
return None

def island_stdlib(self) -> str | None:
"""The `[tool.pyscriptc.island] stdlib` trim, or None when unset."""

if isinstance(self.island, dict):
value = self.island.get("stdlib")
if value is not None:
from pyscriptc.stdlibzip import STDLIB_MODES

table = "pyscriptc.island" if self.source == "pyscriptc.toml" else "tool.pyscriptc.island"
if value not in STDLIB_MODES:
raise ConfigError(f"{table}.stdlib must be one of {'|'.join(STDLIB_MODES)}, got {value!r}")
return str(value)
return None

# boxed:end

def lane_for(self, name: str) -> tuple[str, str]:
Expand Down Expand Up @@ -494,5 +509,9 @@ def apply_to_args(config: Config, args: Any) -> list[str]:
island_embed = config.island_embed()
if island_embed is not None:
args.embed_python = island_embed
if hasattr(args, "embed_stdlib") and getattr(args, "embed_stdlib", None) is None:
island_stdlib = config.island_stdlib()
if island_stdlib is not None:
args.embed_stdlib = island_stdlib
# boxed:end
return notes
34 changes: 26 additions & 8 deletions src/pyscriptc/embed.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
from dataclasses import dataclass
from pathlib import Path

from pyscriptc import sitedeps, stdlibzip

MODES = ("host", "bundle", "auto")
DEFAULT_MODE = "auto"

Expand Down Expand Up @@ -387,14 +389,27 @@ def link_flags(pack_directory: Path, version: str) -> tuple[str, list[str]]:
return str(include), flags


def stage_bundle(output: Path, pack_directory: Path, version: str, binary: Path) -> Path:
def stage_bundle(
output: Path,
pack_directory: Path,
version: str,
binary: Path,
*,
stdlib_mode: str = stdlibzip.DEFAULT_MODE,
dependency_roots: list[str] | None = None,
) -> Path:
"""Copy the pack runtime next to the binary: `name/bin/name` plus `name/lib/`.

The binary moves into `bin/`; the shared libpython and the stdlib go into
`lib/`, which is what the relative rpath and the getpath landmarks point
at (a python-build-standalone `install/` tree is relocatable: it carries
The binary moves into `bin/`; the shared libpython goes into `lib/`, which
is what the relative rpath and the getpath landmarks point at (a
python-build-standalone `install/` tree is relocatable: it carries
`@executable_path/../lib` already, so no install-name rewrite is needed).
Headers stay in the pack: a relocated bundle runs, it does not compile.
The stdlib is staged as a zip of `.pyc` bodies plus a real `lib-dynload/`
directory (issue #659); the unpacked tree does not travel, and the pure-
Python distributions behind `dependency_roots` are copied into its
`site-packages/` (issue #660; a native dependency is refused by
`sitedeps.check_pure` before staging begins). Headers stay in the pack: a
relocated bundle runs, it does not compile.
"""
out_dir = output if output.suffix == "" else output.parent / output.stem
parent = out_dir.parent
Expand All @@ -418,9 +433,12 @@ def stage_bundle(output: Path, pack_directory: Path, version: str, binary: Path)
for extra in sorted(shared.parent.glob(f"libpython{version}.so*")):
shutil.copy2(extra, lib_dir / extra.name, follow_symlinks=False)
stdlib_src = pack_directory / "lib" / f"python{version}"
stdlib_dest = lib_dir / f"python{version}"
if stdlib_src.is_dir() and not stdlib_dest.exists():
shutil.copytree(stdlib_src, stdlib_dest, symlinks=True, ignore=shutil.ignore_patterns("__pycache__"))
if stdlib_src.is_dir():
stdlibzip.stage_stdlib(lib_dir, pack_directory, version, stdlib_mode)
else:
(lib_dir / f"python{version}" / "lib-dynload").mkdir(parents=True, exist_ok=True)
if dependency_roots:
sitedeps.stage_site_packages(lib_dir, version, dependency_roots)
out_dir = Path(out_dir)
if out_dir.exists():
shutil.rmtree(out_dir)
Expand Down
44 changes: 37 additions & 7 deletions src/pyscriptc/imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,29 @@ def _package_root(target: Path) -> str:


def _read_wheel_record(path: Path) -> list[str] | None:
"""The file list of a wheel, or None when it is not a readable wheel."""
"""The file list of a wheel RECORD, or None when it is not readable.

A RECORD inside an unpacked `*.dist-info/` directory is read as a text
file; a RECORD still inside a wheel archive (the cached-wheel case) is
read from the zip.
"""

if not path.is_file():
return None
if not zipfile.is_zipfile(path):
# An unpacked `*.dist-info/RECORD` is a csv text file. Refuse a plain
# text file whose lines carry no RECORD separators, so a misnamed
# non-record is not read as a one-file wheel.
try:
body = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
files: list[str] = []
for line in body.splitlines():
if not line.strip():
continue
files.append(line.split(",", 1)[0])
return files if any("," in line for line in body.splitlines()) else None
if not zipfile.is_zipfile(path):
return None
try:
Expand Down Expand Up @@ -369,16 +390,20 @@ def _installed_distributions() -> dict[str, dict[str, Any]]:
for entry in sorted(root.iterdir()):
metadata = _metadata_of_tree(entry)
if metadata["dist"]:
key = metadata["dist"].lower().replace("_", "-")
key = metadata["dist"].lower().replace("_", "-").replace(" ", "-")
if key not in found:
found[key] = metadata
for dist_info in sorted(root.glob("*.dist-info")):
name = dist_info.name[: -len(".dist-info")]
key = name.lower().replace("_", "-")
metadata = found.get(key)
key = name.lower().replace("_", "-").replace(" ", "-")
# A versioned directory name (`markupsafe-3.0.4.dist-info`) does
# not match the distribution key the metadata loop built, so strip
# a trailing version before looking the entry up.
stripped = key.split("-")[0] if key.rsplit("-", 1)[-1][0].isdigit() else key
metadata = found.get(stripped) or found.get(key)
if metadata is None:
metadata = {"dist": name, "root": dist_info, "native": False}
found[key] = metadata
found[stripped] = metadata
wheel = dist_info / "RECORD"
if wheel.is_file():
files = _read_wheel_record(wheel)
Expand All @@ -395,8 +420,13 @@ def distribution_is_native(name: str) -> tuple[bool | None, str]:
its metadata cannot be read. The caller must not read `None` as pure.
"""

key = name.lower().replace("_", "-")
metadata = _installed_distributions().get(key)
key = name.lower().replace("_", "-").replace(" ", "-")
found = _installed_distributions()
metadata = found.get(key)
if metadata is None and "-" in key and key.rsplit("-", 1)[-1][0].isdigit():
# A versioned lookup (`nativelib-1.0`) matches the distribution key
# the site metadata built (`nativelib`).
metadata = found.get(key.rsplit("-", 1)[0])
if metadata is None:
return None, "not installed in this interpreter"
if metadata["native"]:
Expand Down
Loading
Loading