""" Tests for :mod:`paperless.parsers` (ParserProtocol) and :mod:`paperless.parsers.registry` (ParserRegistry + module-level helpers). All tests use pytest-style functions/classes — no unittest.TestCase. The ``clean_registry`` fixture ensures complete isolation between tests by resetting the module-level singleton before and after every test. """ from __future__ import annotations import logging from importlib.metadata import EntryPoint from pathlib import Path from typing import Self from unittest.mock import MagicMock from unittest.mock import patch import pytest from paperless.parsers import ParserContext from paperless.parsers import ParserProtocol from paperless.parsers.registry import ParserRegistry from paperless.parsers.registry import get_parser_registry from paperless.parsers.registry import init_builtin_parsers from paperless.parsers.registry import reset_parser_registry @pytest.fixture() def dummy_parser_cls() -> type: """Return a class that fully satisfies :class:`ParserProtocol`. GIVEN: A need to exercise registry and Protocol logic with a minimal but complete parser. WHEN: A test requests this fixture. THEN: A class with all required attributes and methods is returned. """ class DummyParser: name = "dummy-parser" version = "0.1.0" author = "Test Author" url = "https://example.com/dummy-parser" @classmethod def supported_mime_types(cls) -> dict[str, str]: return {"text/plain": ".txt"} @classmethod def score( cls, mime_type: str, filename: str, path: Path | None = None, ) -> int | None: return 10 @property def can_produce_archive(self) -> bool: return False @property def requires_pdf_rendition(self) -> bool: return False def parse( self, document_path: Path, mime_type: str, *, produce_archive: bool = True, ) -> None: """ Required to exist, but doesn't need to do anything """ def get_text(self) -> str: return "" def get_date(self) -> None: return None def get_archive_path(self) -> Path | None: return None def get_thumbnail( self, document_path: Path, mime_type: str, ) -> Path: return Path("/tmp/thumbnail.webp") def get_page_count( self, document_path: Path, mime_type: str, ) -> int | None: return None def extract_metadata( self, document_path: Path, mime_type: str, ) -> list: return [] def configure(self, context: ParserContext) -> None: """ Required to exist, but doesn't need to do anything """ def __enter__(self) -> Self: return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: """ Required to exist, but doesn't need to do anything """ return DummyParser class TestParserProtocol: """Verify runtime isinstance() checks against ParserProtocol.""" def test_compliant_class_instance_passes_isinstance( self, dummy_parser_cls: type, ) -> None: """ GIVEN: A class that implements every method required by ParserProtocol. WHEN: isinstance() is called with the Protocol. THEN: The check passes (returns True). """ instance = dummy_parser_cls() assert isinstance(instance, ParserProtocol) def test_non_compliant_class_instance_fails_isinstance(self) -> None: """ GIVEN: A plain class with no parser-related methods. WHEN: isinstance() is called with ParserProtocol. THEN: The check fails (returns False). """ class Unrelated: pass assert not isinstance(Unrelated(), ParserProtocol) @pytest.mark.parametrize( "missing_method", [ pytest.param("configure", id="missing-configure"), pytest.param("parse", id="missing-parse"), pytest.param("get_text", id="missing-get_text"), pytest.param("get_thumbnail", id="missing-get_thumbnail"), pytest.param("__enter__", id="missing-__enter__"), pytest.param("__exit__", id="missing-__exit__"), ], ) def test_partial_compliant_fails_isinstance( self, dummy_parser_cls: type, missing_method: str, ) -> None: """ GIVEN: A class that satisfies ParserProtocol except for one method. WHEN: isinstance() is called with ParserProtocol. THEN: The check fails because the Protocol is not fully satisfied. """ # Create a subclass and delete the specified method to break compliance. partial_cls = type( "PartialParser", (dummy_parser_cls,), {missing_method: None}, # Replace with None — not callable ) assert not isinstance(partial_cls(), ParserProtocol) class TestRegistrySingleton: """Verify the module-level singleton lifecycle functions.""" def test_get_parser_registry_returns_instance(self) -> None: """ GIVEN: No registry has been created yet. WHEN: get_parser_registry() is called. THEN: A ParserRegistry instance is returned. """ registry = get_parser_registry() assert isinstance(registry, ParserRegistry) def test_get_parser_registry_same_instance_on_repeated_calls(self) -> None: """ GIVEN: A registry instance was created by a prior call. WHEN: get_parser_registry() is called a second time. THEN: The exact same object (identity) is returned. """ first = get_parser_registry() second = get_parser_registry() assert first is second def test_reset_parser_registry_gives_fresh_instance(self) -> None: """ GIVEN: A registry instance already exists. WHEN: reset_parser_registry() is called and then get_parser_registry() is called again. THEN: A new, distinct registry instance is returned. """ first = get_parser_registry() reset_parser_registry() second = get_parser_registry() assert first is not second def test_init_builtin_parsers_does_not_run_discover( self, monkeypatch: pytest.MonkeyPatch, ) -> None: """ GIVEN: discover() would raise an exception if called. WHEN: init_builtin_parsers() is called. THEN: No exception is raised, confirming discover() was not invoked. """ def exploding_discover(self) -> None: raise RuntimeError( "discover() must not be called from init_builtin_parsers", ) monkeypatch.setattr(ParserRegistry, "discover", exploding_discover) # Should complete without raising. init_builtin_parsers() def test_init_builtin_parsers_idempotent(self) -> None: """ GIVEN: init_builtin_parsers() has already been called once. WHEN: init_builtin_parsers() is called a second time. THEN: No error is raised and the same registry instance is reused. """ init_builtin_parsers() # Capture the registry created by the first call. import paperless.parsers.registry as reg_module first_registry = reg_module._registry init_builtin_parsers() assert reg_module._registry is first_registry class TestParserRegistryGetParserForFile: """Verify parser selection logic in get_parser_for_file().""" def test_returns_none_when_no_parsers_registered(self) -> None: """ GIVEN: A registry with no parsers registered. WHEN: get_parser_for_file() is called for any MIME type. THEN: None is returned. """ registry = ParserRegistry() result = registry.get_parser_for_file("text/plain", "doc.txt") assert result is None def test_returns_none_for_unsupported_mime_type( self, dummy_parser_cls: type, ) -> None: """ GIVEN: A registry with a parser that supports only 'text/plain'. WHEN: get_parser_for_file() is called with 'application/pdf'. THEN: None is returned. """ registry = ParserRegistry() registry.register_builtin(dummy_parser_cls) result = registry.get_parser_for_file("application/pdf", "file.pdf") assert result is None def test_returns_parser_for_supported_mime_type( self, dummy_parser_cls: type, ) -> None: """ GIVEN: A registry with a parser registered for 'text/plain'. WHEN: get_parser_for_file() is called with 'text/plain'. THEN: The registered parser class is returned. """ registry = ParserRegistry() registry.register_builtin(dummy_parser_cls) result = registry.get_parser_for_file("text/plain", "readme.txt") assert result is dummy_parser_cls def test_highest_score_wins(self) -> None: """ GIVEN: Two parsers both supporting 'text/plain' with scores 5 and 20. WHEN: get_parser_for_file() is called for 'text/plain'. THEN: The parser with score 20 is returned. """ class LowScoreParser: name = "low" version = "1.0" author = "A" url = "https://example.com/low" @classmethod def supported_mime_types(cls): return {"text/plain": ".txt"} @classmethod def score(cls, mime_type, filename, path=None): return 5 class HighScoreParser: name = "high" version = "1.0" author = "B" url = "https://example.com/high" @classmethod def supported_mime_types(cls): return {"text/plain": ".txt"} @classmethod def score(cls, mime_type, filename, path=None): return 20 registry = ParserRegistry() registry.register_builtin(LowScoreParser) registry.register_builtin(HighScoreParser) result = registry.get_parser_for_file("text/plain", "readme.txt") assert result is HighScoreParser def test_parser_returning_none_score_is_skipped(self) -> None: """ GIVEN: A parser that returns None from score() for the given file. WHEN: get_parser_for_file() is called. THEN: That parser is skipped and None is returned (no other candidates). """ class DecliningParser: name = "declining" version = "1.0" author = "A" url = "https://example.com" @classmethod def supported_mime_types(cls): return {"text/plain": ".txt"} @classmethod def score(cls, mime_type, filename, path=None): return None # Explicitly declines registry = ParserRegistry() registry.register_builtin(DecliningParser) result = registry.get_parser_for_file("text/plain", "readme.txt") assert result is None def test_all_parsers_decline_returns_none(self) -> None: """ GIVEN: Multiple parsers that all return None from score(). WHEN: get_parser_for_file() is called. THEN: None is returned. """ class AlwaysDeclines: name = "declines" version = "1.0" author = "A" url = "https://example.com" @classmethod def supported_mime_types(cls): return {"text/plain": ".txt"} @classmethod def score(cls, mime_type, filename, path=None): return None registry = ParserRegistry() registry.register_builtin(AlwaysDeclines) registry._external.append(AlwaysDeclines) result = registry.get_parser_for_file("text/plain", "file.txt") assert result is None def test_external_parser_beats_builtin_same_score(self) -> None: """ GIVEN: An external and a built-in parser both returning score 10. WHEN: get_parser_for_file() is called. THEN: The external parser wins because externals are evaluated first and the first-seen-wins policy applies at equal scores. """ class BuiltinParser: name = "builtin" version = "1.0" author = "Core" url = "https://example.com/builtin" @classmethod def supported_mime_types(cls): return {"text/plain": ".txt"} @classmethod def score(cls, mime_type, filename, path=None): return 10 class ExternalParser: name = "external" version = "2.0" author = "Third Party" url = "https://example.com/external" @classmethod def supported_mime_types(cls): return {"text/plain": ".txt"} @classmethod def score(cls, mime_type, filename, path=None): return 10 registry = ParserRegistry() registry.register_builtin(BuiltinParser) registry._external.append(ExternalParser) result = registry.get_parser_for_file("text/plain", "file.txt") assert result is ExternalParser def test_builtin_wins_when_external_declines(self) -> None: """ GIVEN: An external parser that declines (score None) and a built-in that returns score 5. WHEN: get_parser_for_file() is called. THEN: The built-in parser is returned. """ class DecliningExternal: name = "declining-external" version = "1.0" author = "Third Party" url = "https://example.com/declining" @classmethod def supported_mime_types(cls): return {"text/plain": ".txt"} @classmethod def score(cls, mime_type, filename, path=None): return None class AcceptingBuiltin: name = "accepting-builtin" version = "1.0" author = "Core" url = "https://example.com/accepting" @classmethod def supported_mime_types(cls): return {"text/plain": ".txt"} @classmethod def score(cls, mime_type, filename, path=None): return 5 registry = ParserRegistry() registry.register_builtin(AcceptingBuiltin) registry._external.append(DecliningExternal) result = registry.get_parser_for_file("text/plain", "file.txt") 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().""" def test_discover_with_no_entrypoints(self) -> None: """ GIVEN: No entrypoints are registered under 'paperless_ngx.parsers'. WHEN: discover() is called. THEN: _external remains empty and no errors are raised. """ registry = ParserRegistry() with patch( "paperless.parsers.registry.entry_points", return_value=[], ): registry.discover() assert registry._external == [] def test_discover_adds_valid_external_parser(self) -> None: """ GIVEN: One valid entrypoint whose loaded class has all required attrs. WHEN: discover() is called. THEN: The class is appended to _external. """ class ValidExternal: name = "valid-external" version = "3.0.0" author = "Someone" url = "https://example.com/valid" @classmethod def supported_mime_types(cls): return {"application/pdf": ".pdf"} @classmethod def score(cls, mime_type, filename, path=None): return 5 mock_ep = MagicMock(spec=EntryPoint) mock_ep.name = "valid_external" mock_ep.load.return_value = ValidExternal registry = ParserRegistry() with patch( "paperless.parsers.registry.entry_points", return_value=[mock_ep], ): registry.discover() assert ValidExternal in registry._external def test_discover_skips_entrypoint_with_load_error( self, caplog: pytest.LogCaptureFixture, ) -> None: """ GIVEN: An entrypoint whose load() method raises ImportError. WHEN: discover() is called. THEN: The entrypoint is skipped, an error is logged, and _external remains empty. """ mock_ep = MagicMock(spec=EntryPoint) mock_ep.name = "broken_ep" mock_ep.load.side_effect = ImportError("missing dependency") registry = ParserRegistry() with caplog.at_level(logging.ERROR, logger="paperless.parsers.registry"): with patch( "paperless.parsers.registry.entry_points", return_value=[mock_ep], ): registry.discover() assert registry._external == [] assert any( "broken_ep" in record.message for record in caplog.records if record.levelno >= logging.ERROR ) def test_discover_skips_entrypoint_with_missing_attrs( self, caplog: pytest.LogCaptureFixture, ) -> None: """ GIVEN: A class loaded from an entrypoint that is missing the 'score' attribute. WHEN: discover() is called. THEN: The entrypoint is skipped, a warning is logged, and _external remains empty. """ class MissingScore: name = "missing-score" version = "1.0" author = "Someone" url = "https://example.com" # 'score' classmethod is intentionally absent. @classmethod def supported_mime_types(cls): return {"text/plain": ".txt"} mock_ep = MagicMock(spec=EntryPoint) mock_ep.name = "missing_score_ep" mock_ep.load.return_value = MissingScore registry = ParserRegistry() with caplog.at_level(logging.WARNING, logger="paperless.parsers.registry"): with patch( "paperless.parsers.registry.entry_points", return_value=[mock_ep], ): registry.discover() assert registry._external == [] assert any( "missing_score_ep" in record.message for record in caplog.records if record.levelno >= logging.WARNING ) def test_discover_logs_loaded_parser_info( self, caplog: pytest.LogCaptureFixture, ) -> None: """ GIVEN: A valid entrypoint that loads successfully. WHEN: discover() is called. THEN: An INFO log message is emitted containing the parser name, version, author, and entrypoint name. """ class LoggableParser: name = "loggable" version = "4.2.0" author = "Log Tester" url = "https://example.com/loggable" @classmethod def supported_mime_types(cls): return {"image/png": ".png"} @classmethod def score(cls, mime_type, filename, path=None): return 1 mock_ep = MagicMock(spec=EntryPoint) mock_ep.name = "loggable_ep" mock_ep.load.return_value = LoggableParser registry = ParserRegistry() with caplog.at_level(logging.INFO, logger="paperless.parsers.registry"): with patch( "paperless.parsers.registry.entry_points", return_value=[mock_ep], ): registry.discover() info_messages = " ".join( r.message for r in caplog.records if r.levelno == logging.INFO ) assert "loggable" in info_messages assert "4.2.0" in info_messages assert "Log Tester" in info_messages assert "loggable_ep" in info_messages class TestLogSummary: """Verify log output from ParserRegistry.log_summary().""" def test_log_summary_with_no_external_parsers( self, dummy_parser_cls: type, caplog: pytest.LogCaptureFixture, ) -> None: """ GIVEN: A registry with one built-in parser and no external parsers. WHEN: log_summary() is called. THEN: The built-in parser name appears in the logs. """ registry = ParserRegistry() registry.register_builtin(dummy_parser_cls) with caplog.at_level(logging.INFO, logger="paperless.parsers.registry"): registry.log_summary() all_messages = " ".join(r.message for r in caplog.records) assert dummy_parser_cls.name in all_messages def test_log_summary_with_external_parsers( self, caplog: pytest.LogCaptureFixture, ) -> None: """ GIVEN: A registry with one external parser registered. WHEN: log_summary() is called. THEN: The external parser name, version, author, and url appear in the log output. """ class ExtParser: name = "ext-parser" version = "9.9.9" author = "Ext Corp" url = "https://ext.example.com" @classmethod def supported_mime_types(cls): return {} @classmethod def score(cls, mime_type, filename, path=None): return None registry = ParserRegistry() registry._external.append(ExtParser) with caplog.at_level(logging.INFO, logger="paperless.parsers.registry"): registry.log_summary() all_messages = " ".join(r.message for r in caplog.records) assert "ext-parser" in all_messages assert "9.9.9" in all_messages assert "Ext Corp" in all_messages assert "https://ext.example.com" in all_messages def test_log_summary_logs_no_third_party_message_when_none( self, caplog: pytest.LogCaptureFixture, ) -> None: """ GIVEN: A registry with no external parsers. WHEN: log_summary() is called. THEN: A message containing 'No third-party parsers discovered.' is logged. """ registry = ParserRegistry() with caplog.at_level(logging.INFO, logger="paperless.parsers.registry"): registry.log_summary() all_messages = " ".join(r.message for r in caplog.records) assert "No third-party parsers discovered." in all_messages