Ctrl K
ComunidadGuías…

simple_nas/app/zipstream.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Stream a directory as a ZIP archive without touching the disk.

stdlib zipfile writes to a non-seekable sink, so it emits data descriptors
instead of seeking back - exactly what a streaming HTTP response needs.

Rules that keep the archive valid under real-world conditions:
* preflight() walks the tree first so the caller can refuse with a proper
  status code before the first byte is sent;
* every source file is opened *before* its entry is created, so a file that
  vanishes mid-stream is skipped and listed in _MISSING.txt instead of
  corrupting the archive;
* if the running size exceeds the estimate by a margin, no further entries
  are added, ZIP-INCOMPLETE.txt explains why, and the archive is closed
  cleanly. A short but valid ZIP beats a truncated one.
"""

import os
import shutil
import time
import zipfile

SKIP_SUFFIX = (".part",)
CHUNK = 1024 * 1024


class TooBig(Exception):
    def __init__(self, total, count, limit_bytes, limit_files):
        super().__init__("archive too big")
        self.total, self.count, self.limit_bytes, self.limit_files = total, count, limit_bytes, limit_files


class _Sink:
    """Write target for ZipFile that hands the bytes to a generator."""

    def __init__(self):
        self.buf = bytearray()
        self.pos = 0

    def write(self, b):
        self.buf += b
        self.pos += len(b)
        return len(b)

    def flush(self):
        pass

    def tell(self):
        return self.pos

    def seekable(self):
        return False

    def take(self):
        out = bytes(self.buf)
        self.buf.clear()
        return out


def _wanted(name):
    return not name.startswith(".") and not name.endswith(SKIP_SUFFIX)


def walk(root):
    """(abs_path, arcname, size, mtime) for every regular file; symlinks skipped."""
    root = os.path.abspath(root)
    base = os.path.basename(root.rstrip(os.sep)) or "archive"
    for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
        dirnames[:] = sorted(d for d in dirnames
                             if _wanted(d) and not os.path.islink(os.path.join(dirpath, d)))
        rel_dir = os.path.relpath(dirpath, root)
        for fn in sorted(filenames):
            if not _wanted(fn):
                continue
            p = os.path.join(dirpath, fn)
            if os.path.islink(p):
                continue
            try:
                st = os.lstat(p)
            except OSError:
                continue
            arc = base if rel_dir == "." else os.path.join(base, rel_dir)
            arc = os.path.join(arc, fn).replace(os.sep, "/")
            yield p, arc, st.st_size, st.st_mtime


def preflight(root, limit_bytes, limit_files, max_seconds=8):
    """Total size and file count, or TooBig. Aborts the walk on the time budget
    (large USB trees) and treats that as too big to be safe."""
    total, count = 0, 0
    deadline = time.time() + max_seconds
    for _, _, size, _ in walk(root):
        total += size
        count += 1
        if total > limit_bytes or count > limit_files or time.time() > deadline:
            raise TooBig(total, count, limit_bytes, limit_files)
    return total, count


def _zinfo(arc, mtime, size, compress):
    lt = time.localtime(mtime)
    zi = zipfile.ZipInfo(arc, date_time=(max(1980, lt.tm_year), lt.tm_mon, lt.tm_mday, lt.tm_hour, lt.tm_min, lt.tm_sec))
    zi.compress_type = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED
    zi.file_size = size
    zi.external_attr = 0o644 << 16
    return zi


def stream(root, estimate_bytes=0, compress=False, margin=1.2):
    """Generator of bytes for the ZIP of `root`."""
    sink = _Sink()
    zf = zipfile.ZipFile(sink, "w", allowZip64=True)
    missing, written, truncated = [], 0, False
    hard_cap = int(estimate_bytes * margin) if estimate_bytes else None
    try:
        for path, arc, size, mtime in walk(root):
            if hard_cap is not None and written > hard_cap:
                truncated = True
                break
            try:
                src = open(path, "rb")           # open FIRST, then create the entry
            except OSError:
                missing.append(arc)
                continue
            with src:
                zi = _zinfo(arc, mtime, size, compress)
                try:
                    with zf.open(zi, "w", force_zip64=size >= 0x7FFFFFFF) as dst:
                        while True:
                            chunk = src.read(CHUNK)
                            if not chunk:
                                break
                            dst.write(chunk)
                            written += len(chunk)
                            if len(sink.buf) >= CHUNK:
                                yield sink.take()
                except OSError:
                    missing.append(arc)
            if sink.buf:
                yield sink.take()
        if missing:
            zf.writestr(_zinfo("_MISSING.txt", time.time(), 0, False),
                        "These files disappeared while the archive was being created:\n" + "\n".join(missing) + "\n")
        if truncated:
            zf.writestr(_zinfo("ZIP-INCOMPLETE.txt", time.time(), 0, False),
                        "The folder grew beyond the size checked at the start.\n"
                        "This archive was closed cleanly but does not contain every file.\n")
    finally:
        zf.close()
    if sink.buf:
        yield sink.take()