From 7f1609332aa34516b4f25b7c6ef6bbed2d21ec6c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:43:04 -0700 Subject: [PATCH] Allow parsers to declare uses remote, and remote ocr_mode (#13634) * uses_remote_service + allow_remote to allow opt-in / out of remote OCR * Add to parser dev docs * remote_ocr_mode config setting * Checks for remote_ocr_mode and fix import * Update config.component.spec.ts * More tests for remote_ocr_mode * Docs for remote_ocr_mode * Ok, wire up the remote_ocr_mode with allow_remote for consumer * Update consumer.py * Format remote OCR mode check tests * Use get_choice_from_env --- docs/configuration.md | 12 ++ docs/development.md | 14 +++ docs/usage.md | 4 + .../admin/config/config.component.spec.ts | 1 + src-ui/src/app/data/paperless-config.ts | 16 +++ src/documents/consumer.py | 8 ++ src/documents/data_models.py | 3 + src/documents/tasks.py | 11 +- src/documents/tests/test_api_app_config.py | 1 + src/documents/tests/test_consumer.py | 80 ++++++++++++ src/documents/tests/test_tasks.py | 39 ++++++ src/paperless/config.py | 16 +++ ...pplicationconfiguration_remote_ocr_mode.py | 27 ++++ src/paperless/models.py | 18 +++ src/paperless/parsers/__init__.py | 9 ++ src/paperless/parsers/registry.py | 14 +++ src/paperless/parsers/remote.py | 5 + src/paperless/settings/__init__.py | 5 + src/paperless/tests/test_registry.py | 118 ++++++++++++++++++ src/paperless/tests/test_remote_ocr_config.py | 25 ++++ 20 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 src/paperless/migrations/0015_applicationconfiguration_remote_ocr_mode.py diff --git a/docs/configuration.md b/docs/configuration.md index ef40ebc4a..56e74a965 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2066,6 +2066,18 @@ password. All of these options come from their similarly-named [Django settings] Defaults to None. +#### [`PAPERLESS_REMOTE_OCR_MODE=`](#PAPERLESS_REMOTE_OCR_MODE) {#PAPERLESS_REMOTE_OCR_MODE} + +: Which documents are sent to the remote OCR engine. + + - `always`: every document of a supported file type is sent to the remote + engine, bypassing the local OCR engine. + - `workflow_only`: documents are processed locally unless a workflow + explicitly enables remote OCR for them, letting you use the remote engine + selectively. + + Defaults to "always". + ## AI {#ai} #### [`PAPERLESS_AI_ENABLED=`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED} diff --git a/docs/development.md b/docs/development.md index 46b84f703..5a845b2b7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -456,6 +456,20 @@ def score( return 10 ``` +**Remote services** + +If your parser sends document content to a remote service, declare it: + +```python +class MyCustomParser: + uses_remote_service = True +``` + +Paperless-ngx excludes such parsers when the document being consumed has not +been marked for remote processing, so users can keep remote OCR off by default +and enable it selectively with a workflow. Parsers that do not declare the +attribute are treated as fully local and are always considered. + **Archive and rendition flags** ```python diff --git a/docs/usage.md b/docs/usage.md index b25dd8d15..42e2716ba 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1096,6 +1096,10 @@ settings can be supplied as environment variables or via **Application Configura Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size or page limitations (e.g. with a free tier). +By default, every document of a supported file type is sent to the remote engine. To use it more selectively, set the +[remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally +unless a workflow explicitly enables remote OCR for them, so you can limit the remote engine to particular documents. + ## Architecture Paperless-ngx consists of the following components: diff --git a/src-ui/src/app/components/admin/config/config.component.spec.ts b/src-ui/src/app/components/admin/config/config.component.spec.ts index 7925b6ad1..a99171f93 100644 --- a/src-ui/src/app/components/admin/config/config.component.spec.ts +++ b/src-ui/src/app/components/admin/config/config.component.spec.ts @@ -179,6 +179,7 @@ describe('ConfigComponent', () => { 'remote_ocr_engine', 'remote_ocr_api_key', 'remote_ocr_endpoint', + 'remote_ocr_mode', ]) }) }) diff --git a/src-ui/src/app/data/paperless-config.ts b/src-ui/src/app/data/paperless-config.ts index 575525f1b..92af2cec5 100644 --- a/src-ui/src/app/data/paperless-config.ts +++ b/src-ui/src/app/data/paperless-config.ts @@ -73,6 +73,11 @@ export const RemoteOCREngineConfig = { AZURE_AI: 'azureai', } +export const RemoteOCRModeConfig = { + ALWAYS: 'always', + WORKFLOW_ONLY: 'workflow_only', +} + export interface ConfigOption { key: string title: string @@ -217,6 +222,16 @@ export const PaperlessConfigOptions: ConfigOption[] = [ section: ConfigSection.RemoteOCR, note: $localize`Required when using the Azure AI engine.`, }, + { + key: 'remote_ocr_mode', + title: $localize`Remote OCR Mode`, + type: ConfigOptionType.Select, + choices: mapToItems(RemoteOCRModeConfig), + config_key: 'PAPERLESS_REMOTE_OCR_MODE', + category: ConfigCategory.OCR, + section: ConfigSection.RemoteOCR, + note: $localize`Which documents are sent to the remote engine. Use 'workflow_only' to keep remote OCR off unless a workflow enables it for a document.`, + }, { key: 'app_logo', title: $localize`Application Logo`, @@ -437,6 +452,7 @@ export interface PaperlessConfig extends ObjectWithId { remote_ocr_engine: string remote_ocr_api_key: string remote_ocr_endpoint: string + remote_ocr_mode: string ai_enabled: boolean llm_embedding_backend: string llm_embedding_model: string diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 43185c19a..683f8f11a 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -53,6 +53,7 @@ from documents.utils import copy_basic_file_stats from documents.utils import copy_file_with_basic_stats from documents.utils import run_subprocess from paperless.config import OcrConfig +from paperless.config import RemoteOCRConfig from paperless.models import ArchiveFileGenerationChoices from paperless.parsers import ParserContext from paperless.parsers import ParserProtocol @@ -451,12 +452,19 @@ class ConsumerPlugin( except Exception as e: self.log.error(f"Error attempting to clean PDF: {e}") + # Workflows have already run at this point, so the metadata knows + # whether this document was singled out for remote OCR + allow_remote = ( + self.metadata.remote_ocr or RemoteOCRConfig().remote_ocr_by_default + ) + # Based on the mime type, get the parser for that type parser_class: type[ParserProtocol] | None = ( get_parser_registry().get_parser_for_file( mime_type, self.filename, self.working_copy, + allow_remote=allow_remote, ) ) if not parser_class: diff --git a/src/documents/data_models.py b/src/documents/data_models.py index 6d9e3a187..230af0684 100644 --- a/src/documents/data_models.py +++ b/src/documents/data_models.py @@ -34,6 +34,7 @@ class DocumentMetadataOverrides: skip_asn_if_exists: bool = False version_label: str | None = None actor_id: int | None = None + remote_ocr: bool = False def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides": """ @@ -57,6 +58,8 @@ class DocumentMetadataOverrides: self.actor_id = other.actor_id if other.skip_asn_if_exists: self.skip_asn_if_exists = True + if other.remote_ocr: + self.remote_ocr = True if other.version_label is not None: self.version_label = other.version_label diff --git a/src/documents/tasks.py b/src/documents/tasks.py index fd61960a1..c77d9fa30 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -67,6 +67,7 @@ from documents.utils import identity from documents.versioning import annotate_effective_content from documents.workflows.utils import get_workflows_for_trigger from paperless.config import AIConfig +from paperless.config import RemoteOCRConfig from paperless.logging import consume_task_id from paperless.parsers import ParserContext from paperless.parsers.registry import get_parser_registry @@ -338,10 +339,17 @@ def bulk_update_documents(document_ids) -> None: @shared_task -def update_document_content_maybe_archive_file(document_id) -> None: +def update_document_content_maybe_archive_file( + document_id, + *, + remote_ocr: bool = False, +) -> None: """ Re-creates OCR content and thumbnail for a document, and archive file if it exists. + + Remote OCR is used only when the engine is configured to handle everything + or if explicitly asked for via ``remote_ocr``. """ document = Document.objects.get(id=document_id) @@ -351,6 +359,7 @@ def update_document_content_maybe_archive_file(document_id) -> None: mime_type, document.original_filename or "", document.source_path, + allow_remote=remote_ocr or RemoteOCRConfig().remote_ocr_by_default, ) if not parser_class: diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index d4cc4b6d2..2b9d56d32 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -75,6 +75,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): "remote_ocr_engine": None, "remote_ocr_api_key": None, "remote_ocr_endpoint": None, + "remote_ocr_mode": None, "ai_enabled": False, "llm_embedding_backend": None, "llm_embedding_model": None, diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index e5b988e1d..69c5afb9d 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -1559,6 +1559,72 @@ class PostConsumeTestCase(DirectoriesMixin, GetConsumerMixin, TestCase): consumer.run_post_consume_script(doc) +class TestConsumerRemoteOCR( + DirectoriesMixin, + FileSystemAssertsMixin, + GetConsumerMixin, + TestCase, +): + """ + The consumer resolves the remote OCR mode and the per-document request from + workflows into the allow_remote flag it hands to the parser registry. + """ + + def setUp(self) -> None: + super().setUp() + + patcher = mock.patch("documents.consumer.get_parser_registry") + self.mock_registry = patcher.start() + self.mock_registry.return_value.get_parser_for_file.return_value = DummyParser + self.addCleanup(patcher.stop) + + def _consume(self, *, overrides: DocumentMetadataOverrides | None = None) -> bool: + src = ( + Path(__file__).parent + / "samples" + / "documents" + / "originals" + / "0000001.pdf" + ) + dst = self.dirs.scratch_dir / "sample.pdf" + shutil.copy(src, dst) + + with self.get_consumer(dst, overrides=overrides) as consumer: + consumer.run() + + _, kwargs = self.mock_registry.return_value.get_parser_for_file.call_args + return kwargs["allow_remote"] + + @override_settings(REMOTE_OCR_MODE="always") + def test_always_mode_allows_remote(self) -> None: + """ + GIVEN: Remote OCR mode is 'always'. + WHEN: A document is consumed without any workflow asking for it. + THEN: The registry is allowed to pick the remote parser. + """ + self.assertTrue(self._consume()) + + @override_settings(REMOTE_OCR_MODE="workflow_only") + def test_workflow_only_mode_denies_remote_by_default(self) -> None: + """ + GIVEN: Remote OCR mode is 'workflow_only'. + WHEN: A document is consumed and nothing asked for remote OCR. + THEN: The remote parser is excluded. + """ + self.assertFalse(self._consume()) + + @override_settings(REMOTE_OCR_MODE="workflow_only") + def test_workflow_only_mode_allows_remote_when_requested(self) -> None: + """ + GIVEN: Remote OCR mode is 'workflow_only'. + WHEN: A workflow set remote_ocr on the metadata overrides. + THEN: The registry is allowed to pick the remote parser. + """ + self.assertTrue( + self._consume(overrides=DocumentMetadataOverrides(remote_ocr=True)), + ) + + class TestMetadataOverrides(TestCase): def test_update_skip_asn_if_exists(self) -> None: base = DocumentMetadataOverrides() @@ -1566,6 +1632,20 @@ class TestMetadataOverrides(TestCase): base.update(incoming) self.assertTrue(base.skip_asn_if_exists) + def test_update_remote_ocr(self) -> None: + base = DocumentMetadataOverrides() + base.update(DocumentMetadataOverrides(remote_ocr=True)) + self.assertTrue(base.remote_ocr) + + def test_update_remote_ocr_is_not_unset(self) -> None: + """ + A later workflow that says nothing must not undo an earlier one that + asked for remote OCR. + """ + base = DocumentMetadataOverrides(remote_ocr=True) + base.update(DocumentMetadataOverrides()) + self.assertTrue(base.remote_ocr) + def test_update_actor_and_version_label(self) -> None: base = DocumentMetadataOverrides( actor_id=1, diff --git a/src/documents/tests/test_tasks.py b/src/documents/tests/test_tasks.py index 0e3d3f2a5..2954d6442 100644 --- a/src/documents/tests/test_tasks.py +++ b/src/documents/tests/test_tasks.py @@ -287,6 +287,45 @@ class TestUpdateContent(DirectoriesMixin, TestCase): self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test") +class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase): + """ + Consumption workflows do not run on reprocess, so the remote parser is + used only in 'always' mode or when the caller explicitly asks for it. + """ + + def setUp(self) -> None: + super().setUp() + + patcher = mock.patch("documents.tasks.get_parser_registry") + self.mock_registry = patcher.start() + self.mock_registry.return_value.get_parser_for_file.return_value = None + self.addCleanup(patcher.stop) + + self.doc = Document.objects.create( + title="test", + content="my document", + checksum="wow", + mime_type="application/pdf", + ) + + def _allow_remote(self, **kwargs) -> bool: + tasks.update_document_content_maybe_archive_file(self.doc.pk, **kwargs) + _, call_kwargs = self.mock_registry.return_value.get_parser_for_file.call_args + return call_kwargs["allow_remote"] + + @override_settings(REMOTE_OCR_MODE="always") + def test_always_mode_allows_remote(self) -> None: + self.assertTrue(self._allow_remote()) + + @override_settings(REMOTE_OCR_MODE="workflow_only") + def test_workflow_only_mode_denies_remote_by_default(self) -> None: + self.assertFalse(self._allow_remote()) + + @override_settings(REMOTE_OCR_MODE="workflow_only") + def test_workflow_only_mode_allows_remote_when_requested(self) -> None: + self.assertTrue(self._allow_remote(remote_ocr=True)) + + class TestAIIndex(DirectoriesMixin, TestCase): @override_settings( AI_ENABLED=True, diff --git a/src/paperless/config.py b/src/paperless/config.py index c3dcc3f15..d99d9e837 100644 --- a/src/paperless/config.py +++ b/src/paperless/config.py @@ -9,6 +9,7 @@ from paperless.models import CleanChoices from paperless.models import ColorConvertChoices from paperless.models import ModeChoices from paperless.models import OutputTypeChoices +from paperless.models import RemoteOCRMode @dataclasses.dataclass @@ -194,6 +195,7 @@ class RemoteOCRConfig(BaseConfig): remote_ocr_engine: str | None = dataclasses.field(init=False) remote_ocr_api_key: str | None = dataclasses.field(init=False) remote_ocr_endpoint: str | None = dataclasses.field(init=False) + remote_ocr_mode: RemoteOCRMode = dataclasses.field(init=False) def __post_init__(self) -> None: app_config = self._get_config_instance() @@ -207,6 +209,20 @@ class RemoteOCRConfig(BaseConfig): self.remote_ocr_endpoint = ( app_config.remote_ocr_endpoint or settings.REMOTE_OCR_ENDPOINT ) + self.remote_ocr_mode = app_config.remote_ocr_mode or RemoteOCRMode( + settings.REMOTE_OCR_MODE, + ) + + @property + def remote_ocr_by_default(self) -> bool: + """ + Whether every supported document goes to the remote engine. + + When False the remote engine is used only for documents that + explicitly asked for it, i.e. a workflow matched during consumption or + the user ticked the box when reprocessing. + """ + return self.remote_ocr_mode == RemoteOCRMode.ALWAYS @dataclasses.dataclass diff --git a/src/paperless/migrations/0015_applicationconfiguration_remote_ocr_mode.py b/src/paperless/migrations/0015_applicationconfiguration_remote_ocr_mode.py new file mode 100644 index 000000000..16b38e086 --- /dev/null +++ b/src/paperless/migrations/0015_applicationconfiguration_remote_ocr_mode.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.16 on 2026-08-10 15:43 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("paperless", "0014_applicationconfiguration_remote_ocr_api_key_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="applicationconfiguration", + name="remote_ocr_mode", + field=models.CharField( + blank=True, + choices=[ + ("always", "All supported documents"), + ("workflow_only", "Only when a workflow enables it"), + ], + max_length=32, + null=True, + verbose_name="Sets which documents are sent to the remote OCR engine", + ), + ), + ] diff --git a/src/paperless/models.py b/src/paperless/models.py index 7c230daba..73eaabfee 100644 --- a/src/paperless/models.py +++ b/src/paperless/models.py @@ -82,6 +82,15 @@ class RemoteOCREngine(models.TextChoices): AZURE_AI = ("azureai", _("Azure AI Document Intelligence")) +class RemoteOCRMode(models.TextChoices): + """ + Matches to PAPERLESS_REMOTE_OCR_MODE + """ + + ALWAYS = ("always", _("All supported documents")) + WORKFLOW_ONLY = ("workflow_only", _("Only when a workflow enables it")) + + class LLMEmbeddingBackend(models.TextChoices): OPENAI_LIKE = ("openai-like", _("OpenAI-compatible")) HUGGINGFACE = ("huggingface", _("Huggingface")) @@ -323,6 +332,15 @@ class ApplicationConfiguration(AbstractSingletonModel): max_length=256, ) + # PAPERLESS_REMOTE_OCR_MODE + remote_ocr_mode = models.CharField( + verbose_name=_("Sets which documents are sent to the remote OCR engine"), + blank=True, + null=True, + max_length=32, + choices=RemoteOCRMode.choices, + ) + """ AI related settings """ diff --git a/src/paperless/parsers/__init__.py b/src/paperless/parsers/__init__.py index 5ba42f1f1..f658a086c 100644 --- a/src/paperless/parsers/__init__.py +++ b/src/paperless/parsers/__init__.py @@ -134,6 +134,11 @@ class ParserProtocol(Protocol): Author or organisation name. url : str URL for documentation, source code, or issue tracker. + + Parsers that send document content to a remote service should additionally + set ``uses_remote_service = True`` so the registry can exclude them when + remote processing has not been requested for a document. The attribute is + optional so a parser that omits it is treated as fully local. """ # ------------------------------------------------------------------ @@ -145,6 +150,10 @@ class ParserProtocol(Protocol): author: str url: str + # NOTE: uses_remote_service is not declared here, the registry reads it + # with getattr(cls, ..., False) for backwards-compatibility with existing + # parsers + # ------------------------------------------------------------------ # Class methods # ------------------------------------------------------------------ diff --git a/src/paperless/parsers/registry.py b/src/paperless/parsers/registry.py index c81fb1c45..7a2b57832 100644 --- a/src/paperless/parsers/registry.py +++ b/src/paperless/parsers/registry.py @@ -334,6 +334,8 @@ class ParserRegistry: mime_type: str, filename: str, path: Path | None = None, + *, + allow_remote: bool = True, ) -> type[ParserProtocol] | None: """Return the best parser class for the given file, or None. @@ -359,6 +361,11 @@ class ParserRegistry: path: Optional filesystem path to the file. Forwarded to each parser's score method. + allow_remote: + When False, parsers that declare ``uses_remote_service = True`` + are excluded from consideration, so a document is never sent to + a remote service. Parsers that do not declare the attribute + are treated as local and are always considered. Returns ------- @@ -374,6 +381,13 @@ class ParserRegistry: if mime_type not in parser_class.supported_mime_types(): continue + if not allow_remote and getattr( + parser_class, + "uses_remote_service", + False, + ): + continue + score = parser_class.score(mime_type, filename, path) if score is None: continue diff --git a/src/paperless/parsers/remote.py b/src/paperless/parsers/remote.py index f76528807..403ca0a10 100644 --- a/src/paperless/parsers/remote.py +++ b/src/paperless/parsers/remote.py @@ -102,6 +102,9 @@ class RemoteDocumentParser: Maintainer name. url : str Issue tracker / source URL. + uses_remote_service : bool + Content is sent to a remote service, True so that the registry + can skip this parser if remote processing was not requested. """ name: str = "Paperless-ngx Remote OCR Parser" @@ -109,6 +112,8 @@ class RemoteDocumentParser: author: str = "Paperless-ngx Contributors" url: str = "https://github.com/paperless-ngx/paperless-ngx" + uses_remote_service: bool = True + # ------------------------------------------------------------------ # Class methods # ------------------------------------------------------------------ diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index f833ed7f5..401f52959 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -1203,6 +1203,11 @@ WEBHOOKS_ALLOW_INTERNAL_REQUESTS = get_bool_from_env( REMOTE_OCR_ENGINE = os.getenv("PAPERLESS_REMOTE_OCR_ENGINE") REMOTE_OCR_API_KEY = os.getenv("PAPERLESS_REMOTE_OCR_API_KEY") REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT") +REMOTE_OCR_MODE = get_choice_from_env( + "PAPERLESS_REMOTE_OCR_MODE", + {"always", "workflow_only"}, + default="always", +) ################################################################################ # AI Settings # diff --git a/src/paperless/tests/test_registry.py b/src/paperless/tests/test_registry.py index a371abc35..0e27f1da9 100644 --- a/src/paperless/tests/test_registry.py +++ b/src/paperless/tests/test_registry.py @@ -468,6 +468,124 @@ class TestParserRegistryGetParserForFile: assert result is AcceptingBuiltin +class TestParserRegistryRemoteParsers: + """Verify the allow_remote filter in ParserRegistry.get_parser_for_file().""" + + @staticmethod + def _remote_parser_cls() -> type: + class RemoteParser: + name = "remote" + version = "1.0" + author = "A" + url = "https://example.com/remote" + uses_remote_service = True + + @classmethod + def supported_mime_types(cls): + return {"text/plain": ".txt"} + + @classmethod + def score(cls, mime_type, filename, path=None): + return 20 + + return RemoteParser + + def test_remote_parser_wins_when_remote_allowed( + self, + dummy_parser_cls: type, + ) -> None: + """ + GIVEN: A remote parser scoring 20 and a local parser scoring 10. + WHEN: get_parser_for_file() is called with allow_remote=True. + THEN: The remote parser is returned. + """ + remote_parser_cls = self._remote_parser_cls() + registry = ParserRegistry() + registry.register_builtin(dummy_parser_cls) + registry.register_builtin(remote_parser_cls) + + result = registry.get_parser_for_file( + "text/plain", + "readme.txt", + allow_remote=True, + ) + assert result is remote_parser_cls + + def test_remote_parser_skipped_when_remote_not_allowed( + self, + dummy_parser_cls: type, + ) -> None: + """ + GIVEN: A remote parser scoring 20 and a local parser scoring 10. + WHEN: get_parser_for_file() is called with allow_remote=False. + THEN: The local parser is returned despite its lower score. + """ + registry = ParserRegistry() + registry.register_builtin(dummy_parser_cls) + registry.register_builtin(self._remote_parser_cls()) + + result = registry.get_parser_for_file( + "text/plain", + "readme.txt", + allow_remote=False, + ) + assert result is dummy_parser_cls + + def test_no_parser_when_only_remote_available_and_not_allowed(self) -> None: + """ + GIVEN: A registry whose only candidate declares uses_remote_service. + WHEN: get_parser_for_file() is called with allow_remote=False. + THEN: None is returned — the remote parser is never used as a + fallback when remote processing was not requested. + """ + registry = ParserRegistry() + registry.register_builtin(self._remote_parser_cls()) + + result = registry.get_parser_for_file( + "text/plain", + "readme.txt", + allow_remote=False, + ) + assert result is None + + def test_parser_without_attribute_treated_as_local( + self, + dummy_parser_cls: type, + ) -> None: + """ + GIVEN: A third-party parser predating uses_remote_service, so it does + not declare the attribute at all. + WHEN: get_parser_for_file() is called with allow_remote=False. + THEN: It is still considered, i.e. treated as fully local, rather + than raising AttributeError. + """ + assert not hasattr(dummy_parser_cls, "uses_remote_service") + + registry = ParserRegistry() + registry.register_builtin(dummy_parser_cls) + + result = registry.get_parser_for_file( + "text/plain", + "readme.txt", + allow_remote=False, + ) + assert result is dummy_parser_cls + + def test_remote_allowed_by_default(self) -> None: + """ + GIVEN: A registry containing only a remote parser. + WHEN: get_parser_for_file() is called without allow_remote. + THEN: The remote parser is returned — callers that do not opt in to + the filter keep the previous behaviour. + """ + remote_parser_cls = self._remote_parser_cls() + registry = ParserRegistry() + registry.register_builtin(remote_parser_cls) + + result = registry.get_parser_for_file("text/plain", "readme.txt") + assert result is remote_parser_cls + + class TestDiscover: """Verify entrypoint discovery in ParserRegistry.discover().""" diff --git a/src/paperless/tests/test_remote_ocr_config.py b/src/paperless/tests/test_remote_ocr_config.py index b331d41a9..d0a58b119 100644 --- a/src/paperless/tests/test_remote_ocr_config.py +++ b/src/paperless/tests/test_remote_ocr_config.py @@ -8,6 +8,7 @@ import pytest from django.test import override_settings from paperless.config import RemoteOCRConfig +from paperless.models import RemoteOCRMode if TYPE_CHECKING: from unittest.mock import MagicMock @@ -20,6 +21,7 @@ def null_app_config(mocker) -> MagicMock: remote_ocr_engine=None, remote_ocr_api_key=None, remote_ocr_endpoint=None, + remote_ocr_mode=None, ) @@ -47,10 +49,12 @@ class TestRemoteOCRConfig: REMOTE_OCR_ENGINE="azureai", REMOTE_OCR_API_KEY="env-key", REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com", + REMOTE_OCR_MODE=RemoteOCRMode.WORKFLOW_ONLY, ) assert cfg.remote_ocr_engine == "azureai" assert cfg.remote_ocr_api_key == "env-key" assert cfg.remote_ocr_endpoint == "https://env.cognitiveservices.azure.com" + assert cfg.remote_ocr_mode == RemoteOCRMode.WORKFLOW_ONLY def test_app_config_takes_precedence( self, @@ -61,16 +65,19 @@ class TestRemoteOCRConfig: remote_ocr_engine="azureai", remote_ocr_api_key="db-key", remote_ocr_endpoint="https://db.cognitiveservices.azure.com", + remote_ocr_mode=RemoteOCRMode.WORKFLOW_ONLY, ) cfg = make_remote_ocr_config( app_config, REMOTE_OCR_ENGINE=None, REMOTE_OCR_API_KEY="env-key", REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com", + REMOTE_OCR_MODE=RemoteOCRMode.ALWAYS, ) assert cfg.remote_ocr_engine == "azureai" assert cfg.remote_ocr_api_key == "db-key" assert cfg.remote_ocr_endpoint == "https://db.cognitiveservices.azure.com" + assert cfg.remote_ocr_mode == RemoteOCRMode.WORKFLOW_ONLY def test_unset_everywhere( self, @@ -86,3 +93,21 @@ class TestRemoteOCRConfig: assert cfg.remote_ocr_engine is None assert cfg.remote_ocr_api_key is None assert cfg.remote_ocr_endpoint is None + + +class TestRemoteOCRByDefault: + def test_always_mode(self, make_remote_ocr_config, null_app_config) -> None: + cfg = make_remote_ocr_config( + null_app_config, + REMOTE_OCR_MODE=RemoteOCRMode.ALWAYS, + ) + + assert cfg.remote_ocr_by_default is True + + def test_workflow_only_mode(self, make_remote_ocr_config, null_app_config) -> None: + cfg = make_remote_ocr_config( + null_app_config, + REMOTE_OCR_MODE=RemoteOCRMode.WORKFLOW_ONLY, + ) + + assert cfg.remote_ocr_by_default is False