mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-24 21:04:55 +00:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c573fa61cb | ||
|
|
5d0fb476cf | ||
|
|
e3d8bcb102 | ||
|
|
9558ede894 | ||
|
|
437a219658 | ||
|
|
396c0b539c | ||
|
|
50dec14653 | ||
|
|
cc4917efec | ||
|
|
672fc6a0d4 | ||
|
|
943d322196 | ||
|
|
4d956fa13b | ||
|
|
3963581af0 | ||
|
|
581fbab8f3 | ||
|
|
7044d8ae5f | ||
|
|
5e21ba891d | ||
|
|
c2c30541bb | ||
|
|
1ea9775ab4 | ||
|
|
02b97fbaaf | ||
|
|
0e538acdef | ||
|
|
7f58d16758 | ||
|
|
344414bb47 | ||
|
|
8263f75643 | ||
|
|
72d031a7e2 | ||
|
|
1a253d0f26 | ||
|
|
dc4cb829a6 | ||
|
|
cad2d2d151 | ||
|
|
8db09d7c92 | ||
|
|
b02a151e45 | ||
|
|
0083443e77 | ||
|
|
2cd9b94c43 | ||
|
|
b76bd9a7bb | ||
|
|
87a9fcadc8 | ||
|
|
1c4f6b6144 | ||
|
|
2d985961b0 | ||
|
|
08a2186de1 | ||
|
|
a89fdfb033 | ||
|
|
c0fc6c0475 | ||
|
|
28f291c503 | ||
|
|
8263460b7b | ||
|
|
f4e997e524 | ||
|
|
1944208de4 | ||
|
|
db14bd6b61 | ||
|
|
f41181f992 | ||
|
|
b4729ec5d2 | ||
|
|
3cd772d8e9 |
@@ -272,6 +272,8 @@ optional arguments:
|
||||
-sm, --split-manifest
|
||||
-z, --zip
|
||||
-zn, --zip-name
|
||||
--zip-compression
|
||||
--zip-compression-level
|
||||
--data-only
|
||||
--no-progress-bar
|
||||
--passphrase
|
||||
@@ -334,6 +336,19 @@ If `-z` or `--zip` is provided, the export will be a zip file
|
||||
in the target directory, named according to the current local date or the
|
||||
value set in `-zn` or `--zip-name`.
|
||||
|
||||
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: 0–9, bzip2: 1–9, zstd: -22–22; 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.
|
||||
|
||||
If `--data-only` is provided, only the database will be exported. This option is intended
|
||||
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
||||
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
# 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` 0–9, `bzip2` 1–9, `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.py` — `ZipExportSink.__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`:
|
||||
|
||||
```python
|
||||
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`:
|
||||
|
||||
```python
|
||||
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**
|
||||
|
||||
```bash
|
||||
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):
|
||||
|
||||
```python
|
||||
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()`:
|
||||
|
||||
```python
|
||||
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()`:
|
||||
|
||||
```python
|
||||
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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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):
|
||||
|
||||
```python
|
||||
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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
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`):
|
||||
|
||||
```python
|
||||
with ZipFile(self.source) as zf:
|
||||
zf.extractall(tmp_dir)
|
||||
```
|
||||
|
||||
Replace it with a pre-check before extraction:
|
||||
|
||||
```python
|
||||
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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```markdown
|
||||
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: 0–9, bzip2: 1–9, zstd: -131072–22; 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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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)**
|
||||
|
||||
```bash
|
||||
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"`.
|
||||
@@ -0,0 +1,236 @@
|
||||
# Export Zip Compression Control — Design
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Branch base:** `dev`
|
||||
**Status:** Design complete (zstd facts verified on CPython 3.14.3) — **depends on**
|
||||
`2026-06-16-export-sink-architecture-design.md` being implemented first.
|
||||
|
||||
## Prerequisite
|
||||
|
||||
This builds directly on the export sink refactor. It assumes `ZipExportSink`
|
||||
already exists and is the single place that owns `zipfile.ZipFile` creation and
|
||||
entry writes. Do not start this until that refactor has landed; without it, the
|
||||
change would have to touch the command's zip branches again.
|
||||
|
||||
## Problem
|
||||
|
||||
Zip export is hardwired to `ZIP_DEFLATED` at the library default level. Users
|
||||
have no way to trade speed against archive size — a fast `ZIP_STORED` pass for a
|
||||
quick local copy, or a maximal `ZIP_LZMA` pass for the smallest off-site backup.
|
||||
The sink refactor turns "which compression" into a single constructor argument,
|
||||
so exposing it is now a small, isolated change.
|
||||
|
||||
## Goal
|
||||
|
||||
Let the operator choose the zip compression method and level from the CLI, with
|
||||
behavior identical to today when the flags are omitted. All knowledge of
|
||||
compression stays inside `ZipExportSink`; the command only parses flags and maps
|
||||
them to sink arguments.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- `ZipExportSink` gains `compression: int` and `compresslevel: int | None`
|
||||
constructor parameters (default `ZIP_DEFLATED`, `None` → library default),
|
||||
passed straight to `zipfile.ZipFile(...)`.
|
||||
- New `document_exporter` flags: `--zip-compression` and
|
||||
`--zip-compression-level`, valid only with `--zip`.
|
||||
- Validation: method availability, level range per method, and the
|
||||
requires-`--zip` guard.
|
||||
- Import-side: a pre-extract support check in `document_importer` that turns an
|
||||
unsupported codec into a clear `CommandError` (the importer otherwise decompresses
|
||||
transparently via `ZipFile.extractall`).
|
||||
- Docs: add both flags and the zstd-portability caveat to `docs/administration.md`
|
||||
(the `document_exporter` option list, lines ~257-270 and the `-z`/`-zn` section,
|
||||
lines ~328-330). New flags are long-form only (`--zip-compression`,
|
||||
`--zip-compression-level`) — no short aliases, to avoid `-zc`/`-zl` collisions
|
||||
with the existing `-z`/`-zn`.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Compression for any non-zip sink (folder has none; a future S3 sink would
|
||||
handle its own object storage compression separately).
|
||||
- Changing the default. Omitting the flags must produce a byte-compatible-method
|
||||
archive to today's (`ZIP_DEFLATED`, default level).
|
||||
|
||||
## Design
|
||||
|
||||
### `ZipExportSink` changes
|
||||
|
||||
The base sink's signature is `ZipExportSink(target, zip_name, *, delete)`; this
|
||||
adds two keyword-only params after `delete`:
|
||||
|
||||
```python
|
||||
def __init__(
|
||||
self,
|
||||
target: Path,
|
||||
zip_name: str,
|
||||
*,
|
||||
delete: bool = False,
|
||||
compression: int = zipfile.ZIP_DEFLATED,
|
||||
compresslevel: int | None = None,
|
||||
) -> None:
|
||||
...
|
||||
# opened in __enter__:
|
||||
self._zip = zipfile.ZipFile(
|
||||
self._tmp_path,
|
||||
"w",
|
||||
compression=compression,
|
||||
compresslevel=compresslevel,
|
||||
allowZip64=True,
|
||||
)
|
||||
```
|
||||
|
||||
`ZipFile` applies `compression`/`compresslevel` as the default for every
|
||||
`write`/`writestr` (verified: a `ZipFile(..., compression=ZIP_BZIP2)` yields
|
||||
entries with `compress_type == ZIP_BZIP2` without per-call args), so `add_file` /
|
||||
`add_json` / the manifest entry need no changes. Directory marker entries are
|
||||
empty so their compressed payload is zero, but they are still _tagged_ with the
|
||||
chosen `compress_type` — harmless, but tests that read `infolist()` should filter
|
||||
or account for marker entries (see Testing).
|
||||
|
||||
### CLI flags (`document_exporter`)
|
||||
|
||||
- `--zip-compression {stored,deflated,bzip2,lzma}` — and `zstd` **when the
|
||||
runtime supports it** (see below). Maps to the matching `zipfile.ZIP_*`
|
||||
constant. Default `deflated`.
|
||||
- `--zip-compression-level N` — integer. Per-method accepted ranges (verified
|
||||
against the [3.14 `zipfile` docs](https://docs.python.org/3.14/library/zipfile.html#zipfile.ZipFile)):
|
||||
- `deflated`: **0–9** (`zlib` also accepts `-1` = "default", identical to
|
||||
omitting the flag / `compresslevel=None`).
|
||||
- `bzip2`: **1–9** (`0` is invalid for bzip2).
|
||||
- `lzma`, `stored`: level has **no effect** — passing `--zip-compression-level`
|
||||
with either is a `CommandError`, not a silent accept (consistent with the
|
||||
base refactor's fail-fast posture).
|
||||
- `zstd`: **-131072 … 22** (the documented commonly-accepted range; the
|
||||
authoritative bounds are
|
||||
`compression.zstd.CompressionParameter.compression_level.bounds()`).
|
||||
|
||||
Default: unset → library default (`compresslevel=None`).
|
||||
|
||||
Both flags require `--zip`; passing either without `--zip` raises a
|
||||
`CommandError`, matching the incremental-flag rule from the base refactor.
|
||||
|
||||
**Why validate up front (not let `zipfile` raise) — verified on 3.14.3:** an
|
||||
invalid level does _not_ fail at `ZipFile(...)` construction — it fails at the
|
||||
**first `write`/`writestr` call**, with an opaque message
|
||||
(`ValueError: Invalid initialization option` for deflated > 9, or
|
||||
`ValueError: compresslevel must be between 1 and 9` for bzip2). Worse, on context
|
||||
exit the half-initialized write handle emits a secondary
|
||||
`AttributeError: '_ZipWriteFile' object has no attribute '_compressor'` during GC
|
||||
finalization, so the user sees stack-trace noise unrelated to the real cause.
|
||||
Up-front validation turns all of that into a single clean `CommandError`.
|
||||
|
||||
### Validation (in `handle()`, before constructing the sink)
|
||||
|
||||
1. **Requires `--zip`.** Either flag without `--zip` → `CommandError`.
|
||||
2. **Method availability — via a named, patchable seam.** Expose a module-level
|
||||
helper `compression_available(method: str) -> bool` that does
|
||||
`try: import bz2 / import lzma / from compression import zstd except ImportError:
|
||||
return False` — **not** `importlib.util.find_spec`, which can report a stdlib
|
||||
C-extension as present when importing it actually fails. `stored`/`deflated`
|
||||
are always available (`zlib` is a hard CPython dependency). For `zstd` the probe
|
||||
must import `compression.zstd` (3.14+), not merely check that
|
||||
`zipfile.ZIP_ZSTANDARD` exists. Making this a named function is also what lets
|
||||
the test patch "method unavailable" with `mocker`. If the chosen method is
|
||||
unavailable, raise a `CommandError` naming the missing capability — `zipfile`
|
||||
itself would otherwise raise a bare `RuntimeError`
|
||||
("Compression requires the (missing) … module").
|
||||
3. **Level range.** Reject an out-of-range `--zip-compression-level` for the
|
||||
chosen method with a clear `CommandError`; reject the flag entirely for
|
||||
`stored`/`lzma` (see above).
|
||||
|
||||
### zstd (Python 3.14+)
|
||||
|
||||
**Verified empirically on CPython 3.14.3** (via `uv run --python 3.14 --no-project`)
|
||||
and against [PEP 784](https://peps.python.org/pep-0784/) +
|
||||
[the 3.14 `zipfile` docs](https://docs.python.org/3.14/library/zipfile.html):
|
||||
|
||||
- The compression-method constant is **`zipfile.ZIP_ZSTANDARD`** (added 3.14; its
|
||||
numeric value is `93`). It does **not** exist on < 3.14.
|
||||
- It is backed by the new **`compression.zstd`** stdlib module (PEP 784 added a
|
||||
`compression` namespace package; legacy `bz2`/`lzma`/`zlib` imports are
|
||||
unchanged). `zipfile` raises `RuntimeError` if `compression.zstd` is
|
||||
unavailable when zstd is requested.
|
||||
- Accepted `compresslevel` is **`-131072 … 22`**, confirmed at runtime via
|
||||
`compression.zstd.CompressionParameter.compression_level.bounds() == (-131072, 22)`.
|
||||
|
||||
Gate everything zstd-related at runtime so nothing is imported or referenced on
|
||||
< 3.14 (the project targets Python ≥ 3.11):
|
||||
|
||||
```python
|
||||
_ZSTD: int | None = getattr(zipfile, "ZIP_ZSTANDARD", None) # None before 3.14
|
||||
```
|
||||
|
||||
Presence of the _constant_ does not guarantee the _codec_ is usable, so the
|
||||
availability probe (validation step 2) imports `compression.zstd`, not merely
|
||||
checks the constant.
|
||||
|
||||
Keep `zstd` in the `--zip-compression` `choices` **always** (even on < 3.14), and
|
||||
reject it in validation with a friendly "zstd requires Python 3.14+" message. If
|
||||
it were dropped from `choices` on older runtimes, argparse would emit a generic
|
||||
"invalid choice" that reads as though the option never existed — worse UX.
|
||||
|
||||
### Import-side compatibility
|
||||
|
||||
`document_importer` reads zips with `ZipFile(self.source).extractall(...)`
|
||||
(`document_importer.py:453`), which decompresses each entry transparently using
|
||||
whatever method it was stored with — **provided the matching module exists on the
|
||||
importing machine.**
|
||||
|
||||
The failure mode when it doesn't is unfriendly and must be handled: a zstd (or
|
||||
otherwise unsupported) entry raises a bare `NotImplementedError` **per-entry,
|
||||
during `extractall`** — _not_ at `ZipFile(self.source)` open, and `is_zipfile()`
|
||||
still returns true (a zstd archive is a valid zip container). So the importer
|
||||
enters the zip branch, creates its temp dir, may partially extract other entries,
|
||||
then blows up mid-extract with no context. **Mitigation (in scope here):** before
|
||||
extracting, inspect `ZipFile(self.source).infolist()` compress types and, if any
|
||||
is unsupported on this runtime, raise a `CommandError` naming the method and the
|
||||
requirement (e.g. "this archive uses zstd, which needs Python 3.14+") instead of
|
||||
letting `NotImplementedError` escape.
|
||||
|
||||
Per-method summary (document in help text + `administration.md`):
|
||||
|
||||
- `deflated`/`stored`: universally importable.
|
||||
- `bzip2`/`lzma`: importable wherever the `bz2`/`lzma` modules are present
|
||||
(essentially always).
|
||||
- `zstd`: importable only on Python 3.14+. An archive compressed with `zstd` is
|
||||
**not** importable on older runtimes.
|
||||
|
||||
## Testing
|
||||
|
||||
New cases in the sink tests and an export→import round-trip
|
||||
(pytest classes, factory-boy, `mocker`, `parametrize`, typed; run on the VM):
|
||||
|
||||
- **Round-trip per method.** Parametrize over the available methods (skip `zstd`
|
||||
below 3.14, skip `bzip2`/`lzma` if the module is somehow absent): export a
|
||||
small library, import it back, assert documents/manifest match.
|
||||
- **Method is applied.** Assert each written _file_ entry's `compress_type`
|
||||
equals the requested method (read back via `ZipFile.infolist()`), filtering out
|
||||
directory marker entries (which are tagged but empty).
|
||||
- **Level affects size — robustly.** Do **not** compare deflate level 9 vs 1
|
||||
(on small or incompressible fixtures level 9 can equal or slightly exceed level
|
||||
1, causing flaky CI). Instead assert that a compressing method on a
|
||||
moderately-compressible fixture yields a total smaller than `stored`
|
||||
(`ZIP_STORED`), which is a stable invariant.
|
||||
- **Validation.** Each flag without `--zip` → `CommandError`; out-of-range level
|
||||
(`--zip-compression-level 99`) → a clean `CommandError` from validation
|
||||
(asserting we never reach the `writestr` that would raise the masked
|
||||
`ValueError`); `--zip-compression-level` with `stored`/`lzma` → `CommandError`;
|
||||
unavailable method (patch the named availability seam with `mocker`) →
|
||||
`CommandError`; on < 3.14, `--zip-compression zstd` → the friendly
|
||||
"requires 3.14+" `CommandError`.
|
||||
- **Import pre-check.** An archive containing an unsupported compress type
|
||||
produces a `CommandError` from the importer naming the method, not a raw
|
||||
`NotImplementedError` (simulate by patching the importer's support probe).
|
||||
- **Default unchanged.** Omitting both flags yields file entries with
|
||||
`compress_type == ZIP_DEFLATED`, identical to pre-feature behavior.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Foot-gun archives.** A user could produce a `zstd`/`lzma` archive their
|
||||
import target can't read. Mitigation: explicit help text and the import-side
|
||||
notes above; the default stays the universally-readable `deflated`.
|
||||
- **Optional-module assumptions.** Don't assume `bz2`/`lzma` are always compiled
|
||||
in; probe and error clearly. Mitigation: the availability validation step.
|
||||
@@ -0,0 +1,108 @@
|
||||
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.
|
||||
#
|
||||
# zstd's raw library bounds are (-131072, 22)
|
||||
# (compression.zstd.CompressionParameter.compression_level.bounds()) — the
|
||||
# minimum is an internal implementation constant (-ZSTD_TARGETLENGTH_MAX),
|
||||
# not a meaningful distinct "level"; deeper negative values than -22 buy
|
||||
# nothing over -22 in practice. We expose the conventional zstd CLI range
|
||||
# instead of the raw library bounds.
|
||||
LEVEL_BOUNDS: dict[str, tuple[int, int] | None] = {
|
||||
"stored": None,
|
||||
"deflated": (0, 9),
|
||||
"bzip2": (1, 9),
|
||||
"lzma": None,
|
||||
"zstd": (-22, 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 {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
|
||||
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from contextlib import AbstractContextManager
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from pathlib import PurePosixPath
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.utils import compute_checksum
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from typing import TextIO
|
||||
|
||||
|
||||
def _dumps(content: list | dict) -> str:
|
||||
"""Serialize export JSON consistently across all sinks."""
|
||||
return json.dumps(content, cls=DjangoJSONEncoder, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
class StreamingManifestWriter:
|
||||
"""Incrementally writes a JSON array to a text handle, one record at a time.
|
||||
|
||||
Knows nothing about folders or zips: it writes the array framing and records
|
||||
to whatever handle the sink's ``stream()`` yields. The sink owns the handle's
|
||||
lifecycle (atomic rename, compare, spooling).
|
||||
"""
|
||||
|
||||
def __init__(self, handle: TextIO) -> None:
|
||||
self._file = handle
|
||||
self._first = True
|
||||
self._file.write("[")
|
||||
|
||||
def write_record(self, record: dict) -> None:
|
||||
if not self._first:
|
||||
self._file.write(",\n")
|
||||
else:
|
||||
self._first = False
|
||||
self._file.write(_dumps(record))
|
||||
|
||||
def write_batch(self, records: list[dict]) -> None:
|
||||
for record in records:
|
||||
self.write_record(record)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Write the closing bracket. Does NOT close the handle (the sink owns it)."""
|
||||
self._file.write("\n]")
|
||||
|
||||
|
||||
class ExportSink(AbstractContextManager, abc.ABC):
|
||||
"""Destination for a document export.
|
||||
|
||||
The command declares export contents via three verbs; the sink decides how to
|
||||
persist each. ``arcname`` is always a relative POSIX path
|
||||
(e.g. ``"manifest.json"``, ``"originals/foo.pdf"``).
|
||||
|
||||
Contract:
|
||||
* At most one ``stream()`` open at a time (it is the manifest);
|
||||
``add_file``/``add_json`` may be called while it is open.
|
||||
* Context-manager: normal exit finalizes, an exception aborts. No partial or
|
||||
failed run leaves a complete-looking artifact.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def add_file(
|
||||
self,
|
||||
source: Path,
|
||||
arcname: str,
|
||||
*,
|
||||
checksum: str | None = None,
|
||||
) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def add_json(self, content: list | dict, arcname: str) -> None: ...
|
||||
|
||||
@abc.abstractmethod
|
||||
def stream(self, arcname: str) -> AbstractContextManager[TextIO]: ...
|
||||
|
||||
def _open(self) -> None:
|
||||
"""Hook called on context entry. Override as needed."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _finalize(self) -> None:
|
||||
"""Commit on clean exit."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _abort(self) -> None:
|
||||
"""Roll back on exception."""
|
||||
|
||||
def __enter__(self) -> ExportSink:
|
||||
self._open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
if exc_type is not None:
|
||||
self._abort()
|
||||
else:
|
||||
self._finalize()
|
||||
|
||||
|
||||
class DirectoryExportSink(ExportSink):
|
||||
"""Writes loose files into a target directory, with incremental sync.
|
||||
|
||||
Owns the snapshot/skip/compare/prune machinery that used to live in the
|
||||
command (``files_in_export_dir``, ``check_and_copy``, ``check_and_write_json``,
|
||||
and the ``--delete`` pass).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target: Path,
|
||||
*,
|
||||
compare_checksums: bool,
|
||||
compare_json: bool,
|
||||
delete: bool,
|
||||
) -> None:
|
||||
self._target = target.resolve()
|
||||
self._compare_checksums = compare_checksums
|
||||
self._compare_json = compare_json
|
||||
self._delete = delete
|
||||
self._snapshot: set[Path] = set()
|
||||
self._stream_open = False
|
||||
|
||||
def _open(self) -> None:
|
||||
for x in self._target.glob("**/*"):
|
||||
if x.is_file():
|
||||
self._snapshot.add(x.resolve())
|
||||
|
||||
def add_file(
|
||||
self,
|
||||
source: Path,
|
||||
arcname: str,
|
||||
*,
|
||||
checksum: str | None = None,
|
||||
) -> None:
|
||||
target = (self._target / arcname).resolve()
|
||||
self._snapshot.discard(target)
|
||||
perform_copy = False
|
||||
if target.exists():
|
||||
source_stat = source.stat()
|
||||
target_stat = target.stat()
|
||||
if self._compare_checksums and checksum:
|
||||
perform_copy = compute_checksum(target) != checksum
|
||||
elif (
|
||||
source_stat.st_mtime != target_stat.st_mtime
|
||||
or source_stat.st_size != target_stat.st_size
|
||||
):
|
||||
perform_copy = True
|
||||
else:
|
||||
perform_copy = True
|
||||
if perform_copy:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
copy_file_with_basic_stats(source, target)
|
||||
|
||||
@staticmethod
|
||||
def _content_unchanged(target: Path, new_bytes: bytes) -> bool:
|
||||
"""True if ``target`` already holds byte-identical content (BLAKE2b)."""
|
||||
return (
|
||||
hashlib.blake2b(target.read_bytes()).hexdigest()
|
||||
== hashlib.blake2b(new_bytes).hexdigest()
|
||||
)
|
||||
|
||||
def add_json(self, content: list | dict, arcname: str) -> None:
|
||||
target = (self._target / arcname).resolve()
|
||||
json_str = _dumps(content)
|
||||
perform_write = True
|
||||
if target in self._snapshot:
|
||||
self._snapshot.discard(target)
|
||||
if self._compare_json and self._content_unchanged(
|
||||
target,
|
||||
json_str.encode("utf-8"),
|
||||
):
|
||||
perform_write = False
|
||||
if perform_write:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json_str, encoding="utf-8")
|
||||
|
||||
@contextmanager
|
||||
def stream(self, arcname: str) -> Iterator[TextIO]:
|
||||
if self._stream_open:
|
||||
raise RuntimeError("A stream is already open on this sink")
|
||||
target = (self._target / arcname).resolve()
|
||||
tmp = target.with_suffix(target.suffix + ".tmp")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = tmp.open("w", encoding="utf-8")
|
||||
self._stream_open = True
|
||||
try:
|
||||
yield handle
|
||||
except BaseException:
|
||||
handle.close()
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
else:
|
||||
handle.close()
|
||||
self._commit_streamed_file(target, tmp)
|
||||
finally:
|
||||
self._stream_open = False
|
||||
|
||||
def _commit_streamed_file(self, target: Path, tmp: Path) -> None:
|
||||
if target in self._snapshot:
|
||||
self._snapshot.discard(target)
|
||||
if self._compare_json and self._content_unchanged(
|
||||
target,
|
||||
tmp.read_bytes(),
|
||||
):
|
||||
tmp.unlink()
|
||||
return
|
||||
tmp.rename(target)
|
||||
|
||||
def _finalize(self) -> None:
|
||||
if self._delete:
|
||||
for f in self._snapshot:
|
||||
f.unlink()
|
||||
delete_empty_directories(f.parent, self._target)
|
||||
|
||||
def _abort(self) -> None:
|
||||
# Folder mode is in-place/incremental: streamed .tmp files are already
|
||||
# cleaned in stream(); leave everything else intact and skip the prune.
|
||||
return None
|
||||
|
||||
|
||||
class ZipExportSink(ExportSink):
|
||||
"""Writes a single zip archive, produced atomically only on success.
|
||||
|
||||
Builds into ``<target>/<zip_name>.zip.tmp`` and renames to ``.zip`` on clean
|
||||
finalize. The manifest stream is spooled to a temp file in SCRATCH_DIR and
|
||||
added as an entry at finalize (a zip entry cannot be interleaved with others).
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
def _ensure_dirs(self, arcname: str) -> None:
|
||||
assert self._zip is not None
|
||||
dir_arc = ""
|
||||
for part in PurePosixPath(arcname).parts[:-1]:
|
||||
dir_arc += f"{part}/"
|
||||
if dir_arc not in self._dirs:
|
||||
self._dirs.add(dir_arc)
|
||||
self._zip.mkdir(dir_arc)
|
||||
|
||||
def add_file(
|
||||
self,
|
||||
source: Path,
|
||||
arcname: str,
|
||||
*,
|
||||
checksum: str | None = None,
|
||||
) -> None:
|
||||
assert self._zip is not None
|
||||
self._ensure_dirs(arcname)
|
||||
self._zip.write(source, arcname=arcname)
|
||||
|
||||
def add_json(self, content: list | dict, arcname: str) -> None:
|
||||
assert self._zip is not None
|
||||
self._ensure_dirs(arcname)
|
||||
self._zip.writestr(arcname, _dumps(content))
|
||||
|
||||
@contextmanager
|
||||
def stream(self, arcname: str) -> Iterator[TextIO]:
|
||||
if self._stream_open:
|
||||
raise RuntimeError("A stream is already open on this sink")
|
||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=settings.SCRATCH_DIR,
|
||||
prefix="export-manifest-",
|
||||
suffix=".json",
|
||||
)
|
||||
tmp = Path(tmp_name)
|
||||
handle = os.fdopen(fd, "w", encoding="utf-8")
|
||||
self._stream_open = True
|
||||
try:
|
||||
yield handle
|
||||
except BaseException:
|
||||
handle.close()
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
else:
|
||||
handle.close()
|
||||
self._pending_manifest = (tmp, arcname)
|
||||
finally:
|
||||
self._stream_open = False
|
||||
|
||||
def _finalize(self) -> None:
|
||||
assert self._zip is not None
|
||||
if self._pending_manifest is not None:
|
||||
tmp, arcname = self._pending_manifest
|
||||
self._ensure_dirs(arcname)
|
||||
self._zip.write(tmp, arcname=arcname)
|
||||
tmp.unlink(missing_ok=True)
|
||||
self._pending_manifest = None
|
||||
self._zip.close()
|
||||
self._zip = None
|
||||
if self._delete:
|
||||
self._wipe_destination()
|
||||
self._tmp_path.replace(self._zip_path)
|
||||
|
||||
def _wipe_destination(self) -> None:
|
||||
skip = {self._zip_path.resolve(), self._tmp_path.resolve()}
|
||||
for item in self._target.glob("*"):
|
||||
if item.resolve() in skip:
|
||||
continue
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
|
||||
def _abort(self) -> None:
|
||||
if self._zip is not None:
|
||||
self._zip.close()
|
||||
self._zip = None
|
||||
self._tmp_path.unlink(missing_ok=True)
|
||||
if self._pending_manifest is not None:
|
||||
self._pending_manifest[0].unlink(missing_ok=True)
|
||||
self._pending_manifest = None
|
||||
@@ -1,8 +1,4 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from itertools import islice
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -19,7 +15,6 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core import serializers
|
||||
from django.core.management.base import CommandError
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
@@ -34,7 +29,15 @@ if TYPE_CHECKING:
|
||||
if settings.AUDIT_LOG_ENABLED:
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.export.compression import COMPRESSION_CHOICES
|
||||
from documents.export.compression import COMPRESSION_METHODS
|
||||
from documents.export.compression import ZSTD
|
||||
from documents.export.compression import compression_available
|
||||
from documents.export.compression import level_error
|
||||
from documents.export.sinks import DirectoryExportSink
|
||||
from documents.export.sinks import ExportSink
|
||||
from documents.export.sinks import StreamingManifestWriter
|
||||
from documents.export.sinks import ZipExportSink
|
||||
from documents.file_handling import generate_filename
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
from documents.management.commands.mixins import CryptMixin
|
||||
@@ -60,8 +63,6 @@ from documents.settings import EXPORTER_ARCHIVE_NAME
|
||||
from documents.settings import EXPORTER_FILE_NAME
|
||||
from documents.settings import EXPORTER_SHARE_LINK_BUNDLE_NAME
|
||||
from documents.settings import EXPORTER_THUMBNAIL_NAME
|
||||
from documents.utils import compute_checksum
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
from paperless import version
|
||||
from paperless.models import ApplicationConfiguration
|
||||
from paperless_mail.models import MailAccount
|
||||
@@ -84,87 +85,6 @@ def serialize_queryset_batched(
|
||||
yield serializers.serialize("python", chunk)
|
||||
|
||||
|
||||
class StreamingManifestWriter:
|
||||
"""Incrementally writes a JSON array to a file, one record at a time.
|
||||
|
||||
Writes to <target>.tmp first; on close(), optionally BLAKE2b-compares
|
||||
with the existing file (--compare-json) and renames or discards accordingly.
|
||||
On exception, discard() deletes the tmp file and leaves the original intact.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
compare_json: bool = False,
|
||||
files_in_export_dir: "set[Path] | None" = None,
|
||||
) -> None:
|
||||
self._path = path.resolve()
|
||||
self._tmp_path = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
self._compare_json = compare_json
|
||||
self._files_in_export_dir: set[Path] = (
|
||||
files_in_export_dir if files_in_export_dir is not None else set()
|
||||
)
|
||||
self._file = None
|
||||
self._first = True
|
||||
|
||||
def open(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._file = self._tmp_path.open("w", encoding="utf-8")
|
||||
self._file.write("[")
|
||||
self._first = True
|
||||
|
||||
def write_record(self, record: dict) -> None:
|
||||
if not self._first:
|
||||
self._file.write(",\n")
|
||||
else:
|
||||
self._first = False
|
||||
self._file.write(
|
||||
json.dumps(record, cls=DjangoJSONEncoder, indent=2, ensure_ascii=False),
|
||||
)
|
||||
|
||||
def write_batch(self, records: list[dict]) -> None:
|
||||
for record in records:
|
||||
self.write_record(record)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._file is None:
|
||||
return
|
||||
self._file.write("\n]")
|
||||
self._file.close()
|
||||
self._file = None
|
||||
self._finalize()
|
||||
|
||||
def discard(self) -> None:
|
||||
if self._file is not None:
|
||||
self._file.close()
|
||||
self._file = None
|
||||
if self._tmp_path.exists():
|
||||
self._tmp_path.unlink()
|
||||
|
||||
def _finalize(self) -> None:
|
||||
"""Compare with existing file (if --compare-json) then rename or discard tmp."""
|
||||
if self._path in self._files_in_export_dir:
|
||||
self._files_in_export_dir.remove(self._path)
|
||||
if self._compare_json:
|
||||
existing_hash = hashlib.blake2b(self._path.read_bytes()).hexdigest()
|
||||
new_hash = hashlib.blake2b(self._tmp_path.read_bytes()).hexdigest()
|
||||
if existing_hash == new_hash:
|
||||
self._tmp_path.unlink()
|
||||
return
|
||||
self._tmp_path.rename(self._path)
|
||||
|
||||
def __enter__(self) -> "StreamingManifestWriter":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
if exc_type is not None:
|
||||
self.discard()
|
||||
else:
|
||||
self.close()
|
||||
|
||||
|
||||
class Command(CryptMixin, PaperlessCommand):
|
||||
help = (
|
||||
"Decrypt and rename all files in our collection into a given target "
|
||||
@@ -276,6 +196,28 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
help="Sets the export zip file name",
|
||||
)
|
||||
|
||||
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: -22..22; ignored for "
|
||||
"stored/lzma."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--data-only",
|
||||
default=False,
|
||||
@@ -314,20 +256,13 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
self.passphrase: str | None = options.get("passphrase")
|
||||
self.batch_size: int = options["batch_size"]
|
||||
|
||||
self.files_in_export_dir: set[Path] = set()
|
||||
self.exported_files: set[str] = set()
|
||||
|
||||
# If zipping, save the original target for later and
|
||||
# get a temporary directory for the target instead
|
||||
temp_dir = None
|
||||
self.original_target = self.target
|
||||
if self.zip_export:
|
||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
temp_dir = tempfile.TemporaryDirectory(
|
||||
dir=settings.SCRATCH_DIR,
|
||||
prefix="paperless-export",
|
||||
if self.zip_export and (self.compare_checksums or self.compare_json):
|
||||
raise CommandError(
|
||||
"--compare-checksums and --compare-json have no effect when "
|
||||
"used with --zip",
|
||||
)
|
||||
self.target = Path(temp_dir.name).resolve()
|
||||
|
||||
if not self.target.exists():
|
||||
raise CommandError("That path doesn't exist")
|
||||
@@ -338,33 +273,55 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
if not os.access(self.target, os.W_OK):
|
||||
raise CommandError("That path doesn't appear to be writable")
|
||||
|
||||
try:
|
||||
# Prevent any ongoing changes in the documents
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
self.dump()
|
||||
zip_compression: str | None = options["zip_compression"]
|
||||
zip_compression_level: int | None = options["zip_compression_level"]
|
||||
|
||||
# We've written everything to the temporary directory in this case,
|
||||
# now make an archive in the original target, with all files stored
|
||||
if self.zip_export and temp_dir is not None:
|
||||
shutil.make_archive(
|
||||
self.original_target / options["zip_name"],
|
||||
format="zip",
|
||||
root_dir=temp_dir.name,
|
||||
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)
|
||||
|
||||
finally:
|
||||
# Always cleanup the temporary directory, if one was created
|
||||
if self.zip_export and temp_dir is not None:
|
||||
temp_dir.cleanup()
|
||||
sink: ExportSink
|
||||
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,
|
||||
)
|
||||
|
||||
def dump(self) -> None:
|
||||
# 1. Take a snapshot of what files exist in the current export folder
|
||||
for x in self.target.glob("**/*"):
|
||||
if x.is_file():
|
||||
self.files_in_export_dir.add(x.resolve())
|
||||
# Prevent any ongoing changes in the documents while exporting
|
||||
with FileLock(settings.MEDIA_LOCK), sink:
|
||||
self.dump(sink)
|
||||
|
||||
# 2. Create manifest, containing all correspondents, types, tags, storage paths
|
||||
# note, documents and ui_settings
|
||||
def dump(self, sink: ExportSink) -> None:
|
||||
# 1. Create manifest, containing all correspondents, types, tags, storage
|
||||
# paths, note, documents and ui_settings
|
||||
_excluded_usernames = ["consumer", "AnonymousUser"]
|
||||
manifest_key_to_object_query: dict[str, QuerySet[Any]] = {
|
||||
"correspondents": Correspondent.objects.all(),
|
||||
@@ -427,13 +384,9 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
|
||||
document_manifest: list[dict] = []
|
||||
share_link_bundle_manifest: list[dict] = []
|
||||
manifest_path = (self.target / "manifest.json").resolve()
|
||||
|
||||
with StreamingManifestWriter(
|
||||
manifest_path,
|
||||
compare_json=self.compare_json,
|
||||
files_in_export_dir=self.files_in_export_dir,
|
||||
) as writer:
|
||||
with sink.stream("manifest.json") as handle:
|
||||
writer = StreamingManifestWriter(handle)
|
||||
with transaction.atomic():
|
||||
for key, qs in manifest_key_to_object_query.items():
|
||||
if key == "documents":
|
||||
@@ -479,84 +432,55 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
)
|
||||
}
|
||||
|
||||
# 3. Export files from each document
|
||||
for index, document_dict in enumerate(
|
||||
self.track(
|
||||
document_manifest,
|
||||
description="Exporting documents...",
|
||||
total=len(document_manifest),
|
||||
),
|
||||
# 2. Export files from each document
|
||||
for document_dict in self.track(
|
||||
document_manifest,
|
||||
description="Exporting documents...",
|
||||
total=len(document_manifest),
|
||||
):
|
||||
document = document_map[document_dict["pk"]]
|
||||
|
||||
# 3.1. generate a unique filename
|
||||
# generate a unique filename, then the arcnames for its files
|
||||
base_name = self.generate_base_name(document)
|
||||
|
||||
# 3.2. write filenames into manifest
|
||||
original_target, thumbnail_target, archive_target = (
|
||||
original_arc, thumbnail_arc, archive_arc = (
|
||||
self.generate_document_targets(document, base_name, document_dict)
|
||||
)
|
||||
|
||||
# 3.3. write files to target folder
|
||||
if not self.data_only:
|
||||
self.copy_document_files(
|
||||
document,
|
||||
original_target,
|
||||
thumbnail_target,
|
||||
archive_target,
|
||||
sink,
|
||||
original_arc,
|
||||
thumbnail_arc,
|
||||
archive_arc,
|
||||
)
|
||||
|
||||
if self.split_manifest:
|
||||
self._write_split_manifest(document_dict, document, base_name)
|
||||
self._write_split_manifest(sink, document_dict, document, base_name)
|
||||
else:
|
||||
writer.write_record(document_dict)
|
||||
|
||||
for bundle_dict in share_link_bundle_manifest:
|
||||
bundle = share_link_bundle_map[bundle_dict["pk"]]
|
||||
|
||||
bundle_target = self.generate_share_link_bundle_target(
|
||||
bundle_arc = self.generate_share_link_bundle_target(
|
||||
bundle,
|
||||
bundle_dict,
|
||||
)
|
||||
|
||||
if not self.data_only and bundle_target is not None:
|
||||
self.copy_share_link_bundle_file(bundle, bundle_target)
|
||||
|
||||
if not self.data_only and bundle_arc is not None:
|
||||
self.copy_share_link_bundle_file(bundle, sink, bundle_arc)
|
||||
writer.write_record(bundle_dict)
|
||||
|
||||
# 4.2 write version information to target folder
|
||||
extra_metadata_path = (self.target / "metadata.json").resolve()
|
||||
writer.close()
|
||||
|
||||
# 3. Write version (and crypto params) to metadata.json
|
||||
# Django stores most crypto values in the field itself; we store
|
||||
# them once here for the whole export
|
||||
metadata: dict[str, str | int | dict[str, str | int]] = {
|
||||
"version": version.__full_version_str__,
|
||||
}
|
||||
|
||||
# 4.2.1 If needed, write the crypto values into the metadata
|
||||
# Django stores most of these in the field itself, we store them once here
|
||||
if self.passphrase:
|
||||
metadata.update(self.get_crypt_params())
|
||||
|
||||
self.check_and_write_json(
|
||||
metadata,
|
||||
extra_metadata_path,
|
||||
)
|
||||
|
||||
if self.delete:
|
||||
# 5. Remove files which we did not explicitly export in this run
|
||||
if not self.zip_export:
|
||||
for f in self.files_in_export_dir:
|
||||
f.unlink()
|
||||
|
||||
delete_empty_directories(
|
||||
f.parent,
|
||||
self.target,
|
||||
)
|
||||
else:
|
||||
# 5. Remove anything in the original location (before moving the zip)
|
||||
for item in self.original_target.glob("*"):
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
sink.add_json(metadata, "metadata.json")
|
||||
|
||||
def generate_base_name(self, document: Document) -> Path:
|
||||
"""
|
||||
@@ -584,73 +508,69 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
document: Document,
|
||||
base_name: Path,
|
||||
document_dict: dict,
|
||||
) -> tuple[Path, Path | None, Path | None]:
|
||||
) -> tuple[str, str | None, str | None]:
|
||||
"""
|
||||
Generates the targets for a given document, including the original file, archive file and thumbnail (depending on settings).
|
||||
Generates the relative POSIX arcnames for a document's original, thumbnail
|
||||
and archive files (depending on settings), and records them in the manifest.
|
||||
"""
|
||||
original_name = base_name
|
||||
if self.use_folder_prefix:
|
||||
original_name = Path("originals") / original_name
|
||||
original_target = (self.target / original_name).resolve()
|
||||
document_dict[EXPORTER_FILE_NAME] = str(original_name)
|
||||
original_arc = original_name.as_posix()
|
||||
document_dict[EXPORTER_FILE_NAME] = original_arc
|
||||
|
||||
if not self.no_thumbnail:
|
||||
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
|
||||
if self.use_folder_prefix:
|
||||
thumbnail_name = Path("thumbnails") / thumbnail_name
|
||||
thumbnail_target = (self.target / thumbnail_name).resolve()
|
||||
document_dict[EXPORTER_THUMBNAIL_NAME] = str(thumbnail_name)
|
||||
thumbnail_arc = thumbnail_name.as_posix()
|
||||
document_dict[EXPORTER_THUMBNAIL_NAME] = thumbnail_arc
|
||||
else:
|
||||
thumbnail_target = None
|
||||
thumbnail_arc = None
|
||||
|
||||
if not self.no_archive and document.has_archive_version:
|
||||
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
|
||||
if self.use_folder_prefix:
|
||||
archive_name = Path("archive") / archive_name
|
||||
archive_target = (self.target / archive_name).resolve()
|
||||
document_dict[EXPORTER_ARCHIVE_NAME] = str(archive_name)
|
||||
archive_arc = archive_name.as_posix()
|
||||
document_dict[EXPORTER_ARCHIVE_NAME] = archive_arc
|
||||
else:
|
||||
archive_target = None
|
||||
archive_arc = None
|
||||
|
||||
return original_target, thumbnail_target, archive_target
|
||||
return original_arc, thumbnail_arc, archive_arc
|
||||
|
||||
def copy_document_files(
|
||||
self,
|
||||
document: Document,
|
||||
original_target: Path,
|
||||
thumbnail_target: Path | None,
|
||||
archive_target: Path | None,
|
||||
sink: ExportSink,
|
||||
original_arc: str,
|
||||
thumbnail_arc: str | None,
|
||||
archive_arc: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Copies files from the document storage location to the specified target location.
|
||||
|
||||
If the document is encrypted, the files are decrypted before copying them to the target location.
|
||||
Hands the document's files to the sink (original, thumbnail, archive).
|
||||
"""
|
||||
self.check_and_copy(
|
||||
document.source_path,
|
||||
document.checksum,
|
||||
original_target,
|
||||
)
|
||||
sink.add_file(document.source_path, original_arc, checksum=document.checksum)
|
||||
|
||||
if thumbnail_target:
|
||||
self.check_and_copy(document.thumbnail_path, None, thumbnail_target)
|
||||
if thumbnail_arc:
|
||||
sink.add_file(document.thumbnail_path, thumbnail_arc)
|
||||
|
||||
if archive_target:
|
||||
if archive_arc:
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(document.archive_path, Path)
|
||||
self.check_and_copy(
|
||||
sink.add_file(
|
||||
document.archive_path,
|
||||
document.archive_checksum,
|
||||
archive_target,
|
||||
archive_arc,
|
||||
checksum=document.archive_checksum,
|
||||
)
|
||||
|
||||
def generate_share_link_bundle_target(
|
||||
self,
|
||||
bundle: ShareLinkBundle,
|
||||
bundle_dict: dict,
|
||||
) -> Path | None:
|
||||
) -> str | None:
|
||||
"""
|
||||
Generates the export target for a share link bundle file, when present.
|
||||
Generates the relative POSIX arcname for a share link bundle file, if any.
|
||||
"""
|
||||
if not bundle.file_path:
|
||||
return None
|
||||
@@ -666,25 +586,22 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
bundle_dict["fields"]["file_path"] = portable_bundle_path.as_posix()
|
||||
bundle_dict[EXPORTER_SHARE_LINK_BUNDLE_NAME] = export_bundle_path.as_posix()
|
||||
|
||||
return (self.target / export_bundle_path).resolve()
|
||||
return export_bundle_path.as_posix()
|
||||
|
||||
def copy_share_link_bundle_file(
|
||||
self,
|
||||
bundle: ShareLinkBundle,
|
||||
bundle_target: Path,
|
||||
sink: ExportSink,
|
||||
bundle_arc: str,
|
||||
) -> None:
|
||||
"""
|
||||
Copies a share link bundle ZIP into the export directory.
|
||||
Hands a share link bundle ZIP to the sink.
|
||||
"""
|
||||
bundle_source_path = bundle.absolute_file_path
|
||||
if bundle_source_path is None:
|
||||
raise FileNotFoundError(f"Share link bundle {bundle.pk} has no file path")
|
||||
|
||||
self.check_and_copy(
|
||||
bundle_source_path,
|
||||
None,
|
||||
bundle_target,
|
||||
)
|
||||
sink.add_file(bundle_source_path, bundle_arc)
|
||||
|
||||
def _encrypt_record_inline(self, record: dict) -> None:
|
||||
"""Encrypt sensitive fields in a single record, if passphrase is set."""
|
||||
@@ -700,6 +617,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
|
||||
def _write_split_manifest(
|
||||
self,
|
||||
sink: ExportSink,
|
||||
document_dict: dict,
|
||||
document: Document,
|
||||
base_name: Path,
|
||||
@@ -721,81 +639,4 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
|
||||
if self.use_folder_prefix:
|
||||
manifest_name = Path("json") / manifest_name
|
||||
manifest_name = (self.target / manifest_name).resolve()
|
||||
manifest_name.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.check_and_write_json(content, manifest_name)
|
||||
|
||||
def check_and_write_json(
|
||||
self,
|
||||
content: list[dict] | dict,
|
||||
target: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Writes the source content to the target json file.
|
||||
If --compare-json arg was used, don't write to target file if
|
||||
the file exists and checksum is identical to content checksum.
|
||||
This preserves the file timestamps when no changes are made.
|
||||
"""
|
||||
|
||||
target = target.resolve()
|
||||
perform_write = True
|
||||
if target in self.files_in_export_dir:
|
||||
self.files_in_export_dir.remove(target)
|
||||
if self.compare_json:
|
||||
target_checksum = hashlib.blake2b(target.read_bytes()).hexdigest()
|
||||
src_str = json.dumps(
|
||||
content,
|
||||
cls=DjangoJSONEncoder,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
src_checksum = hashlib.blake2b(src_str.encode("utf-8")).hexdigest()
|
||||
if src_checksum == target_checksum:
|
||||
perform_write = False
|
||||
|
||||
if perform_write:
|
||||
target.write_text(
|
||||
json.dumps(
|
||||
content,
|
||||
cls=DjangoJSONEncoder,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def check_and_copy(
|
||||
self,
|
||||
source: Path,
|
||||
source_checksum: str | None,
|
||||
target: Path,
|
||||
) -> None:
|
||||
"""
|
||||
Copies the source to the target, if target doesn't exist or the target doesn't seem to match
|
||||
the source attributes
|
||||
"""
|
||||
|
||||
target = target.resolve()
|
||||
if target in self.files_in_export_dir:
|
||||
self.files_in_export_dir.remove(target)
|
||||
|
||||
perform_copy = False
|
||||
|
||||
if target.exists():
|
||||
source_stat = source.stat()
|
||||
target_stat = target.stat()
|
||||
if self.compare_checksums and source_checksum:
|
||||
target_checksum = compute_checksum(target)
|
||||
perform_copy = target_checksum != source_checksum
|
||||
elif (
|
||||
source_stat.st_mtime != target_stat.st_mtime
|
||||
or source_stat.st_size != target_stat.st_size
|
||||
):
|
||||
perform_copy = True
|
||||
else:
|
||||
# Copy if it does not exist
|
||||
perform_copy = True
|
||||
|
||||
if perform_copy:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
copy_file_with_basic_stats(source, target)
|
||||
sink.add_json(content, manifest_name.as_posix())
|
||||
|
||||
@@ -32,6 +32,8 @@ from django.db.models.signals import post_save
|
||||
from filelock import FileLock
|
||||
from guardian.shortcuts import clear_ct_cache
|
||||
|
||||
from documents.export.compression import compress_type_readable
|
||||
from documents.export.compression import unreadable_method_names
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.management.commands.base import PaperlessCommand
|
||||
from documents.management.commands.mixins import CryptMixin
|
||||
@@ -451,6 +453,20 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
if is_zipfile(self.source):
|
||||
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 = sorted(unreadable_method_names(unsupported))
|
||||
message = (
|
||||
f"This archive uses compression this Python cannot "
|
||||
f"read ({', '.join(names)})."
|
||||
)
|
||||
if "zstd" in names:
|
||||
message += " zstd archives require Python 3.14+."
|
||||
raise CommandError(message)
|
||||
zf.extractall(tmp_dir)
|
||||
self.source = Path(tmp_dir)
|
||||
self._run_import()
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 current (93) and legacy (20) zstd compress_type ids
|
||||
WHEN:
|
||||
- Checked with compress_type_readable() on this runtime
|
||||
THEN:
|
||||
- Readability matches whether Python is 3.14+
|
||||
"""
|
||||
# 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:
|
||||
"""
|
||||
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"}
|
||||
@@ -0,0 +1,332 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
|
||||
from documents.export.sinks import DirectoryExportSink
|
||||
from documents.export.sinks import ExportSink
|
||||
from documents.export.sinks import StreamingManifestWriter
|
||||
from documents.export.sinks import ZipExportSink
|
||||
from documents.export.sinks import _dumps
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def source_file(tmp_path: Path) -> Path:
|
||||
src: Path = tmp_path / "src" / "doc.pdf"
|
||||
src.parent.mkdir(parents=True)
|
||||
src.write_bytes(b"PDF-CONTENT")
|
||||
return src
|
||||
|
||||
|
||||
class TestDumps:
|
||||
def test_dumps_is_indented_unicode_json(self) -> None:
|
||||
result: str = _dumps({"a": "é", "b": 1})
|
||||
assert '"é"' in result # ensure_ascii=False keeps unicode literal
|
||||
assert "\n" in result # indent=2 produces newlines
|
||||
assert json.loads(result) == {"a": "é", "b": 1}
|
||||
|
||||
|
||||
class TestStreamingManifestWriter:
|
||||
def test_writes_json_array_of_records(self) -> None:
|
||||
handle: io.StringIO = io.StringIO()
|
||||
writer: StreamingManifestWriter = StreamingManifestWriter(handle)
|
||||
writer.write_batch([{"pk": 1}, {"pk": 2}])
|
||||
writer.write_record({"pk": 3})
|
||||
writer.close()
|
||||
assert json.loads(handle.getvalue()) == [{"pk": 1}, {"pk": 2}, {"pk": 3}]
|
||||
|
||||
def test_empty_manifest_is_valid_empty_array(self) -> None:
|
||||
handle: io.StringIO = io.StringIO()
|
||||
writer: StreamingManifestWriter = StreamingManifestWriter(handle)
|
||||
writer.close()
|
||||
assert json.loads(handle.getvalue()) == []
|
||||
|
||||
|
||||
class TestDirectoryExportSink:
|
||||
def test_add_file_copies_to_relative_arcname(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
with DirectoryExportSink(
|
||||
target,
|
||||
compare_checksums=False,
|
||||
compare_json=False,
|
||||
delete=False,
|
||||
) as sink:
|
||||
sink.add_file(source_file, "originals/doc.pdf")
|
||||
assert (target / "originals" / "doc.pdf").read_bytes() == b"PDF-CONTENT"
|
||||
|
||||
def test_add_json_writes_file(self, tmp_path: Path) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
with DirectoryExportSink(
|
||||
target,
|
||||
compare_checksums=False,
|
||||
compare_json=False,
|
||||
delete=False,
|
||||
) as sink:
|
||||
sink.add_json({"version": "x"}, "metadata.json")
|
||||
assert json.loads((target / "metadata.json").read_text()) == {"version": "x"}
|
||||
|
||||
def test_stream_writes_manifest(self, tmp_path: Path) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
with DirectoryExportSink(
|
||||
target,
|
||||
compare_checksums=False,
|
||||
compare_json=False,
|
||||
delete=False,
|
||||
) as sink:
|
||||
with sink.stream("manifest.json") as handle:
|
||||
writer: StreamingManifestWriter = StreamingManifestWriter(handle)
|
||||
writer.write_record({"pk": 1})
|
||||
writer.close()
|
||||
assert json.loads((target / "manifest.json").read_text()) == [{"pk": 1}]
|
||||
|
||||
def test_add_file_skips_when_size_and_mtime_match(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
# Pre-existing target with identical size+mtime but DIFFERENT content:
|
||||
# if add_file skips (no compare-checksums), the old content survives.
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
existing: Path = target / "originals" / "doc.pdf"
|
||||
existing.parent.mkdir(parents=True)
|
||||
# Same byte length as the source but different content + matching mtime,
|
||||
# so a size/mtime comparison treats it as unchanged and skips the copy.
|
||||
existing.write_bytes(b"X" * len(b"PDF-CONTENT"))
|
||||
stat = source_file.stat()
|
||||
os.utime(existing, (stat.st_atime, stat.st_mtime))
|
||||
with DirectoryExportSink(
|
||||
target,
|
||||
compare_checksums=False,
|
||||
compare_json=False,
|
||||
delete=False,
|
||||
) as sink:
|
||||
sink.add_file(source_file, "originals/doc.pdf", checksum="abc")
|
||||
assert existing.read_bytes() == b"X" * len(b"PDF-CONTENT") # skipped
|
||||
|
||||
def test_add_file_recopies_when_compare_checksums_differ(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
existing: Path = target / "originals" / "doc.pdf"
|
||||
existing.parent.mkdir(parents=True)
|
||||
existing.write_bytes(b"X" * len(b"PDF-CONTENT"))
|
||||
stat = source_file.stat()
|
||||
os.utime(existing, (stat.st_atime, stat.st_mtime))
|
||||
with DirectoryExportSink(
|
||||
target,
|
||||
compare_checksums=True,
|
||||
compare_json=False,
|
||||
delete=False,
|
||||
) as sink:
|
||||
# wrong checksum forces recopy despite matching size/mtime
|
||||
sink.add_file(source_file, "originals/doc.pdf", checksum="not-the-real-sum")
|
||||
assert existing.read_bytes() == b"PDF-CONTENT" # recopied
|
||||
|
||||
def test_delete_prunes_unwritten_snapshot_files(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
stale: Path = target / "stale.pdf"
|
||||
stale.write_bytes(b"STALE")
|
||||
with DirectoryExportSink(
|
||||
target,
|
||||
compare_checksums=False,
|
||||
compare_json=False,
|
||||
delete=True,
|
||||
) as sink:
|
||||
sink.add_file(source_file, "originals/doc.pdf")
|
||||
assert not stale.exists()
|
||||
assert (target / "originals" / "doc.pdf").exists()
|
||||
|
||||
def test_no_delete_keeps_unwritten_files(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
stale: Path = target / "stale.pdf"
|
||||
stale.write_bytes(b"STALE")
|
||||
with DirectoryExportSink(
|
||||
target,
|
||||
compare_checksums=False,
|
||||
compare_json=False,
|
||||
delete=False,
|
||||
) as sink:
|
||||
sink.add_file(source_file, "originals/doc.pdf")
|
||||
assert stale.exists()
|
||||
|
||||
|
||||
class TestZipExportSink:
|
||||
def test_round_trip_files_json_and_stream(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
with ZipExportSink(target, "export", delete=False) as sink:
|
||||
sink.add_file(source_file, "originals/doc.pdf")
|
||||
sink.add_json({"version": "x"}, "metadata.json")
|
||||
with sink.stream("manifest.json") as handle:
|
||||
writer = StreamingManifestWriter(handle)
|
||||
writer.write_record({"pk": 1})
|
||||
writer.close()
|
||||
zip_path: Path = target / "export.zip"
|
||||
assert zip_path.exists()
|
||||
assert not (target / "export.zip.tmp").exists()
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = set(zf.namelist())
|
||||
assert {"originals/doc.pdf", "metadata.json", "manifest.json"} <= names
|
||||
assert zf.read("originals/doc.pdf") == b"PDF-CONTENT"
|
||||
assert json.loads(zf.read("manifest.json")) == [{"pk": 1}]
|
||||
|
||||
def test_nested_arcname_emits_directory_marker(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
with ZipExportSink(target, "export", delete=False) as sink:
|
||||
sink.add_file(source_file, "originals/doc.pdf")
|
||||
with zipfile.ZipFile(target / "export.zip") as zf:
|
||||
assert "originals/" in zf.namelist()
|
||||
|
||||
def test_flat_arcname_has_no_directory_markers(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
with ZipExportSink(target, "export", delete=False) as sink:
|
||||
sink.add_file(source_file, "doc.pdf")
|
||||
with zipfile.ZipFile(target / "export.zip") as zf:
|
||||
assert all(not n.endswith("/") for n in zf.namelist())
|
||||
|
||||
def test_exception_leaves_no_zip_and_no_tmp(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
with pytest.raises(RuntimeError):
|
||||
with ZipExportSink(target, "export", delete=False) as sink:
|
||||
sink.add_file(source_file, "doc.pdf")
|
||||
raise RuntimeError("boom")
|
||||
assert not (target / "export.zip").exists()
|
||||
assert not (target / "export.zip.tmp").exists()
|
||||
|
||||
def test_delete_wipes_destination_on_success(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
(target / "preexisting.txt").write_text("old")
|
||||
(target / "olddir").mkdir()
|
||||
with ZipExportSink(target, "export", delete=True) as sink:
|
||||
sink.add_file(source_file, "doc.pdf")
|
||||
assert (target / "export.zip").exists()
|
||||
assert not (target / "preexisting.txt").exists()
|
||||
assert not (target / "olddir").exists()
|
||||
|
||||
def test_abort_with_delete_does_not_wipe_destination(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
) -> None:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
(target / "preexisting.txt").write_text("old")
|
||||
with pytest.raises(RuntimeError):
|
||||
with ZipExportSink(target, "export", delete=True) as sink:
|
||||
sink.add_file(source_file, "doc.pdf")
|
||||
raise RuntimeError("boom")
|
||||
assert (target / "preexisting.txt").exists()
|
||||
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:
|
||||
target: Path = tmp_path / "out"
|
||||
target.mkdir()
|
||||
if request.param == "dir":
|
||||
return DirectoryExportSink(
|
||||
target,
|
||||
compare_checksums=False,
|
||||
compare_json=False,
|
||||
delete=False,
|
||||
)
|
||||
return ZipExportSink(target, "export", delete=False)
|
||||
|
||||
def test_second_concurrent_stream_is_rejected(self, sink: ExportSink) -> None:
|
||||
with sink:
|
||||
with sink.stream("manifest.json"):
|
||||
with pytest.raises(RuntimeError, match="already open"):
|
||||
with sink.stream("other.json"):
|
||||
pass
|
||||
@@ -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
|
||||
@@ -426,7 +428,7 @@ class TestExportImport(
|
||||
st_mtime_1 = (self.target / "manifest.json").stat().st_mtime
|
||||
|
||||
with mock.patch(
|
||||
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
|
||||
"documents.export.sinks.copy_file_with_basic_stats",
|
||||
) as m:
|
||||
self._do_export()
|
||||
m.assert_not_called()
|
||||
@@ -437,7 +439,7 @@ class TestExportImport(
|
||||
Path(self.d1.source_path).touch()
|
||||
|
||||
with mock.patch(
|
||||
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
|
||||
"documents.export.sinks.copy_file_with_basic_stats",
|
||||
) as m:
|
||||
self._do_export()
|
||||
self.assertEqual(m.call_count, 1)
|
||||
@@ -464,7 +466,7 @@ class TestExportImport(
|
||||
self.assertIsFile(self.target / "manifest.json")
|
||||
|
||||
with mock.patch(
|
||||
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
|
||||
"documents.export.sinks.copy_file_with_basic_stats",
|
||||
) as m:
|
||||
self._do_export()
|
||||
m.assert_not_called()
|
||||
@@ -475,7 +477,7 @@ class TestExportImport(
|
||||
self.d2.save()
|
||||
|
||||
with mock.patch(
|
||||
"documents.management.commands.document_exporter.copy_file_with_basic_stats",
|
||||
"documents.export.sinks.copy_file_with_basic_stats",
|
||||
) as m:
|
||||
self._do_export(compare_checksums=True)
|
||||
self.assertEqual(m.call_count, 1)
|
||||
@@ -1058,6 +1060,190 @@ class TestExportImport(
|
||||
|
||||
self.assertEqual(Document.objects.all().count(), 4)
|
||||
|
||||
def test_zip_with_compare_flags_raises(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A request to export to a zip file
|
||||
WHEN:
|
||||
- --compare-checksums or --compare-json is also passed
|
||||
THEN:
|
||||
- A CommandError is raised (the flags are no-ops in zip mode)
|
||||
"""
|
||||
for flag in ("--compare-checksums", "--compare-json"):
|
||||
with self.subTest(flag=flag):
|
||||
with self.assertRaises(CommandError):
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
flag,
|
||||
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)
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
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_stored(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A request to export to a zip file with --zip-compression stored
|
||||
WHEN:
|
||||
- --zip-compression-level is also passed
|
||||
THEN:
|
||||
- A CommandError is raised (stored ignores level entirely)
|
||||
"""
|
||||
with self.assertRaises(CommandError):
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
"--zip-compression",
|
||||
"stored",
|
||||
"--zip-compression-level",
|
||||
"5",
|
||||
skip_checks=True,
|
||||
)
|
||||
|
||||
def test_zip_compression_level_rejected_for_lzma(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A request to export to a zip file with --zip-compression lzma
|
||||
WHEN:
|
||||
- --zip-compression-level is also passed
|
||||
THEN:
|
||||
- A CommandError is raised (lzma ignores level entirely)
|
||||
"""
|
||||
with self.assertRaises(CommandError):
|
||||
call_command(
|
||||
"document_exporter",
|
||||
self.target,
|
||||
"--zip",
|
||||
"--zip-compression",
|
||||
"lzma",
|
||||
"--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
|
||||
"""
|
||||
with 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_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,35 @@ 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))
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
|
||||
Reference in New Issue
Block a user