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: 2 additions & 0 deletions .github/workflows/test_wppm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ on:
push:
paths:
- 'wppm/**'
- 'winpython/**'
- 'tests/**'
- 'pyproject.toml'
- '.github/workflows/test_wppm.yml'
pull_request:
paths:
- 'wppm/**'
- 'winpython/**'
- 'tests/**'
- 'pyproject.toml'
- '.github/workflows/test_wppm.yml'
Expand Down
3 changes: 3 additions & 0 deletions build_winpython_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ def run_build(build, python_versions):
my_toolsdirs = build.get("toolsdirs", "")
#my_install_options = build.get("install_options", "")
wheelhousereq = build.get("wheelhousereq", "")
# "pip" (default, what ships) | "none" | "parallel" | "parallel-N"
my_bytecode = build.get("bytecode", "pip")

# Get Python release info from TOML [pythons]
py_target = my_python_target
Expand Down Expand Up @@ -77,6 +79,7 @@ def run_build(build, python_versions):
"--constraints", my_constraints,
"--find-links", my_find_links,
"--wheelhousereq", wheelhousereq,
"--bytecode", my_bytecode,
"--create-installer", my_create_installer,
#"--install-options", env["my_install_options"],
]
Expand Down
61 changes: 61 additions & 0 deletions tests/test_build_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""Build-script options: --bytecode mode parsing.

The default must stay 'pip', because that is what release builds ship.
"""
import os

import pytest

from winpython.build_winpython import PARALLEL_DEFAULT_MAX, parse_bytecode_mode


class TestBytecodeMode:
def test_default_keeps_pip_behaviour(self):
"""No --no-compile, no extra pass: exactly what builds did before."""
assert parse_bytecode_mode("pip") == (False, None)

@pytest.mark.parametrize("empty", ["", None])
def test_missing_value_defaults_to_pip(self, empty):
assert parse_bytecode_mode(empty) == (False, None)

def test_none_skips_compilation_entirely(self):
no_compile, jobs = parse_bytecode_mode("none")
assert no_compile is True
assert jobs is None

def test_parallel_is_capped(self):
"""Bare 'parallel' must not spawn a worker per logical CPU.

Measured I/O-bound, so 8 workers on a 4-core/8-thread laptop only adds
contention. cpu_count() is logical, hence the cap.
"""
no_compile, jobs = parse_bytecode_mode("parallel")
assert no_compile is True
assert jobs == min(os.cpu_count() or 1, PARALLEL_DEFAULT_MAX)
assert 1 <= jobs <= PARALLEL_DEFAULT_MAX

def test_parallel_never_asks_for_zero_workers(self):
"""-j0 would hand the choice back to ProcessPoolExecutor, undoing the cap."""
assert parse_bytecode_mode("parallel")[1] >= 1

@pytest.mark.parametrize("value,jobs", [("parallel-1", 1), ("parallel-4", 4), ("parallel-16", 16)])
def test_explicit_n_is_taken_literally_and_uncapped(self, value, jobs):
"""An explicit N is a deliberate choice, so the cap must not apply."""
assert parse_bytecode_mode(value) == (True, jobs)

@pytest.mark.parametrize("value", ["PIP", " None ", "Parallel-4"])
def test_case_and_whitespace_tolerant(self, value):
parse_bytecode_mode(value) # must not raise

@pytest.mark.parametrize("value", ["parallel-", "parallel-x", "quick", "no", "-j4",
"parallel-4x", "parallel-0"])
def test_rejects_nonsense_loudly(self, value):
"""A typo in the TOML must fail the build, not silently ship no .pyc."""
with pytest.raises(ValueError, match="--bytecode"):
parse_bytecode_mode(value)

def test_only_pip_mode_lets_pip_compile(self):
"""Every non-default mode has to pass --no-compile, or work is done twice."""
for mode in ("none", "parallel", "parallel-2"):
assert parse_bytecode_mode(mode)[0] is True
65 changes: 64 additions & 1 deletion winpython/build_winpython.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,53 @@ def pip_install(python_exe: Path, req_file: str, constraints: str, find_links: s
else:
log_section(f"No {label} specified/skipped")

PARALLEL_DEFAULT_MAX = 4 # bare "parallel" never goes above this


def parse_bytecode_mode(value: str):
"""Interpret --bytecode. Returns (skip pip's inline compile, compileall jobs).

pip pip byte-compiles inline as it always has (default, unchanged)
none no .pyc at all -- for throwaway builds: pylock, size pre-check
parallel pip --no-compile, then one compileall pass over
min(cpu_count, PARALLEL_DEFAULT_MAX) workers
parallel-N same, with exactly N workers and no cap

os.cpu_count() reports logical CPUs, so on a 4-core/8-thread laptop an
uncapped default would start 8 workers for a job that measured I/O-bound
rather than CPU-bound. Cap the default and let -N override deliberately.
"""
mode = (value or "pip").strip().lower()
if mode == "pip":
return False, None
if mode == "none":
return True, None
if mode == "parallel":
return True, min(os.cpu_count() or 1, PARALLEL_DEFAULT_MAX)
if mode.startswith("parallel-") and mode[9:].isdigit() and int(mode[9:]) > 0:
return True, int(mode[9:])
raise ValueError(f"--bytecode must be pip, none, parallel or parallel-N (got {value!r})")


def compile_bytecode(target_python: Path, jobs: int):
"""Byte-compile site-packages in one pass, using the *target* interpreter.

pip compiles serially while installing; doing it afterwards lets it run
across cores. Failures stay non-fatal: some packages ship modules that are
not importable on this Python, and pip tolerates those too.

-W ignore matches what pip does. pip wraps its own byte-compilation in
warnings.filterwarnings("ignore") (see pip/_internal/operations/install/
wheel.py), so the SyntaxWarnings that plenty of packages carry -- invalid
escape sequences, `is` against a literal -- never reach the build log.
Without this, moving the compile out of pip would surface all of them.
"""
site_packages = target_python.parent / "Lib" / "site-packages"
log_section(f"Byte-compiling {site_packages} (-j{jobs})")
run_command([str(target_python), "-W", "ignore", "-m", "compileall",
"-q", f"-j{jobs}", str(site_packages)], check=False)


def patch_winpython(python_exe):
cmd = [
str(python_exe), "-c",
Expand Down Expand Up @@ -176,6 +223,13 @@ def main():
parser.add_argument('--log-dir', default='WinPython_build_logs', help='Directory for logs')
parser.add_argument('--mandatory-req', help='Mandatory requirements file')
parser.add_argument('--wheelhousereq', help='Wheelhouse requirements file')
parser.add_argument('--bytecode', default='pip',
help="byte-compilation: 'pip' (inline, the default and what ships), "
"'none' (no .pyc -- for throwaway builds such as pylock generation "
"or a size pre-check), 'parallel' (pip --no-compile, then one "
f"compileall pass over min(cpu_count, {PARALLEL_DEFAULT_MAX}) workers), "
"or 'parallel-N' for exactly N workers. cpu_count is logical CPUs, "
"so on a 4-core/8-thread machine 'parallel' uses 4, not 8")
parser.add_argument('--create-installer', default='', help='default installer to create')
args = parser.parse_args()

Expand Down Expand Up @@ -214,15 +268,24 @@ def main():

log_section("🙏 Step 3: install requirements")

no_compile, compile_jobs = parse_bytecode_mode(args.bytecode)
pip_options = ["--force-reinstall"] + (["--no-compile"] if no_compile else [])
for label, req in [
("Mandatory", args.mandatory_req),
("Main", args.requirements),
]:
pip_install(target_python, req, args.constraints, args.find_links, label, ["--force-reinstall"])
pip_install(target_python, req, args.constraints, args.find_links, label, pip_options)

log_section("🙏 Step 4: Patch Winpython")
patch_winpython(target_python)

# after patching, so the patched sources are the ones compiled
if compile_jobs is not None:
log_section("🙏 Step 4b: byte-compile")
compile_bytecode(target_python, compile_jobs)
elif no_compile:
log_section("🙏 Step 4b: byte-compile skipped (--bytecode none)")

log_section(f"🙏 Step 5: install wheelhouse requirements {args.wheelhousereq}")
if args.wheelhousereq:
process_wheelhouse_requirements(target_python, winpydirbase, args, file_postfix)
Expand Down
Loading