Skip to content
Open
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
5 changes: 3 additions & 2 deletions src/manage/install_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ def validate_package(install, dest, *, delete=True):


def extract_package(package, prefix, calculate_dest=Path, *, on_progress=None, repair=False):
import shutil
import zipfile

LOGGER.debug("Starting extract of %s to %s", package, prefix)
Expand Down Expand Up @@ -205,8 +206,8 @@ def _calc(prefix, filename, calculate_dest=calculate_dest):
warn_overwrite.append(dest)
continue
ensure_tree(dest)
with open(dest, "wb") as f:
f.write(zf.read(member))
with zf.open(member) as source, open(dest, "wb") as f:
shutil.copyfileobj(source, f, length=1024 * 1024)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How badly does it affect the maximum memory usage if we make this use 10MB instead of 1MB? I'd like our standard Python distributions to still only use one call per file, and the biggest file we include is (currently) around 7MB.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I measured this on Windows 11 with CPython 3.14.3, using the PR's actual extract_package function and changing only the copyfileobj buffer size in an in-memory benchmark variant.

For synthetic ZIP_DEFLATED archives containing one member each, the median tracemalloc peak across five extractions was:

Uncompressed member size 1 MiB buffer 10 MiB buffer Increase
7 MiB 3.34 MiB 17.32 MiB 13.98 MiB
64 MiB 3.78 MiB 30.67 MiB 26.90 MiB

Archive creation was outside the measured region. Each extraction used a fresh destination, and I verified the extracted contents afterward. The payload was a repeating bytes(range(256)) pattern, so these are highly compressible synthetic cases, not measurements of standard runtime packages. These figures measure peak traced Python allocations during extraction, not total process RSS; they include zipfile/decompression allocations as well as the copy buffer.

The 10 MiB buffer therefore costs more than just the extra 9 MiB of buffer capacity, but still bounds memory for oversized members. A 7 MiB file fits in one data-bearing read/write with that buffer (copyfileobj also performs a final EOF read).

Punisheroot has also posted complementary measurements using real 3.12/3.13/3.14 packages here: #409 (comment) . Their reported results likewise support 10 MiB as a compromise: single data-bearing reads for normal distribution files, with bounded memory for oversized files.

Based on these results, 10 MiB looks reasonable to me for the goal you described.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up with measurements using real official runtime packages rather than synthetic files.

I tested the AMD64 ZIP distributions for Python 3.12.10, 3.13.15, and 3.14.7 from the official Windows install-manager index, verifying each download against its published SHA-256. Together they contain 10,241 files and 376,099,043 uncompressed bytes; the largest member is 6,945,272 bytes.

Setup: Windows 11 (build 26200), CPython 3.14.3 AMD64. Each run used a fresh process and destination and extracted all three packages sequentially using this PR's extract_package function. The variants changed only the copy operation: original zf.read(), 1 MiB copyfileobj, or 10 MiB copyfileobj. There was one warm-up per variant, then five measured runs per variant in rotating order.

Variant Median extraction time Time range Median tracemalloc peak Median peak process working set
Original whole-member read 9.50 s 9.23–14.09 s 25.05 MiB 50.66 MiB
1 MiB buffer 11.91 s 9.67–15.57 s 7.33 MiB 40.86 MiB
10 MiB buffer 10.23 s 9.78–20.11 s 21.70 MiB 50.75 MiB

Moving from 1 MiB to 10 MiB increased the median traced peak by 14.38 MiB and peak working set by 9.88 MiB. Compared with the original, 10 MiB reduced traced allocations somewhat, but the process working-set peak was essentially unchanged on these normal-sized packages.

A separate instrumented extraction confirmed that all 10,164 non-empty files completed in exactly one data-bearing read with 10 MiB. There were 20,405 reads including EOF reads across all 10,241 files. This diagnostic run was excluded from the measurements above.

SHA-256 output manifests, including relative paths and per-file digests, matched across all 18 benchmark executions and the separate read-count run.

Limitations: downloads, imports, integrity hashing, and cleanup were excluded from extraction timing. tracemalloc was enabled during all timed runs, so timings include its overhead. Windows working-set peaks were read with GetProcessMemoryInfo immediately after extraction, before hashing, and include the interpreter/tracing machinery. OS caches were not flushed and background activity was not controlled. The timing ranges overlap substantially, so I would not claim a reliable speed improvement from this run.

This confirms that 10 MiB meets the single-data-read goal for these distributions, at the cost of giving up much of the normal-package memory saving of 1 MiB. Oversized members remain a separate case, covered by the earlier synthetic measurements. No production code has been changed for this benchmark.

on_progress(100)

if warn_out_of_prefix:
Expand Down
42 changes: 42 additions & 0 deletions tests/test_install_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,48 @@
from manage.logging import LOGGER


@pytest.mark.parametrize("suffix", [".zip", ".nupkg"])
@pytest.mark.parametrize("repair", [False, True])
def test_extract_package_streaming(tmp_path, monkeypatch, suffix, repair):
"""Extract in bounded reads while preserving overwrite and repair behavior."""
import zipfile

package = tmp_path / ("package" + suffix)
prefix = tmp_path / "install"
prefix.mkdir()
existing = prefix / "existing.txt"
existing.write_bytes(b"original")
data = bytes(range(256)) * 10000
archive_prefix = "tools/" if suffix == ".nupkg" else ""
with zipfile.ZipFile(package, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(archive_prefix + "nested/data.bin", data)
zf.writestr(archive_prefix + "empty.txt", b"")
zf.writestr(archive_prefix + "existing.txt", b"replacement")
if suffix == ".nupkg":
zf.writestr("metadata.txt", b"ignored")

reads = []
original_read = zipfile.ZipExtFile.read

def bounded_read(self, n=-1):
assert 0 < n <= 1024 * 1024
reads.append(n)
return original_read(self, n)

monkeypatch.setattr(zipfile.ZipExtFile, "read", bounded_read)
progress = []
IC.extract_package(package, prefix, calculate_dest=Path,
on_progress=progress.append, repair=repair)
assert (prefix / "nested/data.bin").read_bytes() == data
assert (prefix / "empty.txt").read_bytes() == b""
assert existing.read_bytes() == (b"replacement" if repair else b"original")
assert not (prefix / "metadata.txt").exists()
assert len(reads) >= 4
assert progress[0] == 0
assert 100 in progress
assert (None in progress) == (not repair)


def test_print_cli_shortcuts(patched_installs, assert_log, monkeypatch, tmp_path):
class Cmd:
scratch = {}
Expand Down