Files
paperless-ngx/docs/superpowers/plans/2026-06-16-export-zip-compression.md
T
2026-07-20 14:21:29 -07:00

32 KiB
Raw Blame History

Export Zip Compression Control Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add --zip-compression {stored,deflated,bzip2,lzma,zstd} and --zip-compression-level N flags to document_exporter, threaded into ZipExportSink, with import-side safety for codecs the running Python can't read.

Architecture: A new pure-data module documents/export/compression.py owns the method↔constant map, per-method level bounds, the runtime availability probe, and a compress-type readability check. ZipExportSink gains compression/compresslevel constructor params. The command validates flags up front (fail-fast CommandError) and constructs the sink; the importer pre-checks entry compress types before extracting.

Tech Stack: Python ≥3.11 (zstd only on 3.14+), zipfile, compression.zstd (PEP 784), pytest + pytest-mock + factory-boy. Backend tests run on the Linux VM (Python 3.11 — zstd positive tests are skipif-guarded); ruff runs locally.

Spec: docs/superpowers/specs/2026-06-16-export-zip-compression-design.md

PREREQUISITE: The base refactor docs/superpowers/plans/2026-06-16-export-sink-architecture.md MUST be merged first. This plan assumes src/documents/export/sinks.py exists with ZipExportSink(target, zip_name, *, delete=False) opening its ZipFile in _open().


Verified facts (CPython 3.14.3, via uv run --python 3.14 --no-project)

  • Constants: ZIP_STORED=0, ZIP_DEFLATED=8, ZIP_BZIP2=12, ZIP_LZMA=14, ZIP_ZSTANDARD=93 (zstd added 3.14; absent on < 3.14).
  • ZipFile(file, "w", compression=…, compresslevel=…) applies both as the default for every write/writestr — no per-entry args needed (verified).
  • Level bounds: deflated 09, bzip2 19, lzma/stored ignore level, zstd -131072…22 (compression.zstd.CompressionParameter.compression_level.bounds() == (-131072, 22)).
  • An invalid level fails at the first write (ValueError: Invalid initialization option / compresslevel must be between 1 and 9), plus GC-time AttributeError noise on close — hence up-front validation.
  • zstd is backed by compression.zstd; zipfile raises RuntimeError if it's unavailable.

Conventions for every task

  • Run backend tests on the VM: bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "<targets>" (never locally).
  • Lint locally: ruff check <paths> && ruff format <paths> (global ruff, not uv run).
  • Tests are pytest-style: classes, @pytest.mark.django_db on the class only where DB is needed (the compression.py and sink tests need no DB), factory-boy, mocker, parametrize, full type annotations.
  • The VM runs Python 3.11, so zstd positive tests must be @pytest.mark.skipif(...)-guarded; they will simply not run there. zstd rejection tests (the < 3.14 path) DO run on the VM.

File structure

  • Create src/documents/export/compression.py — method map, CLI choices, level bounds, compression_available(), level_error(), compress_type_readable(), unreadable_method_names(). Pure, no Django.
  • Create src/documents/tests/export/test_compression.py — unit tests for the above.
  • Modify src/documents/export/sinks.pyZipExportSink.__init__ gains compression/compresslevel; _open() passes them to ZipFile.
  • Modify src/documents/tests/export/test_sinks.py — assert the chosen compress_type is applied.
  • Modify src/documents/management/commands/document_exporter.py — add the two CLI flags, up-front validation, and pass resolved values to ZipExportSink.
  • Modify src/documents/tests/test_management_exporter.py — flag validation + default-unchanged tests.
  • Modify src/documents/management/commands/document_importer.py — pre-extract compress-type check.
  • Modify src/documents/tests/test_management_importer.py — unsupported-codec → CommandError.
  • Modify docs/administration.md — document both flags + zstd portability caveat.

Task 1: documents/export/compression.py (pure compression policy)

Files:

  • Create: src/documents/export/compression.py

  • Test: src/documents/tests/export/test_compression.py

  • Step 1: Write the failing tests

Create src/documents/tests/export/test_compression.py:

import sys
import zipfile

import pytest

from documents.export import compression


class TestCompressionMethods:
    def test_choices_always_include_zstd(self) -> None:
        # zstd is offered regardless of runtime; availability is checked separately
        assert compression.COMPRESSION_CHOICES == (
            "stored",
            "deflated",
            "bzip2",
            "lzma",
            "zstd",
        )

    @pytest.mark.parametrize(
        ("name", "constant"),
        [
            ("stored", zipfile.ZIP_STORED),
            ("deflated", zipfile.ZIP_DEFLATED),
            ("bzip2", zipfile.ZIP_BZIP2),
            ("lzma", zipfile.ZIP_LZMA),
        ],
    )
    def test_method_maps_to_zipfile_constant(self, name: str, constant: int) -> None:
        assert compression.COMPRESSION_METHODS[name] == constant

    def test_stored_and_deflated_always_available(self) -> None:
        assert compression.compression_available("stored")
        assert compression.compression_available("deflated")

    def test_zstd_availability_tracks_runtime(self) -> None:
        expected: bool = sys.version_info >= (3, 14)
        assert compression.compression_available("zstd") == expected


class TestLevelError:
    @pytest.mark.parametrize(
        ("method", "level"),
        [
            ("deflated", 0),
            ("deflated", 9),
            ("bzip2", 1),
            ("bzip2", 9),
            ("deflated", None),
            ("stored", None),
        ],
    )
    def test_valid_levels_return_none(self, method: str, level: int | None) -> None:
        assert compression.level_error(method, level) is None

    @pytest.mark.parametrize(
        ("method", "level"),
        [
            ("deflated", 10),
            ("deflated", -1),
            ("bzip2", 0),
            ("bzip2", 10),
        ],
    )
    def test_out_of_range_levels_return_message(
        self,
        method: str,
        level: int,
    ) -> None:
        msg: str | None = compression.level_error(method, level)
        assert msg is not None
        assert "between" in msg

    @pytest.mark.parametrize("method", ["stored", "lzma"])
    def test_level_on_levelless_method_is_rejected(self, method: str) -> None:
        msg: str | None = compression.level_error(method, 5)
        assert msg is not None
        assert "no effect" in msg


class TestCompressTypeReadable:
    @pytest.mark.parametrize("ct", [zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED])
    def test_stored_and_deflated_always_readable(self, ct: int) -> None:
        assert compression.compress_type_readable(ct)

    def test_zstd_compress_type_readability_tracks_runtime(self) -> None:
        # 93 = ZIP_ZSTANDARD; 20 = legacy zstd method id (read-only)
        expected: bool = sys.version_info >= (3, 14)
        assert compression.compress_type_readable(93) == expected
        assert compression.compress_type_readable(20) == expected

    def test_unknown_compress_type_is_unreadable(self) -> None:
        assert not compression.compress_type_readable(9999)

    def test_unreadable_method_names_lists_methods(self) -> None:
        # An unknown method id maps to no name and is reported generically.
        names: set[str] = compression.unreadable_method_names({9999})
        assert names == {"method 9999"}
  • Step 2: Run to verify it fails

Run: bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_compression.py -v" Expected: FAIL with ModuleNotFoundError: No module named 'documents.export.compression'.

  • Step 3: Implement compression.py

Create src/documents/export/compression.py:

from __future__ import annotations

import importlib
import zipfile

# ZIP_ZSTANDARD exists only on Python 3.14+ (PEP 784). None elsewhere.
ZSTD: int | None = getattr(zipfile, "ZIP_ZSTANDARD", None)

# CLI choices are fixed across runtimes so argparse never hides zstd; runtime
# availability is enforced separately in compression_available().
COMPRESSION_CHOICES: tuple[str, ...] = (
    "stored",
    "deflated",
    "bzip2",
    "lzma",
    "zstd",
)

# Method name -> zipfile compression constant (zstd only when supported).
COMPRESSION_METHODS: dict[str, int] = {
    "stored": zipfile.ZIP_STORED,
    "deflated": zipfile.ZIP_DEFLATED,
    "bzip2": zipfile.ZIP_BZIP2,
    "lzma": zipfile.ZIP_LZMA,
}
if ZSTD is not None:
    COMPRESSION_METHODS["zstd"] = ZSTD

# Inclusive (min, max) level bounds per method; None => level not applicable.
# Verified on CPython 3.14.3.
LEVEL_BOUNDS: dict[str, tuple[int, int] | None] = {
    "stored": None,
    "deflated": (0, 9),
    "bzip2": (1, 9),
    "lzma": None,
    "zstd": (-131072, 22),
}

# zipfile compress_type id -> method name. 93 = current zstd id, 20 = legacy
# zstd id that zipfile can still read.
_COMPRESS_TYPE_TO_METHOD: dict[int, str] = {
    zipfile.ZIP_STORED: "stored",
    zipfile.ZIP_DEFLATED: "deflated",
    zipfile.ZIP_BZIP2: "bzip2",
    zipfile.ZIP_LZMA: "lzma",
    93: "zstd",
    20: "zstd",
}


def compression_available(method: str) -> bool:
    """Whether the running interpreter can actually use the given method."""
    if method in ("stored", "deflated"):
        # zlib is a hard CPython dependency; stored needs nothing.
        return True
    if method == "bzip2":
        return _module_importable("bz2")
    if method == "lzma":
        return _module_importable("lzma")
    if method == "zstd":
        return ZSTD is not None and _module_importable("compression.zstd")
    return False


def _module_importable(name: str) -> bool:
    try:
        importlib.import_module(name)
    except ImportError:
        return False
    return True


def level_error(method: str, level: int | None) -> str | None:
    """Return a human message if (method, level) is invalid, else None."""
    if level is None:
        return None
    bounds = LEVEL_BOUNDS[method]
    if bounds is None:
        return f"--zip-compression-level has no effect for '{method}'"
    low, high = bounds
    if not (low <= level <= high):
        return (
            f"--zip-compression-level for '{method}' must be between "
            f"{low} and {high}"
        )
    return None


def compress_type_readable(compress_type: int) -> bool:
    """Whether this interpreter can decompress an entry of the given type."""
    method = _COMPRESS_TYPE_TO_METHOD.get(compress_type)
    if method is None:
        return False
    return compression_available(method)


def unreadable_method_names(compress_types: set[int]) -> set[str]:
    """Map a set of compress_type ids to human method names for error messages."""
    names: set[str] = set()
    for ct in compress_types:
        names.add(_COMPRESS_TYPE_TO_METHOD.get(ct, f"method {ct}"))
    return names
  • Step 4: Run to verify it passes

Run: bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_compression.py -v" Expected: PASS (on the 3.11 VM, test_zstd_availability_tracks_runtime and test_zstd_compress_type_readability_tracks_runtime assert False).

  • Step 5: Lint

Run: ruff check src/documents/export/compression.py src/documents/tests/export/test_compression.py && ruff format src/documents/export/compression.py src/documents/tests/export/test_compression.py Expected: no errors.

  • Step 6: Commit
git add src/documents/export/compression.py src/documents/tests/export/test_compression.py
git commit -m "Feature: add export compression policy module"

Task 2: ZipExportSink accepts compression method + level

Files:

  • Modify: src/documents/export/sinks.py

  • Test: src/documents/tests/export/test_sinks.py

  • Step 1: Write the failing test

Append to src/documents/tests/export/test_sinks.py (the top-of-file block already imports zipfile, Path, pytest, ZipExportSink, StreamingManifestWriter from the base-refactor plan):

class TestZipExportSinkCompression:
    @pytest.fixture()
    def source_file(self, tmp_path: Path) -> Path:
        src: Path = tmp_path / "src" / "doc.pdf"
        src.parent.mkdir(parents=True)
        src.write_bytes(b"PDF-CONTENT" * 100)
        return src

    @pytest.mark.parametrize(
        ("method", "constant"),
        [
            ("stored", zipfile.ZIP_STORED),
            ("deflated", zipfile.ZIP_DEFLATED),
            ("bzip2", zipfile.ZIP_BZIP2),
            ("lzma", zipfile.ZIP_LZMA),
        ],
    )
    def test_compression_method_is_applied_to_file_entries(
        self,
        tmp_path: Path,
        source_file: Path,
        method: str,
        constant: int,
    ) -> None:
        target: Path = tmp_path / "out"
        target.mkdir()
        with ZipExportSink(
            target,
            "export",
            delete=False,
            compression=constant,
        ) as sink:
            sink.add_file(source_file, "doc.pdf")
        with zipfile.ZipFile(target / "export.zip") as zf:
            info = zf.getinfo("doc.pdf")
            assert info.compress_type == constant

    def test_compressing_method_beats_stored(
        self,
        tmp_path: Path,
        source_file: Path,
    ) -> None:
        # Robust size invariant: a compressing method must be <= stored on
        # compressible content (avoids flaky level-9-vs-level-1 comparisons).
        sizes: dict[str, int] = {}
        for name, constant in (("stored", zipfile.ZIP_STORED), ("deflated", zipfile.ZIP_DEFLATED)):
            target: Path = tmp_path / name
            target.mkdir()
            with ZipExportSink(target, "export", delete=False, compression=constant) as sink:
                sink.add_file(source_file, "doc.pdf")
            sizes[name] = (target / "export.zip").stat().st_size
        assert sizes["deflated"] <= sizes["stored"]
  • Step 2: Run to verify it fails

Run: bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_sinks.py::TestZipExportSinkCompression -v" Expected: FAIL with TypeError: __init__() got an unexpected keyword argument 'compression'.

  • Step 3: Add the params to ZipExportSink

In src/documents/export/sinks.py, change ZipExportSink.__init__ to accept the new keyword-only params and store them, and pass them in _open():

    def __init__(
        self,
        target: Path,
        zip_name: str,
        *,
        delete: bool = False,
        compression: int = zipfile.ZIP_DEFLATED,
        compresslevel: int | None = None,
    ) -> None:
        self._target = target.resolve()
        self._zip_path = (self._target / zip_name).with_suffix(".zip")
        self._tmp_path = self._zip_path.with_name(self._zip_path.name + ".tmp")
        self._delete = delete
        self._compression = compression
        self._compresslevel = compresslevel
        self._zip: zipfile.ZipFile | None = None
        self._dirs: set[str] = set()
        self._pending_manifest: tuple[Path, str] | None = None
        self._stream_open = False

And in _open():

    def _open(self) -> None:
        settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
        self._zip = zipfile.ZipFile(
            self._tmp_path,
            "w",
            compression=self._compression,
            compresslevel=self._compresslevel,
            allowZip64=True,
        )
  • Step 4: Run to verify it passes

Run: bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_sinks.py -v" Expected: PASS (all sink tests, including the four method params and the size invariant). bzip2/lzma are present on the VM's CPython, so those params pass.

  • Step 5: Lint

Run: ruff check src/documents/export/sinks.py && ruff format src/documents/export/sinks.py Expected: no errors.

  • Step 6: Commit
git add src/documents/export/sinks.py src/documents/tests/export/test_sinks.py
git commit -m "Feature: ZipExportSink accepts compression method and level"

Task 3: Wire CLI flags + validation into document_exporter

Files:

  • Modify: src/documents/management/commands/document_exporter.py

  • Test: src/documents/tests/test_management_exporter.py

  • Step 1: Add the argparse flags

In document_exporter.py, add the import near the other documents.export import:

from documents.export.compression import COMPRESSION_CHOICES
from documents.export.compression import COMPRESSION_METHODS
from documents.export.compression import compression_available
from documents.export.compression import level_error
from documents.export.compression import ZSTD

In add_arguments, after the --zip-name argument, add:

        parser.add_argument(
            "--zip-compression",
            choices=COMPRESSION_CHOICES,
            default=None,
            help=(
                "Compression method for the export zip (requires --zip). "
                "Default: deflated. 'zstd' requires Python 3.14+ on both the "
                "exporting and importing machine."
            ),
        )

        parser.add_argument(
            "--zip-compression-level",
            type=int,
            default=None,
            help=(
                "Compression level for the export zip (requires --zip). "
                "deflated: 0-9, bzip2: 1-9, zstd: -131072..22; ignored for "
                "stored/lzma."
            ),
        )
  • Step 2: Read + validate the flags in handle()

In handle(), after the existing --compare-* + --zip guard, add the compression flag handling. Insert before the sink construction:

        zip_compression: str | None = options["zip_compression"]
        zip_compression_level: int | None = options["zip_compression_level"]

        if not self.zip_export and (
            zip_compression is not None or zip_compression_level is not None
        ):
            raise CommandError(
                "--zip-compression and --zip-compression-level require --zip",
            )

        compression_method = zip_compression or "deflated"
        if self.zip_export:
            if not compression_available(compression_method):
                if compression_method == "zstd" and ZSTD is None:
                    raise CommandError(
                        "zstd compression requires Python 3.14 or newer",
                    )
                raise CommandError(
                    f"Compression method '{compression_method}' is not "
                    f"available on this Python runtime",
                )
            level_msg = level_error(compression_method, zip_compression_level)
            if level_msg is not None:
                raise CommandError(level_msg)
  • Step 3: Pass the resolved values into ZipExportSink

Change the ZipExportSink(...) construction in handle() to:

        if self.zip_export:
            sink = ZipExportSink(
                self.target,
                options["zip_name"],
                delete=self.delete,
                compression=COMPRESSION_METHODS[compression_method],
                compresslevel=zip_compression_level,
            )
        else:
            sink = DirectoryExportSink(
                self.target,
                compare_checksums=self.compare_checksums,
                compare_json=self.compare_json,
                delete=self.delete,
            )
  • Step 4: Write the command-level tests

Add to the TestExportImport class in src/documents/tests/test_management_exporter.py (imports call_command, CommandError, ZipFile, timezone already present):

    def test_compression_flags_require_zip(self) -> None:
        for args in (
            ["--zip-compression", "lzma"],
            ["--zip-compression-level", "5"],
        ):
            with self.assertRaises(CommandError):
                call_command(
                    "document_exporter",
                    self.target,
                    *args,
                    skip_checks=True,
                )

    def test_zip_compression_level_out_of_range_raises(self) -> None:
        with self.assertRaises(CommandError):
            call_command(
                "document_exporter",
                self.target,
                "--zip",
                "--zip-compression",
                "deflated",
                "--zip-compression-level",
                "99",
                skip_checks=True,
            )

    def test_zip_compression_level_rejected_for_stored(self) -> None:
        with self.assertRaises(CommandError):
            call_command(
                "document_exporter",
                self.target,
                "--zip",
                "--zip-compression",
                "stored",
                "--zip-compression-level",
                "5",
                skip_checks=True,
            )

    def test_zip_lzma_compression_round_trips(self) -> None:
        call_command(
            "document_exporter",
            self.target,
            "--zip",
            "--zip-compression",
            "lzma",
            skip_checks=True,
        )
        expected = str(
            self.target / f"export-{timezone.localdate().isoformat()}.zip",
        )
        self.assertIsFile(expected)
        with ZipFile(expected) as zip_file:
            info = zip_file.getinfo("manifest.json")
            # manifest.json carries the chosen method; deflated is the default
            self.assertEqual(info.compress_type, 14)  # ZIP_LZMA

    def test_default_zip_uses_deflate(self) -> None:
        call_command(
            "document_exporter",
            self.target,
            "--zip",
            skip_checks=True,
        )
        expected = str(
            self.target / f"export-{timezone.localdate().isoformat()}.zip",
        )
        with ZipFile(expected) as zip_file:
            info = zip_file.getinfo("manifest.json")
            self.assertEqual(info.compress_type, 8)  # ZIP_DEFLATED
  • Step 5: Run the tests

Run: bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/test_management_exporter.py -v" Expected: PASS — the new tests plus all existing exporter tests stay green.

  • Step 6: Lint

Run: ruff check src/documents/management/commands/document_exporter.py src/documents/tests/test_management_exporter.py && ruff format src/documents/management/commands/document_exporter.py src/documents/tests/test_management_exporter.py Expected: no errors.

  • Step 7: Commit
git add src/documents/management/commands/document_exporter.py src/documents/tests/test_management_exporter.py
git commit -m "Feature: add --zip-compression and --zip-compression-level flags"

Task 4: Importer pre-check for unreadable codecs

Files:

  • Modify: src/documents/management/commands/document_importer.py

  • Test: src/documents/tests/test_management_importer.py

  • Step 1: Write the failing test

The importer test file src/documents/tests/test_management_importer.py is TestCase-style (class TestCommandImport(... TestCase), self.assertRaises, DirectoriesMixin gives self.dirs.scratch_dir). Match that style. Add this method to TestCommandImport. It builds a valid zip and patches the readability probe so the check fires deterministically on any runtime:

    def test_import_rejects_unreadable_compression(self) -> None:
        """
        GIVEN:
            - A zip archive with an entry whose compression this Python can't read
        WHEN:
            - Import is attempted
        THEN:
            - A CommandError naming the issue is raised, before extraction
        """
        import zipfile
        from unittest import mock

        archive = Path(self.dirs.scratch_dir) / "export.zip"
        with zipfile.ZipFile(archive, "w") as zf:
            zf.writestr("manifest.json", "[]")

        with mock.patch(
            "documents.management.commands.document_importer.compress_type_readable",
            return_value=False,
        ):
            with self.assertRaises(CommandError) as e:
                call_command(
                    "document_importer",
                    str(archive),
                    "--no-progress-bar",
                    skip_checks=True,
                )
        self.assertIn("compression", str(e.exception))
  • Step 2: Run to verify it fails

Run: bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/test_management_importer.py -k unreadable_compression -v" Expected: FAIL — no pre-check exists yet, so the import proceeds (or fails with a different error).

  • Step 3: Implement the pre-check

In document_importer.py, add the import:

from documents.export.compression import compress_type_readable
from documents.export.compression import unreadable_method_names

Find the zip-handling block (around document_importer.py:453):

                with ZipFile(self.source) as zf:
                    zf.extractall(tmp_dir)

Replace it with a pre-check before extraction:

                with ZipFile(self.source) as zf:
                    unsupported = {
                        info.compress_type
                        for info in zf.infolist()
                        if not compress_type_readable(info.compress_type)
                    }
                    if unsupported:
                        names = ", ".join(sorted(unreadable_method_names(unsupported)))
                        raise CommandError(
                            f"This archive uses compression this Python cannot "
                            f"read ({names}). zstd archives require Python 3.14+.",
                        )
                    zf.extractall(tmp_dir)

Confirm CommandError is imported in document_importer.py (it is used elsewhere; if not, add from django.core.management.base import CommandError).

  • Step 4: Run to verify it passes

Run: bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/test_management_importer.py -v" Expected: PASS — the new test plus all existing importer tests (normal deflated/stored archives still import).

  • Step 5: Lint

Run: ruff check src/documents/management/commands/document_importer.py src/documents/tests/test_management_importer.py && ruff format src/documents/management/commands/document_importer.py src/documents/tests/test_management_importer.py Expected: no errors.

  • Step 6: Commit
git add src/documents/management/commands/document_importer.py src/documents/tests/test_management_importer.py
git commit -m "Feature: importer rejects archives with unreadable compression"

Task 5: Document the flags

Files:

  • Modify: docs/administration.md

  • Step 1: Add the flags to the option list

In docs/administration.md, update the usage block (around line 257) to include the new flags:

document_exporter target [-c] [-d] [-f] [-na] [-nt] [-p] [-sm] [-z]

optional arguments:
-c,  --compare-checksums
-cj, --compare-json
-d,  --delete
-f,  --use-filename-format
-na, --no-archive
-nt, --no-thumbnail
-p,  --use-folder-prefix
-sm, --split-manifest
-z,  --zip
-zn, --zip-name
--zip-compression
--zip-compression-level
--data-only
--no-progress-bar
--passphrase
  • Step 2: Add the prose

After the -z/--zip paragraph (around line 330), add:

The compression method for the zip can be set with `--zip-compression`
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
`--zip-compression-level` (deflated: 09, bzip2: 19, zstd: -13107222; ignored
for `stored` and `lzma`). Both options require `--zip`.

!!! warning

    `zstd` compression requires Python 3.14 or newer on **both** the machine
    creating the export and any machine importing it. An archive compressed with
    `zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
    imported on a runtime that lacks the codec; the importer will refuse it with
    a clear error. The default `deflated` is universally readable.
  • Step 3: Verify the docs build is not broken (lint markdown)

Run: ruff check docs/ 2>/dev/null; echo "docs are markdown; rely on prettier pre-commit" (No code to test. The prettier pre-commit hook will reformat on commit.)

  • Step 4: Commit
git add docs/administration.md
git commit -m "Docs: document --zip-compression and --zip-compression-level"

Task 6: Final verification

Files: none (verification only).

  • Step 1: Full backend suites on the VM

Run: bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/ src/documents/tests/test_management_exporter.py src/documents/tests/test_management_importer.py -v" Expected: PASS, no failures.

  • Step 2: Spot-check the zstd happy path on Python 3.14 (cannot run under Django on the 3.11 VM)

The zstd positive round-trip can't run in the 3.11 test env. Confirm the policy module behaves on a real 3.14 interpreter with a standalone check (no Django needed):

Run:

uv run --python 3.14 --no-project python -c "import sys; sys.path.insert(0,'src'); import django; print('skip')" 2>/dev/null || \
uv run --python 3.14 --no-project python -c "
import zipfile, io
from compression.zstd import CompressionParameter as CP
print('zstd const', zipfile.ZIP_ZSTANDARD, 'bounds', CP.compression_level.bounds())
buf = io.BytesIO()
with zipfile.ZipFile(buf,'w',compression=zipfile.ZIP_ZSTANDARD,compresslevel=19) as zf:
    zf.writestr('a.txt','x'*1000)
with zipfile.ZipFile(buf) as zf:
    assert zf.getinfo('a.txt').compress_type == zipfile.ZIP_ZSTANDARD
    assert zf.read('a.txt') == b'x'*1000
print('zstd round-trip OK')
"

Expected: prints zstd const 93 bounds (-131072, 22) and zstd round-trip OK. This validates the constant, bounds, and that a zstd archive round-trips — the parts the 3.11 CI cannot exercise.

  • Step 3: Type-check on the VM (pyrefly)
tar czf - src pyproject.toml uv.lock .pyrefly-baseline.json | ssh -o BatchMode=yes -p 2244 trenton@localhost 'tar xzf - -C ~/projects/paperless-ngx'
ssh -o BatchMode=yes -p 2244 trenton@localhost 'bash -lc "cd ~/projects/paperless-ngx && uv run pyrefly check"'

Expected: no new type errors beyond the baseline. (Note: import compression.zstd is guarded behind importlib.import_module, so it is never statically resolved on the 3.11 baseline.)

  • Step 4: Final lint

Run: ruff check src/documents/export/ src/documents/management/commands/document_exporter.py src/documents/management/commands/document_importer.py && ruff format --check src/documents/export/ src/documents/management/commands/document_exporter.py src/documents/management/commands/document_importer.py Expected: clean.


Notes for the implementer

  • Default behavior is unchanged: with no flags, the sink is constructed with compression=ZIP_DEFLATED, compresslevel=None — byte-method-identical to today (shutil.make_archive used ZIP_DEFLATED with no level). test_default_zip_uses_deflate pins this.
  • zstd availability is gated three ways and never imported statically: the constant via getattr, the codec via importlib.import_module("compression.zstd"), and the CLI value rejected with a friendly message on < 3.14. The choices list always contains zstd so argparse doesn't hide it.
  • The importer pre-check is the safety net for portability foot-guns — without it an unreadable entry raises a bare NotImplementedError mid-extractall. The check runs on infolist() (metadata only) before any extraction.
  • Why --zip-compression defaults to None, not "deflated": so handle() can detect "user passed it without --zip" and fail fast. The effective default is resolved as zip_compression or "deflated".