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 94ab4f297..ee1ac18fa 100644 --- a/src/paperless/parsers/remote.py +++ b/src/paperless/parsers/remote.py @@ -95,6 +95,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" @@ -102,6 +105,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/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()."""