mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-21 02:03:30 +00:00
Feature: Allow selection of compression type and and level during export (#13661)
* Feature: Allow configuring the compression type and compression levels during export Building on the zip export improvements, this now allows users to further configure the zip to fit their needs. A simple stored zip for speed, or a high compression zstd for the smallest archive. Full validation of the method and levels at the command line Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
|
||||
from documents.export import compression
|
||||
|
||||
|
||||
class TestCompressionMethods:
|
||||
def test_choices_always_include_zstd(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The compression policy module's CLI choices list
|
||||
WHEN:
|
||||
- Read on any runtime
|
||||
THEN:
|
||||
- zstd is always present; availability is checked separately so
|
||||
argparse never hides it based on the current Python version
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
- A compression method name
|
||||
WHEN:
|
||||
- Looked up in COMPRESSION_METHODS
|
||||
THEN:
|
||||
- It maps to the matching zipfile compression constant
|
||||
"""
|
||||
assert compression.COMPRESSION_METHODS[name] == constant
|
||||
|
||||
def test_stored_and_deflated_always_available(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The stored and deflated compression methods
|
||||
WHEN:
|
||||
- Checked with compression_available()
|
||||
THEN:
|
||||
- Both are always available (zlib is a hard CPython dependency)
|
||||
"""
|
||||
assert compression.compression_available("stored")
|
||||
assert compression.compression_available("deflated")
|
||||
|
||||
def test_zstd_availability_tracks_runtime(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The zstd compression method
|
||||
WHEN:
|
||||
- Checked with compression_available() on this runtime
|
||||
THEN:
|
||||
- Availability matches whether Python is 3.14+
|
||||
"""
|
||||
expected: bool = sys.version_info >= (3, 14)
|
||||
assert compression.compression_available("zstd") == expected
|
||||
|
||||
def test_unimportable_module_reports_unavailable(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A compression method whose backing module fails to import
|
||||
(e.g. a minimal Python build without bz2/lzma compiled in)
|
||||
WHEN:
|
||||
- Checked with compression_available()
|
||||
THEN:
|
||||
- False is returned rather than the ImportError propagating
|
||||
"""
|
||||
mocker.patch(
|
||||
"documents.export.compression.importlib.import_module",
|
||||
side_effect=ImportError,
|
||||
)
|
||||
assert not compression.compression_available("bzip2")
|
||||
|
||||
|
||||
class TestLevelError:
|
||||
@pytest.mark.parametrize(
|
||||
("method", "level"),
|
||||
[
|
||||
("deflated", 0),
|
||||
("deflated", 9),
|
||||
("bzip2", 1),
|
||||
("bzip2", 9),
|
||||
("zstd", -22),
|
||||
("zstd", 22),
|
||||
("deflated", None),
|
||||
("stored", None),
|
||||
],
|
||||
)
|
||||
def test_valid_levels_return_none(self, method: str, level: int | None) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A method and a level within its valid bounds (or no level)
|
||||
WHEN:
|
||||
- Checked with level_error()
|
||||
THEN:
|
||||
- No error message is returned
|
||||
"""
|
||||
assert compression.level_error(method, level) is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "level"),
|
||||
[
|
||||
("deflated", 10),
|
||||
("deflated", -1),
|
||||
("bzip2", 0),
|
||||
("bzip2", 10),
|
||||
("zstd", -23),
|
||||
("zstd", 23),
|
||||
],
|
||||
)
|
||||
def test_out_of_range_levels_return_message(
|
||||
self,
|
||||
method: str,
|
||||
level: int,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A method and a level outside its valid bounds
|
||||
WHEN:
|
||||
- Checked with level_error()
|
||||
THEN:
|
||||
- An error message naming the valid range is returned
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
- A method that ignores compression level (stored, lzma)
|
||||
WHEN:
|
||||
- A level is passed to level_error() anyway
|
||||
THEN:
|
||||
- An error message noting the level has no effect is returned
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
- A stored or deflated compress_type id
|
||||
WHEN:
|
||||
- Checked with compress_type_readable()
|
||||
THEN:
|
||||
- It is always readable
|
||||
"""
|
||||
assert compression.compress_type_readable(ct)
|
||||
|
||||
def test_zstd_compress_type_readability_tracks_runtime(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- The zstd compress_type id (93, ZIP_ZSTANDARD)
|
||||
WHEN:
|
||||
- Checked with compress_type_readable() on this runtime
|
||||
THEN:
|
||||
- Readability matches whether Python is 3.14+
|
||||
"""
|
||||
expected: bool = sys.version_info >= (3, 14)
|
||||
assert compression.compress_type_readable(93) == expected
|
||||
|
||||
def test_unknown_compress_type_is_unreadable(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An unrecognized compress_type id
|
||||
WHEN:
|
||||
- Checked with compress_type_readable()
|
||||
THEN:
|
||||
- It is reported as unreadable
|
||||
"""
|
||||
assert not compression.compress_type_readable(9999)
|
||||
|
||||
def test_unreadable_method_names_lists_methods(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A set containing an unknown compress_type id
|
||||
WHEN:
|
||||
- Passed to unreadable_method_names()
|
||||
THEN:
|
||||
- It is reported generically as "method <id>"
|
||||
"""
|
||||
# An unknown method id maps to no name and is reported generically.
|
||||
names: set[str] = compression.unreadable_method_names({9999})
|
||||
assert names == {"method 9999"}
|
||||
@@ -5,6 +5,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
|
||||
from documents.export.sinks import DirectoryExportSink
|
||||
@@ -305,6 +306,48 @@ class TestZipExportSink:
|
||||
assert not (target / "export.zip").exists()
|
||||
|
||||
|
||||
class TestZipExportSinkCompression:
|
||||
@pytest.mark.parametrize(
|
||||
("method", "constant"),
|
||||
[
|
||||
("stored", zipfile.ZIP_STORED),
|
||||
("deflated", zipfile.ZIP_DEFLATED),
|
||||
("bzip2", zipfile.ZIP_BZIP2),
|
||||
("lzma", zipfile.ZIP_LZMA),
|
||||
],
|
||||
)
|
||||
def test_compression_and_level_forwarded_to_zipfile(
|
||||
self,
|
||||
mocker: pytest_mock.MockerFixture,
|
||||
tmp_path: Path,
|
||||
method: str,
|
||||
constant: int,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A ZipExportSink constructed with a compression method and level
|
||||
WHEN:
|
||||
- The sink is opened
|
||||
THEN:
|
||||
- zipfile.ZipFile is constructed with those values forwarded
|
||||
unchanged (whether ZipFile actually compresses is Python's own
|
||||
contract, not ours, so this checks the call args, not a real
|
||||
archive)
|
||||
"""
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
zip_cls = mocker.patch("documents.export.sinks.zipfile.ZipFile")
|
||||
sink = ZipExportSink(target, "export", compression=constant, compresslevel=5)
|
||||
sink._open()
|
||||
zip_cls.assert_called_once_with(
|
||||
mocker.ANY,
|
||||
"w",
|
||||
compression=constant,
|
||||
compresslevel=5,
|
||||
allowZip64=True,
|
||||
)
|
||||
|
||||
|
||||
class TestStreamContract:
|
||||
@pytest.fixture(params=["dir", "zip"])
|
||||
def sink(self, request: pytest.FixtureRequest, tmp_path: Path) -> ExportSink:
|
||||
|
||||
@@ -6,6 +6,8 @@ from datetime import timedelta
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from zipfile import ZIP_DEFLATED
|
||||
from zipfile import ZIP_LZMA
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
@@ -1078,6 +1080,197 @@ class TestExportImport(
|
||||
skip_checks=True,
|
||||
)
|
||||
|
||||
def test_compression_flags_require_zip(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A request to export without --zip
|
||||
WHEN:
|
||||
- --zip-compression or --zip-compression-level is passed anyway
|
||||
THEN:
|
||||
- A CommandError is raised (the flags are meaningless without --zip)
|
||||
"""
|
||||
cases = {
|
||||
"zip-compression": ["--zip-compression", "lzma"],
|
||||
"zip-compression-level": ["--zip-compression-level", "5"],
|
||||
}
|
||||
for case_id, args in cases.items():
|
||||
with self.subTest(case_id), self.assertRaises(CommandError):
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
*args,
|
||||
skip_checks=True,
|
||||
)
|
||||
|
||||
def test_zip_compression_level_out_of_range_raises(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A request to export to a zip file
|
||||
WHEN:
|
||||
- --zip-compression-level is outside the chosen method's valid range
|
||||
THEN:
|
||||
- A CommandError is raised
|
||||
"""
|
||||
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_levelless_method(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A request to export to a zip file with a compression method
|
||||
that ignores level entirely (stored, lzma)
|
||||
WHEN:
|
||||
- --zip-compression-level is also passed
|
||||
THEN:
|
||||
- A CommandError is raised
|
||||
"""
|
||||
for method in ("stored", "lzma"):
|
||||
with self.subTest(method), self.assertRaises(CommandError):
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
"--zip-compression",
|
||||
method,
|
||||
"--zip-compression-level",
|
||||
"5",
|
||||
skip_checks=True,
|
||||
)
|
||||
|
||||
def test_zstd_unavailable_raises_friendly_error(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A Python runtime without zstd support (< 3.14)
|
||||
WHEN:
|
||||
- --zip-compression zstd is requested
|
||||
THEN:
|
||||
- A CommandError naming the Python version requirement is raised
|
||||
|
||||
zstd availability is mocked rather than relying on the actual
|
||||
runtime: on a Python 3.14+ CI leg, ZSTD is not None, so without the
|
||||
mock this check is skipped and the command falls through into the
|
||||
real export, which fails on missing document files instead of
|
||||
raising the expected CommandError.
|
||||
"""
|
||||
with (
|
||||
mock.patch(
|
||||
"documents.management.commands.document_exporter.ZSTD",
|
||||
None,
|
||||
),
|
||||
mock.patch(
|
||||
"documents.management.commands.document_exporter.compression_available",
|
||||
return_value=False,
|
||||
),
|
||||
self.assertRaises(CommandError) as e,
|
||||
):
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
"--zip-compression",
|
||||
"zstd",
|
||||
skip_checks=True,
|
||||
)
|
||||
self.assertIn("3.14", str(e.exception))
|
||||
|
||||
def test_non_zstd_unavailable_raises_generic_error(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A Python runtime missing the module backing a non-zstd method
|
||||
(e.g. bz2/lzma not compiled in on a minimal build)
|
||||
WHEN:
|
||||
- That method is requested via --zip-compression
|
||||
THEN:
|
||||
- A CommandError is raised naming the method, not the
|
||||
zstd-specific "requires 3.14" message
|
||||
"""
|
||||
with (
|
||||
mock.patch(
|
||||
"documents.management.commands.document_exporter.compression_available",
|
||||
return_value=False,
|
||||
),
|
||||
self.assertRaises(CommandError) as e,
|
||||
):
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
"--zip-compression",
|
||||
"bzip2",
|
||||
skip_checks=True,
|
||||
)
|
||||
self.assertIn("bzip2", str(e.exception))
|
||||
self.assertNotIn("3.14", str(e.exception))
|
||||
|
||||
def test_zip_compression_flag_resolves_to_sink_constant(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A request to export to a zip file with --zip-compression lzma
|
||||
WHEN:
|
||||
- The export runs
|
||||
THEN:
|
||||
- ZipExportSink is constructed with the resolved ZIP_LZMA constant
|
||||
(whether zipfile actually compresses with the chosen method is
|
||||
Python's own contract, and ZipExportSink's own tests already
|
||||
cover the forwarding; what this command owns is resolving the
|
||||
CLI string to the right constant, so assert that resolution
|
||||
directly)
|
||||
"""
|
||||
with mock.patch(
|
||||
"documents.management.commands.document_exporter.ZipExportSink",
|
||||
) as sink_cls:
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
"--zip-compression",
|
||||
"lzma",
|
||||
skip_checks=True,
|
||||
)
|
||||
sink_cls.assert_called_once_with(
|
||||
mock.ANY,
|
||||
mock.ANY,
|
||||
delete=False,
|
||||
compression=ZIP_LZMA,
|
||||
compresslevel=None,
|
||||
)
|
||||
|
||||
def test_default_zip_compression_resolves_to_deflate(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A request to export to a zip file with no --zip-compression flag
|
||||
WHEN:
|
||||
- The export runs
|
||||
THEN:
|
||||
- ZipExportSink is constructed with the default ZIP_DEFLATED
|
||||
constant and compresslevel=None, matching pre-existing behavior
|
||||
"""
|
||||
with mock.patch(
|
||||
"documents.management.commands.document_exporter.ZipExportSink",
|
||||
) as sink_cls:
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
skip_checks=True,
|
||||
)
|
||||
sink_cls.assert_called_once_with(
|
||||
mock.ANY,
|
||||
mock.ANY,
|
||||
delete=False,
|
||||
compression=ZIP_DEFLATED,
|
||||
compresslevel=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
class TestCryptExportImport(
|
||||
|
||||
@@ -525,6 +525,71 @@ class TestCommandImport(
|
||||
self.assertEqual(doc.tags.count(), 1)
|
||||
self.assertEqual(doc.tags.first().name, "batch-flush-tag")
|
||||
|
||||
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))
|
||||
|
||||
def test_import_rejects_unreadable_zstd_with_version_hint(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A zip archive with an entry compressed with zstd
|
||||
WHEN:
|
||||
- Import is attempted on a Python runtime that can't read zstd
|
||||
THEN:
|
||||
- The CommandError names the 3.14+ requirement, not just the
|
||||
generic "can't read" message
|
||||
"""
|
||||
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,
|
||||
),
|
||||
mock.patch(
|
||||
"documents.management.commands.document_importer.unreadable_method_names",
|
||||
return_value={"zstd"},
|
||||
),
|
||||
):
|
||||
with self.assertRaises(CommandError) as e:
|
||||
call_command(
|
||||
"document_importer",
|
||||
str(archive),
|
||||
"--no-progress-bar",
|
||||
skip_checks=True,
|
||||
)
|
||||
self.assertIn("3.14", str(e.exception))
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
|
||||
Reference in New Issue
Block a user