Compare commits

...

3 Commits

Author SHA1 Message Date
Trenton H
885b1078dc Merge branch 'dev' into feature-migrate-export-import-rich 2026-03-06 09:52:35 -08:00
Trenton H
db1e4ce432 Refactor: add explicit supports_progress_bar and supports_multiprocessing to all PaperlessCommand subclasses
Each management command now explicitly declares both class attributes
rather than relying on defaults, making intent unambiguous at a glance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 15:05:15 -08:00
Trenton H
0b3cdd6934 Refactor: migrate exporter/importer from tqdm to PaperlessCommand.track()
Replace direct tqdm usage in document_exporter and document_importer with
the PaperlessCommand base class and its track() method, which is backed by
Rich and handles --no-progress-bar automatically. Also removes the unused
ProgressBarMixin from mixins.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 08:56:55 -08:00
13 changed files with 42 additions and 49 deletions

View File

@@ -304,7 +304,7 @@ class PaperlessCommand(RichCommand):
Progress output is directed to stderr to match the convention that Progress output is directed to stderr to match the convention that
progress bars are transient UI feedback, not command output. This progress bars are transient UI feedback, not command output. This
mirrors tqdm's default behavior and prevents progress bar rendering mirrors the convention that progress bars are transient UI feedback and prevents progress bar rendering
from interfering with stdout-based assertions in tests or piped from interfering with stdout-based assertions in tests or piped
command output. command output.

View File

@@ -17,6 +17,7 @@ class Command(PaperlessCommand):
"modified) after their initial import." "modified) after their initial import."
) )
supports_progress_bar = True
supports_multiprocessing = True supports_multiprocessing = True
def add_arguments(self, parser): def add_arguments(self, parser):

View File

@@ -8,7 +8,6 @@ from itertools import islice
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import tqdm
from allauth.mfa.models import Authenticator from allauth.mfa.models import Authenticator
from allauth.socialaccount.models import SocialAccount from allauth.socialaccount.models import SocialAccount
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
@@ -19,7 +18,6 @@ from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core import serializers from django.core import serializers
from django.core.management.base import BaseCommand
from django.core.management.base import CommandError from django.core.management.base import CommandError
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction from django.db import transaction
@@ -38,6 +36,7 @@ if settings.AUDIT_LOG_ENABLED:
from documents.file_handling import delete_empty_directories from documents.file_handling import delete_empty_directories
from documents.file_handling import generate_filename from documents.file_handling import generate_filename
from documents.management.commands.base import PaperlessCommand
from documents.management.commands.mixins import CryptMixin from documents.management.commands.mixins import CryptMixin
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
@@ -81,14 +80,18 @@ def serialize_queryset_batched(
yield serializers.serialize("python", chunk) yield serializers.serialize("python", chunk)
class Command(CryptMixin, BaseCommand): class Command(CryptMixin, PaperlessCommand):
help = ( help = (
"Decrypt and rename all files in our collection into a given target " "Decrypt and rename all files in our collection into a given target "
"directory. And include a manifest file containing document data for " "directory. And include a manifest file containing document data for "
"easy import." "easy import."
) )
supports_progress_bar = True
supports_multiprocessing = False
def add_arguments(self, parser) -> None: def add_arguments(self, parser) -> None:
super().add_arguments(parser)
parser.add_argument("target") parser.add_argument("target")
parser.add_argument( parser.add_argument(
@@ -195,13 +198,6 @@ class Command(CryptMixin, BaseCommand):
help="If set, only the database will be imported, not files", help="If set, only the database will be imported, not files",
) )
parser.add_argument(
"--no-progress-bar",
default=False,
action="store_true",
help="If set, the progress bar will not be shown",
)
parser.add_argument( parser.add_argument(
"--passphrase", "--passphrase",
help="If provided, is used to encrypt sensitive data in the export", help="If provided, is used to encrypt sensitive data in the export",
@@ -230,7 +226,6 @@ class Command(CryptMixin, BaseCommand):
self.no_thumbnail: bool = options["no_thumbnail"] self.no_thumbnail: bool = options["no_thumbnail"]
self.zip_export: bool = options["zip"] self.zip_export: bool = options["zip"]
self.data_only: bool = options["data_only"] self.data_only: bool = options["data_only"]
self.no_progress_bar: bool = options["no_progress_bar"]
self.passphrase: str | None = options.get("passphrase") self.passphrase: str | None = options.get("passphrase")
self.batch_size: int = options["batch_size"] self.batch_size: int = options["batch_size"]
@@ -347,10 +342,12 @@ class Command(CryptMixin, BaseCommand):
document_manifest = manifest_dict["documents"] document_manifest = manifest_dict["documents"]
# 3. Export files from each document # 3. Export files from each document
for index, document_dict in tqdm.tqdm( for index, document_dict in enumerate(
enumerate(document_manifest), self.track(
total=len(document_manifest), document_manifest,
disable=self.no_progress_bar, description="Exporting documents...",
total=len(document_manifest),
),
): ):
document = document_map[document_dict["pk"]] document = document_map[document_dict["pk"]]

View File

@@ -40,6 +40,7 @@ def _process_and_match(work: _WorkPackage) -> _WorkResult:
class Command(PaperlessCommand): class Command(PaperlessCommand):
help = "Searches for documents where the content almost matches" help = "Searches for documents where the content almost matches"
supports_progress_bar = True
supports_multiprocessing = True supports_multiprocessing = True
def add_arguments(self, parser): def add_arguments(self, parser):

View File

@@ -8,14 +8,12 @@ from pathlib import Path
from zipfile import ZipFile from zipfile import ZipFile
from zipfile import is_zipfile from zipfile import is_zipfile
import tqdm
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.core.management.base import CommandError from django.core.management.base import CommandError
from django.core.serializers.base import DeserializationError from django.core.serializers.base import DeserializationError
from django.db import IntegrityError from django.db import IntegrityError
@@ -25,6 +23,7 @@ from django.db.models.signals import post_save
from filelock import FileLock from filelock import FileLock
from documents.file_handling import create_source_path_directory from documents.file_handling import create_source_path_directory
from documents.management.commands.base import PaperlessCommand
from documents.management.commands.mixins import CryptMixin from documents.management.commands.mixins import CryptMixin
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
@@ -57,21 +56,18 @@ def disable_signal(sig, receiver, sender, *, weak: bool | None = None) -> Genera
sig.connect(receiver=receiver, sender=sender, **kwargs) sig.connect(receiver=receiver, sender=sender, **kwargs)
class Command(CryptMixin, BaseCommand): class Command(CryptMixin, PaperlessCommand):
help = ( help = (
"Using a manifest.json file, load the data from there, and import the " "Using a manifest.json file, load the data from there, and import the "
"documents it refers to." "documents it refers to."
) )
def add_arguments(self, parser) -> None: supports_progress_bar = True
parser.add_argument("source") supports_multiprocessing = False
parser.add_argument( def add_arguments(self, parser) -> None:
"--no-progress-bar", super().add_arguments(parser)
default=False, parser.add_argument("source")
action="store_true",
help="If set, the progress bar will not be shown",
)
parser.add_argument( parser.add_argument(
"--data-only", "--data-only",
@@ -231,7 +227,6 @@ class Command(CryptMixin, BaseCommand):
self.source = Path(options["source"]).resolve() self.source = Path(options["source"]).resolve()
self.data_only: bool = options["data_only"] self.data_only: bool = options["data_only"]
self.no_progress_bar: bool = options["no_progress_bar"]
self.passphrase: str | None = options.get("passphrase") self.passphrase: str | None = options.get("passphrase")
self.version: str | None = None self.version: str | None = None
self.salt: str | None = None self.salt: str | None = None
@@ -365,7 +360,7 @@ class Command(CryptMixin, BaseCommand):
filter(lambda r: r["model"] == "documents.document", self.manifest), filter(lambda r: r["model"] == "documents.document", self.manifest),
) )
for record in tqdm.tqdm(manifest_documents, disable=self.no_progress_bar): for record in self.track(manifest_documents, description="Copying files..."):
document = Document.objects.get(pk=record["pk"]) document = Document.objects.get(pk=record["pk"])
doc_file = record[EXPORTER_FILE_NAME] doc_file = record[EXPORTER_FILE_NAME]

View File

@@ -8,6 +8,9 @@ from documents.tasks import index_reindex
class Command(PaperlessCommand): class Command(PaperlessCommand):
help = "Manages the document index." help = "Manages the document index."
supports_progress_bar = True
supports_multiprocessing = False
def add_arguments(self, parser): def add_arguments(self, parser):
super().add_arguments(parser) super().add_arguments(parser)
parser.add_argument("command", choices=["reindex", "optimize"]) parser.add_argument("command", choices=["reindex", "optimize"])

View File

@@ -7,6 +7,9 @@ from documents.tasks import llmindex_index
class Command(PaperlessCommand): class Command(PaperlessCommand):
help = "Manages the LLM-based vector index for Paperless." help = "Manages the LLM-based vector index for Paperless."
supports_progress_bar = True
supports_multiprocessing = False
def add_arguments(self, parser: Any) -> None: def add_arguments(self, parser: Any) -> None:
super().add_arguments(parser) super().add_arguments(parser)
parser.add_argument("command", choices=["rebuild", "update"]) parser.add_argument("command", choices=["rebuild", "update"])

View File

@@ -7,6 +7,9 @@ from documents.models import Document
class Command(PaperlessCommand): class Command(PaperlessCommand):
help = "Rename all documents" help = "Rename all documents"
supports_progress_bar = True
supports_multiprocessing = False
def handle(self, *args, **options): def handle(self, *args, **options):
for document in self.track(Document.objects.all(), description="Renaming..."): for document in self.track(Document.objects.all(), description="Renaming..."):
post_save.send(Document, instance=document, created=False) post_save.send(Document, instance=document, created=False)

View File

@@ -180,6 +180,9 @@ class Command(PaperlessCommand):
"modified) after their initial import." "modified) after their initial import."
) )
supports_progress_bar = True
supports_multiprocessing = False
def add_arguments(self, parser) -> None: def add_arguments(self, parser) -> None:
super().add_arguments(parser) super().add_arguments(parser)
parser.add_argument("-c", "--correspondent", default=False, action="store_true") parser.add_argument("-c", "--correspondent", default=False, action="store_true")

View File

@@ -24,6 +24,9 @@ _LEVEL_STYLE: dict[int, tuple[str, str]] = {
class Command(PaperlessCommand): class Command(PaperlessCommand):
help = "This command checks your document archive for issues." help = "This command checks your document archive for issues."
supports_progress_bar = True
supports_multiprocessing = False
def _render_results(self, messages: SanityCheckMessages) -> None: def _render_results(self, messages: SanityCheckMessages) -> None:
"""Render sanity check results as a Rich table.""" """Render sanity check results as a Rich table."""

View File

@@ -36,6 +36,7 @@ def _process_document(doc_id: int) -> None:
class Command(PaperlessCommand): class Command(PaperlessCommand):
help = "This will regenerate the thumbnails for all documents." help = "This will regenerate the thumbnails for all documents."
supports_progress_bar = True
supports_multiprocessing = True supports_multiprocessing = True
def add_arguments(self, parser) -> None: def add_arguments(self, parser) -> None:

View File

@@ -1,6 +1,5 @@
import base64 import base64
import os import os
from argparse import ArgumentParser
from typing import TypedDict from typing import TypedDict
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
@@ -21,25 +20,6 @@ class CryptFields(TypedDict):
fields: list[str] fields: list[str]
class ProgressBarMixin:
"""
Many commands use a progress bar, which can be disabled
via this class
"""
def add_argument_progress_bar_mixin(self, parser: ArgumentParser) -> None:
parser.add_argument(
"--no-progress-bar",
default=False,
action="store_true",
help="If set, the progress bar will not be shown",
)
def handle_progress_bar_mixin(self, *args, **options) -> None:
self.no_progress_bar = options["no_progress_bar"]
self.use_progress_bar = not self.no_progress_bar
class CryptMixin: class CryptMixin:
""" """
Fully based on: Fully based on:

View File

@@ -9,6 +9,9 @@ class Command(PaperlessCommand):
help = "Prunes the audit logs of objects that no longer exist." help = "Prunes the audit logs of objects that no longer exist."
supports_progress_bar = True
supports_multiprocessing = False
def handle(self, *args, **options): def handle(self, *args, **options):
with transaction.atomic(): with transaction.atomic():
for log_entry in self.track( for log_entry in self.track(