7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
458 lines
17 KiB
Python
458 lines
17 KiB
Python
"""
|
|
Comprehensive tests for loader_registry module.
|
|
|
|
Covers registry lookup logic, loader selection by file type,
|
|
optional-loader availability flags, loader kwargs, error handling,
|
|
and edge cases not covered in existing test files.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from langchain_core.document_loaders import BaseLoader
|
|
|
|
from local_deep_research.document_loaders.loader_registry import (
|
|
LOADER_REGISTRY,
|
|
get_loader_class_for_extension,
|
|
get_loader_for_path,
|
|
get_supported_extensions,
|
|
is_extension_supported,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry structure and content
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRegistryStructure:
|
|
"""Verify the structure and invariants of LOADER_REGISTRY."""
|
|
|
|
def test_every_loader_class_is_a_type(self):
|
|
"""Every loader_class value must be a class (type), not an instance."""
|
|
for ext, entry in LOADER_REGISTRY.items():
|
|
assert isinstance(entry["loader_class"], type), (
|
|
f"{ext}: loader_class should be a type, got {type(entry['loader_class'])}"
|
|
)
|
|
|
|
def test_loader_kwargs_defaults_to_empty_dict(self):
|
|
"""loader_kwargs should always be a dict (may be empty)."""
|
|
for ext, entry in LOADER_REGISTRY.items():
|
|
kwargs = entry.get("loader_kwargs", {})
|
|
assert isinstance(kwargs, dict), (
|
|
f"{ext}: loader_kwargs is not a dict"
|
|
)
|
|
|
|
def test_minimum_number_of_registered_extensions(self):
|
|
"""Registry should have a reasonable minimum number of extensions."""
|
|
# Core set: pdf, txt, md, markdown, docx, doc, csv, xlsx, xls,
|
|
# html, htm, ppt, pptx, xml, eml, tsv, json, yaml, yml,
|
|
# ipynb, enex, toml, mhtml, mht = 24
|
|
assert len(LOADER_REGISTRY) >= 24
|
|
|
|
|
|
class TestRegistrySpecificLoaderClasses:
|
|
"""Verify that specific extensions map to the expected loader class."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"ext, expected_class_name",
|
|
[
|
|
(".pdf", "PyPDFLoader"),
|
|
(".txt", "TextLoader"),
|
|
(".md", "UnstructuredMarkdownLoader"),
|
|
(".markdown", "UnstructuredMarkdownLoader"),
|
|
(".docx", "UnstructuredWordDocumentLoader"),
|
|
(".csv", "CSVLoader"),
|
|
(".xlsx", "UnstructuredExcelLoader"),
|
|
(".xls", "XLSLoader"),
|
|
(".html", "UnstructuredHTMLLoader"),
|
|
(".htm", "UnstructuredHTMLLoader"),
|
|
(".pptx", "UnstructuredPowerPointLoader"),
|
|
(".xml", "UnstructuredXMLLoader"),
|
|
(".eml", "UnstructuredEmailLoader"),
|
|
(".tsv", "CSVLoader"),
|
|
(".json", "SimpleJSONLoader"),
|
|
(".yaml", "YAMLLoader"),
|
|
(".yml", "YAMLLoader"),
|
|
(".ipynb", "NotebookLoader"),
|
|
(".enex", "EverNoteLoader"),
|
|
(".toml", "TomlLoader"),
|
|
(".mhtml", "MHTMLLoader"),
|
|
(".mht", "MHTMLLoader"),
|
|
],
|
|
)
|
|
def test_extension_maps_to_expected_class(self, ext, expected_class_name):
|
|
entry = LOADER_REGISTRY[ext]
|
|
assert entry["loader_class"].__name__ == expected_class_name
|
|
|
|
|
|
class TestRegistrySpecificKwargs:
|
|
"""Test that specific extensions carry the expected kwargs."""
|
|
|
|
def test_txt_encoding_kwargs(self):
|
|
entry = LOADER_REGISTRY[".txt"]
|
|
assert entry["loader_kwargs"]["encoding"] == "utf-8"
|
|
assert entry["loader_kwargs"]["autodetect_encoding"] is True
|
|
|
|
def test_tsv_delimiter_kwargs(self):
|
|
entry = LOADER_REGISTRY[".tsv"]
|
|
assert "csv_args" in entry["loader_kwargs"]
|
|
assert entry["loader_kwargs"]["csv_args"]["delimiter"] == "\t"
|
|
|
|
def test_ipynb_include_outputs(self):
|
|
entry = LOADER_REGISTRY[".ipynb"]
|
|
assert entry["loader_kwargs"]["include_outputs"] is True
|
|
assert entry["loader_kwargs"]["remove_newline"] is True
|
|
|
|
def test_enex_load_single_document_false(self):
|
|
entry = LOADER_REGISTRY[".enex"]
|
|
assert entry["loader_kwargs"]["load_single_document"] is False
|
|
|
|
def test_pdf_kwargs_empty(self):
|
|
entry = LOADER_REGISTRY[".pdf"]
|
|
assert entry["loader_kwargs"] == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Optional loader availability flags
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOptionalLoaderFlags:
|
|
"""Test that optional loaders are conditionally registered."""
|
|
|
|
def test_odt_conditional_registration(self):
|
|
from local_deep_research.document_loaders import loader_registry as mod
|
|
|
|
if mod.HAS_ODT_LOADER:
|
|
assert ".odt" in LOADER_REGISTRY
|
|
else:
|
|
assert ".odt" not in LOADER_REGISTRY
|
|
|
|
def test_epub_conditional_registration(self):
|
|
from local_deep_research.document_loaders import loader_registry as mod
|
|
|
|
if mod.HAS_EPUB_LOADER:
|
|
assert ".epub" in LOADER_REGISTRY
|
|
else:
|
|
assert ".epub" not in LOADER_REGISTRY
|
|
|
|
def test_rtf_conditional_registration(self):
|
|
from local_deep_research.document_loaders import loader_registry as mod
|
|
|
|
if mod.HAS_RTF_LOADER:
|
|
assert ".rtf" in LOADER_REGISTRY
|
|
else:
|
|
assert ".rtf" not in LOADER_REGISTRY
|
|
|
|
def test_rst_conditional_registration(self):
|
|
from local_deep_research.document_loaders import loader_registry as mod
|
|
|
|
if mod.HAS_RST_LOADER:
|
|
assert ".rst" in LOADER_REGISTRY
|
|
else:
|
|
assert ".rst" not in LOADER_REGISTRY
|
|
|
|
def test_org_conditional_registration(self):
|
|
from local_deep_research.document_loaders import loader_registry as mod
|
|
|
|
if mod.HAS_ORG_LOADER:
|
|
assert ".org" in LOADER_REGISTRY
|
|
else:
|
|
assert ".org" not in LOADER_REGISTRY
|
|
|
|
def test_image_conditional_registration(self):
|
|
from local_deep_research.document_loaders import loader_registry as mod
|
|
|
|
image_exts = [".png", ".jpg", ".jpeg", ".tiff", ".tif", ".bmp", ".heic"]
|
|
# Images require both the loader class and the OCR runtime dep
|
|
# (pytesseract + the tesseract binary).
|
|
if mod.HAS_IMAGE_LOADER and mod.HAS_OCR_DEP:
|
|
for ext in image_exts:
|
|
assert ext in LOADER_REGISTRY, (
|
|
f"{ext} missing despite image loader + OCR dep"
|
|
)
|
|
else:
|
|
for ext in image_exts:
|
|
assert ext not in LOADER_REGISTRY, (
|
|
f"{ext} present despite missing image loader or OCR dep"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_supported_extensions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetSupportedExtensionsCoverage:
|
|
def test_matches_registry_keys(self):
|
|
"""Return value must match LOADER_REGISTRY keys exactly."""
|
|
assert set(get_supported_extensions()) == set(LOADER_REGISTRY.keys())
|
|
|
|
def test_returns_fresh_list_each_call(self):
|
|
"""Each call returns a new list object (no shared mutable state)."""
|
|
a = get_supported_extensions()
|
|
b = get_supported_extensions()
|
|
assert a is not b
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_extension_supported - additional edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsExtensionSupportedEdgeCases:
|
|
def test_empty_string(self):
|
|
assert is_extension_supported("") is False
|
|
|
|
def test_dot_only(self):
|
|
assert is_extension_supported(".") is False
|
|
|
|
def test_multiple_dots(self):
|
|
"""Extension like '.tar.gz' -- only last suffix matters in Path, but
|
|
the function receives raw string; '.tar.gz' starts with dot so treated
|
|
as-is and should not match."""
|
|
assert is_extension_supported(".tar.gz") is False
|
|
|
|
def test_whitespace_extension(self):
|
|
assert is_extension_supported(" ") is False
|
|
assert is_extension_supported(" .pdf") is False
|
|
|
|
def test_numeric_extension(self):
|
|
assert is_extension_supported(".123") is False
|
|
|
|
def test_all_registered_extensions_report_supported(self):
|
|
"""Cross-check: every key in LOADER_REGISTRY should be supported."""
|
|
for ext in LOADER_REGISTRY:
|
|
assert is_extension_supported(ext) is True, (
|
|
f"{ext} not reported as supported"
|
|
)
|
|
|
|
def test_without_dot_case_insensitive(self):
|
|
"""Without-dot + uppercase should still resolve."""
|
|
assert is_extension_supported("JSON") is True
|
|
assert is_extension_supported("Csv") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_loader_class_for_extension - additional coverage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetLoaderClassForExtensionCoverage:
|
|
def test_returns_none_for_empty_string(self):
|
|
assert get_loader_class_for_extension("") is None
|
|
|
|
def test_returns_none_for_dot_only(self):
|
|
assert get_loader_class_for_extension(".") is None
|
|
|
|
def test_yml_same_class_as_yaml(self):
|
|
r1 = get_loader_class_for_extension(".yml")
|
|
r2 = get_loader_class_for_extension(".yaml")
|
|
assert r1 is not None and r2 is not None
|
|
assert r1[0] is r2[0]
|
|
|
|
def test_htm_same_class_as_html(self):
|
|
r1 = get_loader_class_for_extension(".htm")
|
|
r2 = get_loader_class_for_extension(".html")
|
|
assert r1 is not None and r2 is not None
|
|
assert r1[0] is r2[0]
|
|
|
|
def test_markdown_same_class_as_md(self):
|
|
r1 = get_loader_class_for_extension(".markdown")
|
|
r2 = get_loader_class_for_extension(".md")
|
|
assert r1 is not None and r2 is not None
|
|
assert r1[0] is r2[0]
|
|
|
|
def test_mht_same_class_as_mhtml(self):
|
|
r1 = get_loader_class_for_extension(".mht")
|
|
r2 = get_loader_class_for_extension(".mhtml")
|
|
assert r1 is not None and r2 is not None
|
|
assert r1[0] is r2[0]
|
|
|
|
def test_xls_and_xlsx_use_distinct_loaders(self):
|
|
# .xlsx uses the unstructured Excel loader; legacy .xls uses our own
|
|
# XLSLoader (pandas + xlrd) to avoid the msoffcrypto pre-check crash.
|
|
r_xls = get_loader_class_for_extension(".xls")
|
|
r_xlsx = get_loader_class_for_extension(".xlsx")
|
|
assert r_xls is not None and r_xlsx is not None
|
|
assert r_xls[0].__name__ == "XLSLoader"
|
|
assert r_xlsx[0].__name__ == "UnstructuredExcelLoader"
|
|
|
|
def test_doc_same_class_as_docx_when_registered(self):
|
|
# .doc is only registered when LibreOffice is available; when it is,
|
|
# it must share the Word loader class with .docx.
|
|
r1 = get_loader_class_for_extension(".doc")
|
|
r2 = get_loader_class_for_extension(".docx")
|
|
assert r2 is not None
|
|
if r1 is not None:
|
|
assert r1[0] is r2[0]
|
|
|
|
def test_ppt_same_class_as_pptx_when_registered(self):
|
|
r1 = get_loader_class_for_extension(".ppt")
|
|
r2 = get_loader_class_for_extension(".pptx")
|
|
assert r2 is not None
|
|
if r1 is not None:
|
|
assert r1[0] is r2[0]
|
|
|
|
@pytest.mark.parametrize("ext", list(LOADER_REGISTRY.keys()))
|
|
def test_all_registry_extensions_resolve(self, ext):
|
|
"""Every registered extension must resolve to a non-None result."""
|
|
result = get_loader_class_for_extension(ext)
|
|
assert result is not None, f"{ext} returned None"
|
|
assert len(result) == 2
|
|
|
|
def test_tsv_kwargs_include_tab_delimiter(self):
|
|
result = get_loader_class_for_extension(".tsv")
|
|
assert result is not None
|
|
kwargs = result[1]
|
|
assert kwargs["csv_args"]["delimiter"] == "\t"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_loader_for_path - comprehensive coverage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetLoaderForPathCoverage:
|
|
def test_returns_base_loader_subclass_for_txt(self, tmp_path: Path):
|
|
f = tmp_path / "sample.txt"
|
|
f.write_text("hello")
|
|
loader = get_loader_for_path(f)
|
|
assert loader is not None
|
|
assert isinstance(loader, BaseLoader)
|
|
|
|
def test_returns_base_loader_subclass_for_csv(self, tmp_path: Path):
|
|
f = tmp_path / "data.csv"
|
|
f.write_text("a,b\n1,2\n")
|
|
loader = get_loader_for_path(f)
|
|
assert loader is not None
|
|
assert isinstance(loader, BaseLoader)
|
|
|
|
def test_returns_none_for_unsupported_extension(self, tmp_path: Path):
|
|
f = tmp_path / "file.zzz"
|
|
f.write_text("data")
|
|
assert get_loader_for_path(f) is None
|
|
|
|
def test_accepts_pathlib_path(self, tmp_path: Path):
|
|
f = tmp_path / "test.txt"
|
|
f.write_text("x")
|
|
loader = get_loader_for_path(f)
|
|
assert loader is not None
|
|
|
|
def test_accepts_string_path(self, tmp_path: Path):
|
|
f = tmp_path / "test.txt"
|
|
f.write_text("x")
|
|
loader = get_loader_for_path(str(f))
|
|
assert loader is not None
|
|
|
|
def test_case_insensitive_extension(self, tmp_path: Path):
|
|
f = tmp_path / "TEST.TXT"
|
|
f.write_text("x")
|
|
loader = get_loader_for_path(f)
|
|
assert loader is not None
|
|
|
|
def test_mixed_case_extension(self, tmp_path: Path):
|
|
f = tmp_path / "readme.Md"
|
|
f.write_text("# Title")
|
|
loader = get_loader_for_path(f)
|
|
assert loader is not None
|
|
|
|
def test_returns_none_on_loader_constructor_exception(self, tmp_path: Path):
|
|
"""When the loader constructor raises, get_loader_for_path returns None."""
|
|
f = tmp_path / "broken.txt"
|
|
f.write_text("content")
|
|
|
|
with patch.dict(
|
|
LOADER_REGISTRY,
|
|
{".txt": {"loader_class": _RaisingLoader, "loader_kwargs": {}}},
|
|
):
|
|
result = get_loader_for_path(f)
|
|
assert result is None
|
|
|
|
def test_no_suffix_returns_none(self, tmp_path: Path):
|
|
"""File without extension should return None."""
|
|
f = tmp_path / "Makefile"
|
|
f.write_text("all:")
|
|
assert get_loader_for_path(f) is None
|
|
|
|
def test_hidden_file_with_extension(self, tmp_path: Path):
|
|
"""Hidden file like .gitignore has suffix '' on some systems; ensure
|
|
no crash even if suffix is empty."""
|
|
f = tmp_path / ".hidden"
|
|
f.write_text("secret")
|
|
# .hidden has suffix '' -- should return None gracefully
|
|
result = get_loader_for_path(f)
|
|
# Just ensure no exception; result depends on whether '.hidden' is registered
|
|
assert result is None or isinstance(result, BaseLoader)
|
|
|
|
def test_double_extension_uses_last_suffix(self, tmp_path: Path):
|
|
"""Path('a.backup.txt').suffix == '.txt', so it should resolve to TextLoader."""
|
|
f = tmp_path / "notes.backup.txt"
|
|
f.write_text("content")
|
|
loader = get_loader_for_path(f)
|
|
assert loader is not None
|
|
|
|
def test_json_loader_for_json_file(self, tmp_path: Path):
|
|
from local_deep_research.document_loaders.json_loader import (
|
|
SimpleJSONLoader,
|
|
)
|
|
|
|
f = tmp_path / "data.json"
|
|
f.write_text('{"key": "value"}')
|
|
loader = get_loader_for_path(f)
|
|
assert loader is not None
|
|
assert isinstance(loader, SimpleJSONLoader)
|
|
|
|
def test_yaml_loader_for_yaml_file(self, tmp_path: Path):
|
|
from local_deep_research.document_loaders.yaml_loader import YAMLLoader
|
|
|
|
f = tmp_path / "config.yaml"
|
|
f.write_text("key: value")
|
|
loader = get_loader_for_path(f)
|
|
assert loader is not None
|
|
assert isinstance(loader, YAMLLoader)
|
|
|
|
def test_yaml_loader_for_yml_file(self, tmp_path: Path):
|
|
from local_deep_research.document_loaders.yaml_loader import YAMLLoader
|
|
|
|
f = tmp_path / "config.yml"
|
|
f.write_text("key: value")
|
|
loader = get_loader_for_path(f)
|
|
assert loader is not None
|
|
assert isinstance(loader, YAMLLoader)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Consistency checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConsistencyBetweenFunctions:
|
|
"""Ensure is_extension_supported, get_loader_class_for_extension, and
|
|
get_supported_extensions agree."""
|
|
|
|
@pytest.mark.parametrize("ext", list(LOADER_REGISTRY.keys()))
|
|
def test_supported_extension_resolves_to_loader_class(self, ext):
|
|
assert is_extension_supported(ext) is True
|
|
assert get_loader_class_for_extension(ext) is not None
|
|
|
|
def test_unsupported_extension_consistent_across_functions(self):
|
|
ext = ".this_does_not_exist_42"
|
|
assert is_extension_supported(ext) is False
|
|
assert get_loader_class_for_extension(ext) is None
|
|
assert ext not in get_supported_extensions()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _RaisingLoader:
|
|
"""Fake loader whose __init__ always raises, used to test error handling."""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
raise RuntimeError("Intentional test error")
|