mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-08 18:57:58 +00:00
* Chore(deps): Bump the utilities-minor group across 1 directory with 11 updates Bumps the utilities-minor group with 11 updates in the / directory: | Package | From | To | | --- | --- | --- | | [django-guardian](https://github.com/django-guardian/django-guardian) | `3.3.3` | `3.4.0` | | [django-treenode](https://github.com/fabiocaccamo/django-treenode) | `0.24.0` | `0.25.0` | | [drf-spectacular-sidecar](https://github.com/tfranzel/drf-spectacular-sidecar) | `2026.7.1` | `2026.8.1` | | [imap-tools](https://github.com/ikvk/imap_tools) | `1.14.0` | `1.15.0` | | [ocrmypdf](https://github.com/ocrmypdf/OCRmyPDF) | `17.10.0` | `17.11.0` | | [prek](https://github.com/j178/prek) | `0.4.11` | `0.5.0` | | [faker](https://github.com/joke2k/faker) | `40.36.0` | `40.37.0` | | [pytest-django](https://github.com/pytest-dev/pytest-django) | `4.12.0` | `4.14.0` | | [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) | `16.4` | `16.6` | | [time-machine](https://github.com/adamchainz/time-machine) | `3.2.0` | `3.5.0` | | [types-pygments](https://github.com/python/typeshed) | `2.20.0.20260408` | `2.21.0.20260819` | Updates `django-guardian` from 3.3.3 to 3.4.0 - [Release notes](https://github.com/django-guardian/django-guardian/releases) - [Commits](https://github.com/django-guardian/django-guardian/compare/3.3.3...3.4.0) Updates `django-treenode` from 0.24.0 to 0.25.0 - [Release notes](https://github.com/fabiocaccamo/django-treenode/releases) - [Changelog](https://github.com/fabiocaccamo/django-treenode/blob/main/CHANGELOG.md) - [Commits](https://github.com/fabiocaccamo/django-treenode/compare/0.24.0...0.25.0) Updates `drf-spectacular-sidecar` from 2026.7.1 to 2026.8.1 - [Commits](https://github.com/tfranzel/drf-spectacular-sidecar/compare/2026.7.1...2026.8.1) Updates `imap-tools` from 1.14.0 to 1.15.0 - [Release notes](https://github.com/ikvk/imap_tools/releases) - [Changelog](https://github.com/ikvk/imap_tools/blob/master/docs/release_notes.rst) - [Commits](https://github.com/ikvk/imap_tools/compare/v1.14.0...v1.15.0) Updates `ocrmypdf` from 17.10.0 to 17.11.0 - [Release notes](https://github.com/ocrmypdf/OCRmyPDF/releases) - [Commits](https://github.com/ocrmypdf/OCRmyPDF/compare/v17.10.0...v17.11.0) Updates `prek` from 0.4.11 to 0.5.0 - [Release notes](https://github.com/j178/prek/releases) - [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md) - [Commits](https://github.com/j178/prek/compare/v0.4.11...v0.5.0) Updates `faker` from 40.36.0 to 40.37.0 - [Release notes](https://github.com/joke2k/faker/releases) - [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md) - [Commits](https://github.com/joke2k/faker/compare/v40.36.0...v40.37.0) Updates `pytest-django` from 4.12.0 to 4.14.0 - [Release notes](https://github.com/pytest-dev/pytest-django/releases) - [Changelog](https://github.com/pytest-dev/pytest-django/blob/main/docs/changelog.rst) - [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.12.0...v4.14.0) Updates `pytest-rerunfailures` from 16.4 to 16.6 - [Changelog](https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst) - [Commits](https://github.com/pytest-dev/pytest-rerunfailures/compare/16.4...16.6) Updates `time-machine` from 3.2.0 to 3.5.0 - [Changelog](https://github.com/adamchainz/time-machine/blob/main/docs/changelog.rst) - [Commits](https://github.com/adamchainz/time-machine/compare/3.2.0...3.5.0) Updates `types-pygments` from 2.20.0.20260408 to 2.21.0.20260819 - [Commits](https://github.com/python/typeshed/commits) --- updated-dependencies: - dependency-name: django-guardian dependency-version: 3.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: django-treenode dependency-version: 0.25.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: drf-spectacular-sidecar dependency-version: 2026.8.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: faker dependency-version: 40.37.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: imap-tools dependency-version: 1.15.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: ocrmypdf dependency-version: 17.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: prek dependency-version: 0.5.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: pytest-django dependency-version: 4.14.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: pytest-rerunfailures dependency-version: '16.6' dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: time-machine dependency-version: 3.5.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor - dependency-name: types-pygments dependency-version: 2.21.0.20260819 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: utilities-minor ... Signed-off-by: dependabot[bot] <support@github.com> * Handles pytest-django * Formatting --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com>
371 lines
13 KiB
Python
371 lines
13 KiB
Python
import io
|
|
import json
|
|
import os
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import pytest_mock
|
|
from pytest_django.fixtures import Settings
|
|
|
|
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_exception_inside_stream_cleans_up_manifest_tmp(
|
|
self,
|
|
tmp_path: Path,
|
|
source_file: Path,
|
|
settings: Settings,
|
|
) -> None:
|
|
scratch_dir = tmp_path / "scratch"
|
|
settings.SCRATCH_DIR = scratch_dir
|
|
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")
|
|
with sink.stream("manifest.json") as handle:
|
|
handle.write("[")
|
|
raise RuntimeError("boom")
|
|
assert list(scratch_dir.glob("export-manifest-*")) == []
|
|
assert not (target / "export.zip").exists()
|
|
assert not (target / "export.zip.tmp").exists()
|
|
|
|
def test_abort_after_manifest_written_cleans_up_pending_tmp(
|
|
self,
|
|
tmp_path: Path,
|
|
settings: Settings,
|
|
) -> None:
|
|
scratch_dir = tmp_path / "scratch"
|
|
settings.SCRATCH_DIR = scratch_dir
|
|
target: Path = tmp_path / "out"
|
|
target.mkdir()
|
|
with pytest.raises(RuntimeError):
|
|
with ZipExportSink(target, "export", delete=False) as sink:
|
|
with sink.stream("manifest.json") as handle:
|
|
handle.write("[]")
|
|
raise RuntimeError("boom")
|
|
assert list(scratch_dir.glob("export-manifest-*")) == []
|
|
assert not (target / "export.zip").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
|