chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -0,0 +1,143 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from haystack import Document
|
||||
from haystack.components.converters.image.document_to_image import DocumentToImageContent
|
||||
from haystack.core.serialization import component_from_dict, component_to_dict
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class TestDocumentToImageContent:
|
||||
def test_to_dict(self) -> None:
|
||||
converter = DocumentToImageContent()
|
||||
assert component_to_dict(converter, "converter") == {
|
||||
"init_parameters": {"file_path_meta_field": "file_path", "root_path": "", "detail": None, "size": None},
|
||||
"type": "haystack.components.converters.image.document_to_image.DocumentToImageContent",
|
||||
}
|
||||
|
||||
def test_to_dict_not_defaults(self) -> None:
|
||||
converter = DocumentToImageContent(
|
||||
file_path_meta_field="image_path", root_path="/data", detail="high", size=(800, 600)
|
||||
)
|
||||
assert component_to_dict(converter, "converter") == {
|
||||
"init_parameters": {
|
||||
"file_path_meta_field": "image_path",
|
||||
"root_path": "/data",
|
||||
"detail": "high",
|
||||
"size": (800, 600),
|
||||
},
|
||||
"type": "haystack.components.converters.image.document_to_image.DocumentToImageContent",
|
||||
}
|
||||
|
||||
def test_from_dict(self) -> None:
|
||||
data = {
|
||||
"init_parameters": {
|
||||
"file_path_meta_field": "image_path",
|
||||
"root_path": "/test",
|
||||
"detail": "auto",
|
||||
"size": (512, 512),
|
||||
},
|
||||
"type": "haystack.components.converters.image.document_to_image.DocumentToImageContent",
|
||||
}
|
||||
converter = component_from_dict(DocumentToImageContent, data, "name")
|
||||
assert component_to_dict(converter, "converter") == data
|
||||
|
||||
def test_run_with_empty_documents_list(self) -> None:
|
||||
converter = DocumentToImageContent()
|
||||
results = converter.run(documents=[])
|
||||
assert results == {"image_contents": []}
|
||||
|
||||
def test_run_with_missing_file_path_metadata(self) -> None:
|
||||
converter = DocumentToImageContent()
|
||||
# Document without file_path in metadata
|
||||
doc_no_path = Document(content="test", meta={})
|
||||
# Document with file_path but file doesn't exist
|
||||
doc_no_file = Document(content="test", meta={"file_path": "nonexistent.jpg"})
|
||||
with pytest.raises(ValueError, match="is missing the 'file_path' key"):
|
||||
_ = converter.run(documents=[doc_no_path, doc_no_file])
|
||||
|
||||
def test_run_with_non_image_documents(self) -> None:
|
||||
converter = DocumentToImageContent()
|
||||
docx_doc = Document(content="test", meta={"file_path": "test/test_files/docx/sample_docx.docx"})
|
||||
with pytest.raises(ValueError, match="has an unsupported MIME type"):
|
||||
_ = converter.run(documents=[docx_doc])
|
||||
|
||||
def test_run_with_invalid_file_path(self, caplog) -> None:
|
||||
converter = DocumentToImageContent()
|
||||
pdf_doc = Document(content="test", meta={"file_path": "wrong_name.jpg"})
|
||||
with pytest.raises(ValueError, match="has an invalid file path 'wrong_name.jpg'"):
|
||||
_ = converter.run(documents=[pdf_doc])
|
||||
|
||||
def test_run_with_pdf_missing_page_number(self, caplog) -> None:
|
||||
converter = DocumentToImageContent()
|
||||
pdf_doc = Document(content="test", meta={"file_path": "test/test_files/pdf/sample_pdf_1.pdf"})
|
||||
with pytest.raises(ValueError, match="is missing the 'page_number' key"):
|
||||
_ = converter.run(documents=[pdf_doc])
|
||||
|
||||
def test_run_with_image_documents(self) -> None:
|
||||
converter = DocumentToImageContent(root_path="test/test_files/images")
|
||||
image_doc = Document(content="test", meta={"file_path": "apple.jpg"})
|
||||
results = converter.run(documents=[image_doc])
|
||||
assert len(results["image_contents"]) == 1
|
||||
assert results["image_contents"][0].meta == {"file_path": "apple.jpg"}
|
||||
|
||||
def test_run_with_pdf_documents(self) -> None:
|
||||
converter = DocumentToImageContent()
|
||||
pdf_doc = Document(content="test", meta={"file_path": "test/test_files/pdf/sample_pdf_1.pdf", "page_number": 1})
|
||||
results = converter.run(documents=[pdf_doc])
|
||||
assert len(results["image_contents"]) == 1
|
||||
assert results["image_contents"][0].meta == {
|
||||
"file_path": "test/test_files/pdf/sample_pdf_1.pdf",
|
||||
"page_number": 1,
|
||||
}
|
||||
|
||||
def test_run_with_mixed_document_types(self) -> None:
|
||||
converter = DocumentToImageContent(root_path="test/test_files")
|
||||
documents = [
|
||||
Document(content="", meta={"file_path": "images/apple.jpg"}),
|
||||
Document(content="", meta={"file_path": "pdf/sample_pdf_1.pdf", "page_number": 1}),
|
||||
Document(content="text", meta={"file_path": "docx/sample_docx.docx"}),
|
||||
]
|
||||
with pytest.raises(ValueError, match="has an unsupported MIME type"):
|
||||
_ = converter.run(documents=documents)
|
||||
|
||||
@patch("haystack.components.converters.image.document_to_image._extract_image_sources_info")
|
||||
@patch("haystack.components.converters.image.document_to_image._batch_convert_pdf_pages_to_images")
|
||||
@patch("PIL.Image.open")
|
||||
@patch("haystack.components.converters.image.document_to_image.ByteStream")
|
||||
def test_run_none_images(
|
||||
self,
|
||||
mocked_byte_stream,
|
||||
mocked_pil_open,
|
||||
mocked_batch_convert_pdf_pages_to_images,
|
||||
mocked_extract_image_sources_info,
|
||||
caplog,
|
||||
):
|
||||
converter = DocumentToImageContent()
|
||||
|
||||
mocked_extract_image_sources_info.return_value = [
|
||||
{"path": "doc1.pdf", "mime_type": "application/pdf", "page_number": 999}, # Page 999 doesn't exist
|
||||
{"path": "image1.jpg", "mime_type": "image/jpeg"},
|
||||
]
|
||||
mocked_batch_convert_pdf_pages_to_images.return_value = {} # Empty dict because page was skipped
|
||||
mocked_pil_open.return_value = Image.new("RGB", (100, 100))
|
||||
mocked_byte_stream.from_file_path.return_value = ByteStream(b"")
|
||||
|
||||
documents = [
|
||||
Document(content="PDF 1", meta={"file_path": "doc1.pdf", "page_number": 999}),
|
||||
Document(content="Image 1", meta={"file_path": "image1.jpg"}),
|
||||
]
|
||||
|
||||
image_contents = converter.run(documents=documents)["image_contents"]
|
||||
|
||||
assert caplog.records[-1].levelname == "WARNING"
|
||||
assert "Conversion failed for some documents." in caplog.records[-1].message
|
||||
|
||||
assert image_contents[0] is None
|
||||
assert image_contents[1] is not None
|
||||
@@ -0,0 +1,82 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.converters.image.file_to_document import ImageFileToDocument
|
||||
from haystack.core.serialization import component_from_dict, component_to_dict
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class TestImageFileToDocument:
|
||||
def test_to_dict(self) -> None:
|
||||
converter = ImageFileToDocument()
|
||||
assert component_to_dict(converter, "converter") == {
|
||||
"init_parameters": {"store_full_path": False},
|
||||
"type": "haystack.components.converters.image.file_to_document.ImageFileToDocument",
|
||||
}
|
||||
|
||||
def test_to_dict_not_defaults(self) -> None:
|
||||
converter = ImageFileToDocument(store_full_path=True)
|
||||
assert component_to_dict(converter, "converter") == {
|
||||
"init_parameters": {"store_full_path": True},
|
||||
"type": "haystack.components.converters.image.file_to_document.ImageFileToDocument",
|
||||
}
|
||||
|
||||
def test_from_dict(self) -> None:
|
||||
data = {
|
||||
"init_parameters": {"store_full_path": False},
|
||||
"type": "haystack.components.converters.image.file_to_document.ImageFileToDocument",
|
||||
}
|
||||
converter = component_from_dict(ImageFileToDocument, data, "name")
|
||||
assert component_to_dict(converter, "converter") == data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("image_path", "mime_type"),
|
||||
[
|
||||
("./test/test_files/images/haystack-logo.png", "image/png"),
|
||||
("./test/test_files/images/apple.jpg", "image/jpeg"),
|
||||
],
|
||||
)
|
||||
def test_run_with_valid_sources(self, image_path: str, mime_type: str) -> None:
|
||||
converter = ImageFileToDocument(store_full_path=True)
|
||||
results = converter.run(sources=[image_path], meta={"source": "test_source"})
|
||||
|
||||
assert len(results["documents"]) == 1
|
||||
assert results["documents"][0].content is None
|
||||
assert results["documents"][0].meta == {"source": "test_source", "file_path": image_path}
|
||||
|
||||
def test_run_with_no_sources(self) -> None:
|
||||
converter = ImageFileToDocument()
|
||||
results = converter.run(sources=[])
|
||||
assert len(results["documents"]) == 0
|
||||
assert results == {"documents": []}
|
||||
|
||||
def test_run_with_invalid_source_type(self, caplog) -> None:
|
||||
converter = ImageFileToDocument()
|
||||
converter.run(sources=[123]) # Invalid source type
|
||||
assert "Could not read" in caplog.text
|
||||
|
||||
def test_run_with_non_existent_file(self, caplog) -> None:
|
||||
converter = ImageFileToDocument()
|
||||
converter.run(sources=["./non_existent_file.png"])
|
||||
assert "Could not read" in caplog.text
|
||||
assert "No such file or directory:" in caplog.text
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("image_path", "mime_type"),
|
||||
[
|
||||
("./test/test_files/images/haystack-logo.png", "image/png"),
|
||||
("./test/test_files/images/apple.jpg", "image/jpeg"),
|
||||
],
|
||||
)
|
||||
def test_run_with_bytestream_sources(self, image_path: str, mime_type: str) -> None:
|
||||
byte_stream = ByteStream.from_file_path(Path(image_path), mime_type=mime_type, meta={"file_path": image_path})
|
||||
converter = ImageFileToDocument(store_full_path=True)
|
||||
results = converter.run(sources=[byte_stream])
|
||||
assert len(results["documents"]) == 1
|
||||
assert results["documents"][0].content is None
|
||||
assert results["documents"][0].meta == {"file_path": image_path}
|
||||
@@ -0,0 +1,116 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.converters.image.file_to_image import ImageFileToImageContent
|
||||
from haystack.components.converters.image.image_utils import _encode_image_to_base64
|
||||
from haystack.core.serialization import component_from_dict, component_to_dict
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class TestImageFileToImageContent:
|
||||
def test_to_dict(self) -> None:
|
||||
converter = ImageFileToImageContent()
|
||||
assert component_to_dict(converter, "converter") == {
|
||||
"init_parameters": {"detail": None, "size": None},
|
||||
"type": "haystack.components.converters.image.file_to_image.ImageFileToImageContent",
|
||||
}
|
||||
|
||||
def test_to_dict_not_defaults(self) -> None:
|
||||
converter = ImageFileToImageContent(detail="low", size=(128, 128))
|
||||
assert component_to_dict(converter, "converter") == {
|
||||
"init_parameters": {"detail": "low", "size": (128, 128)},
|
||||
"type": "haystack.components.converters.image.file_to_image.ImageFileToImageContent",
|
||||
}
|
||||
|
||||
def test_from_dict(self) -> None:
|
||||
data = {
|
||||
"init_parameters": {"detail": "auto", "size": None},
|
||||
"type": "haystack.components.converters.image.file_to_image.ImageFileToImageContent",
|
||||
}
|
||||
converter = component_from_dict(ImageFileToImageContent, data, "name")
|
||||
assert component_to_dict(converter, "converter") == data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("image_path", "mime_type"),
|
||||
[
|
||||
("./test/test_files/images/haystack-logo.png", "image/png"),
|
||||
("./test/test_files/images/apple.jpg", "image/jpeg"),
|
||||
],
|
||||
)
|
||||
def test_run_with_valid_sources(self, image_path: str, mime_type: str) -> None:
|
||||
converter = ImageFileToImageContent()
|
||||
results = converter.run(sources=[image_path], size=(128, 128))
|
||||
|
||||
byte_stream = ByteStream.from_file_path(
|
||||
Path(image_path), mime_type=mime_type, meta={"file_name": image_path.rsplit("/", maxsplit=1)[-1]}
|
||||
)
|
||||
assert len(results["image_contents"]) == 1
|
||||
assert results["image_contents"][0].base64_image is not None
|
||||
assert (
|
||||
results["image_contents"][0].base64_image
|
||||
== _encode_image_to_base64(bytestream=byte_stream, size=(128, 128))[1]
|
||||
)
|
||||
assert results["image_contents"][0].mime_type == mime_type
|
||||
assert results["image_contents"][0].detail is None
|
||||
assert results["image_contents"][0].meta["file_path"] == str(Path(image_path))
|
||||
|
||||
def test_run_with_no_sources(self) -> None:
|
||||
converter = ImageFileToImageContent()
|
||||
results = converter.run(sources=[])
|
||||
assert len(results["image_contents"]) == 0
|
||||
assert results == {"image_contents": []}
|
||||
|
||||
def test_run_with_invalid_source_type(self, caplog) -> None:
|
||||
converter = ImageFileToImageContent()
|
||||
converter.run(sources=[123]) # Invalid source type
|
||||
assert "Could not read" in caplog.text
|
||||
|
||||
def test_run_with_non_existent_file(self, caplog) -> None:
|
||||
converter = ImageFileToImageContent()
|
||||
converter.run(sources=["./non_existent_file.png"])
|
||||
assert "Could not read" in caplog.text
|
||||
assert "No such file or directory:" in caplog.text
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("image_path", "mime_type"),
|
||||
[
|
||||
("./test/test_files/images/haystack-logo.png", "image/png"),
|
||||
("./test/test_files/images/apple.jpg", "image/jpeg"),
|
||||
],
|
||||
)
|
||||
def test_run_with_bytestream_sources(self, image_path: str, mime_type: str) -> None:
|
||||
byte_stream = ByteStream.from_file_path(Path(image_path), mime_type=mime_type, meta={"file_path": image_path})
|
||||
|
||||
# Initialize the converter
|
||||
converter = ImageFileToImageContent(size=(128, 128))
|
||||
|
||||
# Run the converter with the ByteStream
|
||||
results = converter.run(sources=[byte_stream])
|
||||
|
||||
# Assertions
|
||||
assert len(results["image_contents"]) == 1
|
||||
assert results["image_contents"][0].base64_image is not None
|
||||
assert (
|
||||
results["image_contents"][0].base64_image
|
||||
== _encode_image_to_base64(bytestream=byte_stream, size=(128, 128))[1]
|
||||
)
|
||||
assert results["image_contents"][0].mime_type == mime_type
|
||||
assert results["image_contents"][0].detail is None
|
||||
assert results["image_contents"][0].meta["file_path"] == image_path
|
||||
|
||||
def test_run_with_empty_bytestream(self) -> None:
|
||||
# Create an empty ByteStream object
|
||||
byte_stream = ByteStream(data=b"", meta={"file_path": "empty_file.png"})
|
||||
|
||||
# Initialize the converter
|
||||
converter = ImageFileToImageContent()
|
||||
|
||||
# Run the converter with the empty ByteStream
|
||||
results = converter.run(sources=[byte_stream])
|
||||
|
||||
assert results["image_contents"] == []
|
||||
@@ -0,0 +1,227 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import glob
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from pytest import LogCaptureFixture
|
||||
|
||||
from haystack.components.converters.image.image_utils import (
|
||||
_batch_convert_pdf_pages_to_images,
|
||||
_convert_pdf_to_images,
|
||||
_encode_image_to_base64,
|
||||
_encode_pil_image_to_base64,
|
||||
_extract_image_sources_info,
|
||||
_PDFPageInfo,
|
||||
)
|
||||
from haystack.components.converters.utils import get_bytestream_from_source
|
||||
from haystack.dataclasses import ByteStream, Document
|
||||
|
||||
|
||||
class TestToBase64Jpeg:
|
||||
def test_to_base64_jpeg(self) -> None:
|
||||
image_array = np.array(
|
||||
[
|
||||
[[34.215402, 132.78745697, 71.04739979], [24.23156181, 35.26147199, 124.95610316]],
|
||||
[[155.47443501, 196.98050276, 154.74734292], [253.24590033, 84.62392497, 157.34396641]],
|
||||
]
|
||||
)
|
||||
image = Image.fromarray(image_array.astype("uint8"))
|
||||
b64_str = _encode_pil_image_to_base64(image=image, mime_type="image/jpeg")
|
||||
assert (
|
||||
b64_str
|
||||
== "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAACAAIDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwCe50jTTdTE6daffb/livr9KKKK9aHwo9ql8EfRH//Z" # noqa: E501
|
||||
)
|
||||
|
||||
|
||||
class TestConvertPdfToImages:
|
||||
def test_convert_pdf_to_images(self) -> None:
|
||||
bytestream = get_bytestream_from_source(Path("test/test_files/pdf/sample_pdf_1.pdf"))
|
||||
output = _convert_pdf_to_images(bytestream=bytestream, page_range=[1])
|
||||
assert output is not None
|
||||
|
||||
def test_convert_pdf_to_images_with_size(self) -> None:
|
||||
bytestream = get_bytestream_from_source(Path("test/test_files/pdf/sample_pdf_1.pdf"))
|
||||
pages_images = _convert_pdf_to_images(bytestream=bytestream, page_range=[1], size=(100, 100))
|
||||
|
||||
assert len(pages_images) == 1
|
||||
assert pages_images[0][0] == 1
|
||||
assert pages_images[0][1].width <= 100
|
||||
assert pages_images[0][1].height <= 100
|
||||
|
||||
def test_convert_pdf_to_images_invalid_page(self, caplog: LogCaptureFixture) -> None:
|
||||
bytestream = get_bytestream_from_source(Path("test/test_files/pdf/sample_pdf_1.pdf"))
|
||||
out = _convert_pdf_to_images(bytestream=bytestream, page_range=[5])
|
||||
assert out == []
|
||||
assert "Page 5 is out of range for the PDF file. Skipping it." in caplog.text
|
||||
|
||||
def test_convert_pdf_to_images_error_reading_file(self, caplog: LogCaptureFixture) -> None:
|
||||
bytestream = ByteStream(data=b"", mime_type="application/pdf")
|
||||
out = _convert_pdf_to_images(bytestream=bytestream, page_range=[1])
|
||||
assert out == []
|
||||
assert "Could not read PDF file" in caplog.text
|
||||
|
||||
def test_convert_pdf_to_images_empty_file(self, caplog: LogCaptureFixture) -> None:
|
||||
bytestream = get_bytestream_from_source(Path("test/test_files/pdf/sample_pdf_1.pdf"))
|
||||
|
||||
with patch("haystack.components.converters.image.image_utils.PdfDocument") as mock_pdf_document:
|
||||
mock_pdf_document.__len__.return_value = 0
|
||||
out = _convert_pdf_to_images(bytestream=bytestream, page_range=[1])
|
||||
|
||||
assert out == []
|
||||
assert "PDF file is empty" in caplog.text
|
||||
|
||||
def test_convert_pdf_to_images_scale_if_large_pdf(self, caplog: LogCaptureFixture) -> None:
|
||||
bytestream = get_bytestream_from_source(Path("test/test_files/pdf/sample_pdf_1.pdf"))
|
||||
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
mock_pdf_document = MagicMock()
|
||||
mock_pdf_document.__len__.return_value = 1
|
||||
mock_page = MagicMock()
|
||||
mock_page.get_mediabox.return_value = (0, 0, 1e6, 1e6)
|
||||
mock_pdf_document.__getitem__.return_value = mock_page
|
||||
|
||||
with patch("haystack.components.converters.image.image_utils.PdfDocument", return_value=mock_pdf_document):
|
||||
_convert_pdf_to_images(bytestream=bytestream, page_range=[1])
|
||||
|
||||
assert "Large PDF detected" in caplog.text
|
||||
|
||||
|
||||
class TestEncodeImageToBase64:
|
||||
def test_encode_image_to_base64(self) -> None:
|
||||
bytestream = get_bytestream_from_source(Path("test/test_files/images/haystack-logo.png"))
|
||||
base64_str = _encode_image_to_base64(bytestream=bytestream)
|
||||
assert base64_str is not None
|
||||
|
||||
def test_encode_image_to_base64_downsize(self) -> None:
|
||||
bytestream = get_bytestream_from_source(Path("test/test_files/images/haystack-logo.png"))
|
||||
base64_str = _encode_image_to_base64(bytestream=bytestream, size=(128, 128))
|
||||
assert base64_str is not None
|
||||
|
||||
|
||||
class TestExtractImageSourcesInfo:
|
||||
def test_extract_image_source_info(self, test_files_path):
|
||||
image_paths = glob.glob(str(test_files_path / "images" / "*.*")) + glob.glob(
|
||||
str(test_files_path / "pdf" / "*.pdf")
|
||||
)
|
||||
|
||||
documents = []
|
||||
for i, path in enumerate(image_paths):
|
||||
document = Document(content=f"document number {i}", meta={"file_path": path})
|
||||
if path.endswith(".pdf"):
|
||||
document.meta["page_number"] = 1
|
||||
documents.append(document)
|
||||
|
||||
images_source_info = _extract_image_sources_info(
|
||||
documents=documents, file_path_meta_field="file_path", root_path=""
|
||||
)
|
||||
assert len(images_source_info) == len(documents)
|
||||
|
||||
for image_source_info in images_source_info:
|
||||
assert str(image_source_info["path"]) in image_paths
|
||||
assert image_source_info["mime_type"] in ["image/jpeg", "image/png", "application/pdf"]
|
||||
if image_source_info["mime_type"] == "application/pdf":
|
||||
assert image_source_info.get("page_number") == 1
|
||||
else:
|
||||
assert "page_number" not in image_source_info
|
||||
|
||||
def test_extract_image_source_info_errors(self, test_files_path):
|
||||
document = Document(content="test")
|
||||
with pytest.raises(ValueError, match="missing the 'file_path' key"):
|
||||
_extract_image_sources_info(documents=[document], file_path_meta_field="file_path", root_path="")
|
||||
|
||||
document = Document(content="test", meta={"file_path": "invalid_path"})
|
||||
with pytest.raises(ValueError, match="has an invalid file path"):
|
||||
_extract_image_sources_info(documents=[document], file_path_meta_field="file_path", root_path="")
|
||||
|
||||
document = Document(content="test", meta={"file_path": str(test_files_path / "docx" / "sample_docx.docx")})
|
||||
with pytest.raises(ValueError, match="has an unsupported MIME type"):
|
||||
_extract_image_sources_info(documents=[document], file_path_meta_field="file_path", root_path="")
|
||||
|
||||
document = Document(content="test", meta={"file_path": str(test_files_path / "pdf" / "sample_pdf_1.pdf")})
|
||||
with pytest.raises(ValueError, match="missing the 'page_number' key"):
|
||||
_extract_image_sources_info(documents=[document], file_path_meta_field="file_path", root_path="")
|
||||
|
||||
def test_extract_image_source_info_rejects_path_traversal(self, test_files_path):
|
||||
# Attacker-controlled document metadata attempts to escape the configured root.
|
||||
document = Document(content="test", meta={"file_path": "../../../../../../etc/passwd"})
|
||||
with pytest.raises(ValueError, match="escapes the configured root"):
|
||||
_extract_image_sources_info(
|
||||
documents=[document], file_path_meta_field="file_path", root_path=str(test_files_path / "images")
|
||||
)
|
||||
|
||||
def test_extract_image_source_info_rejects_absolute_outside_root(self, test_files_path):
|
||||
# Absolute path that lies outside the configured root must be rejected before any IO.
|
||||
document = Document(content="test", meta={"file_path": "/etc/passwd"})
|
||||
with pytest.raises(ValueError, match="escapes the configured root"):
|
||||
_extract_image_sources_info(
|
||||
documents=[document], file_path_meta_field="file_path", root_path=str(test_files_path / "images")
|
||||
)
|
||||
|
||||
def test_extract_image_source_info_accepts_path_inside_root(self, test_files_path):
|
||||
# When the resolved path is inside the configured root, processing must succeed.
|
||||
document = Document(content="test", meta={"file_path": "haystack-logo.png"})
|
||||
images_source_info = _extract_image_sources_info(
|
||||
documents=[document], file_path_meta_field="file_path", root_path=str(test_files_path / "images")
|
||||
)
|
||||
assert len(images_source_info) == 1
|
||||
assert images_source_info[0]["mime_type"] == "image/png"
|
||||
|
||||
|
||||
class TestBatchConvertPdfPagesToImages:
|
||||
@patch("haystack.components.converters.image.image_utils._convert_pdf_to_images")
|
||||
def test_batch_convert_pdf_pages_to_images(self, mocked_convert_pdf_to_images, test_files_path):
|
||||
mocked_convert_pdf_to_images.return_value = [
|
||||
(1, Image.new("RGB", (100, 100))),
|
||||
(2, Image.new("RGB", (100, 100))),
|
||||
]
|
||||
|
||||
pdf_path = test_files_path / "pdf" / "sample_pdf_1.pdf"
|
||||
pdf_doc_1: _PDFPageInfo = {"doc_idx": 0, "path": pdf_path, "page_number": 1}
|
||||
pdf_doc_2: _PDFPageInfo = {"doc_idx": 1, "path": pdf_path, "page_number": 2}
|
||||
pdf_documents = [pdf_doc_1, pdf_doc_2]
|
||||
|
||||
result = _batch_convert_pdf_pages_to_images(pdf_page_infos=pdf_documents, return_base64=False)
|
||||
|
||||
pdf_bytestream = ByteStream.from_file_path(pdf_path)
|
||||
|
||||
mocked_convert_pdf_to_images.assert_called_once_with(
|
||||
bytestream=pdf_bytestream, page_range=[1, 2], size=None, return_base64=False
|
||||
)
|
||||
|
||||
assert len(result) == len(pdf_documents)
|
||||
assert 0 in result and 1 in result
|
||||
assert isinstance(result[0], Image.Image)
|
||||
assert isinstance(result[1], Image.Image)
|
||||
|
||||
@patch("haystack.components.converters.image.image_utils._convert_pdf_to_images")
|
||||
def test_batch_convert_pdf_pages_to_images_base64(self, mocked_convert_pdf_to_images, test_files_path):
|
||||
mocked_convert_pdf_to_images.return_value = [(1, "base64_image_1"), (2, "base64_image_2")]
|
||||
|
||||
pdf_path = test_files_path / "pdf" / "sample_pdf_1.pdf"
|
||||
pdf_doc_1: _PDFPageInfo = {"doc_idx": 0, "path": pdf_path, "page_number": 1}
|
||||
pdf_doc_2: _PDFPageInfo = {"doc_idx": 1, "path": pdf_path, "page_number": 2}
|
||||
pdf_documents = [pdf_doc_1, pdf_doc_2]
|
||||
|
||||
result = _batch_convert_pdf_pages_to_images(pdf_page_infos=pdf_documents, return_base64=True)
|
||||
|
||||
pdf_bytestream = ByteStream.from_file_path(pdf_path)
|
||||
|
||||
mocked_convert_pdf_to_images.assert_called_once_with(
|
||||
bytestream=pdf_bytestream, page_range=[1, 2], size=None, return_base64=True
|
||||
)
|
||||
|
||||
assert result == {0: "base64_image_1", 1: "base64_image_2"}
|
||||
|
||||
def test_batch_convert_pdf_pages_to_images_no_pages_info(self):
|
||||
result = _batch_convert_pdf_pages_to_images(pdf_page_infos=[])
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert len(result) == 0
|
||||
@@ -0,0 +1,107 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from haystack.components.converters.image.image_utils import _convert_pdf_to_images
|
||||
from haystack.components.converters.image.pdf_to_image import PDFToImageContent
|
||||
from haystack.core.serialization import component_from_dict, component_to_dict
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class TestPDFToImageContent:
|
||||
def test_to_dict(self) -> None:
|
||||
converter = PDFToImageContent()
|
||||
assert component_to_dict(converter, "converter") == {
|
||||
"init_parameters": {"detail": None, "size": None, "page_range": None},
|
||||
"type": "haystack.components.converters.image.pdf_to_image.PDFToImageContent",
|
||||
}
|
||||
|
||||
def test_to_dict_not_defaults(self) -> None:
|
||||
converter = PDFToImageContent(detail="low", size=(128, 128), page_range=[1])
|
||||
assert component_to_dict(converter, "converter") == {
|
||||
"init_parameters": {"detail": "low", "size": (128, 128), "page_range": [1]},
|
||||
"type": "haystack.components.converters.image.pdf_to_image.PDFToImageContent",
|
||||
}
|
||||
|
||||
def test_from_dict(self) -> None:
|
||||
data = {
|
||||
"init_parameters": {"detail": "auto", "size": None, "page_range": [1]},
|
||||
"type": "haystack.components.converters.image.pdf_to_image.PDFToImageContent",
|
||||
}
|
||||
converter = component_from_dict(PDFToImageContent, data, "name")
|
||||
assert component_to_dict(converter, "converter") == data
|
||||
|
||||
def test_run_with_valid_source(self) -> None:
|
||||
file_path = "./test/test_files/pdf/sample_pdf_1.pdf"
|
||||
mime_type = "application/pdf"
|
||||
converter = PDFToImageContent()
|
||||
results = converter.run(sources=[file_path])
|
||||
|
||||
byte_stream = ByteStream.from_file_path(Path(file_path), mime_type=mime_type, meta={"file_path": file_path})
|
||||
assert len(results["image_contents"]) == 4
|
||||
assert results["image_contents"][0].base64_image is not None
|
||||
assert (
|
||||
results["image_contents"][0].base64_image
|
||||
== _convert_pdf_to_images(bytestream=byte_stream, size=None, page_range=[1], return_base64=True)[0][1]
|
||||
)
|
||||
assert results["image_contents"][0].mime_type == "image/jpeg"
|
||||
assert results["image_contents"][0].detail is None
|
||||
assert results["image_contents"][0].meta["file_path"] == str(Path(file_path))
|
||||
assert results["image_contents"][0].meta["page_number"] == 1
|
||||
assert results["image_contents"][1].meta["page_number"] == 2
|
||||
assert results["image_contents"][2].meta["page_number"] == 3
|
||||
assert results["image_contents"][3].meta["page_number"] == 4
|
||||
|
||||
def test_run_with_no_sources(self) -> None:
|
||||
converter = PDFToImageContent()
|
||||
results = converter.run(sources=[])
|
||||
assert len(results["image_contents"]) == 0
|
||||
assert results == {"image_contents": []}
|
||||
|
||||
def test_run_with_invalid_source_type(self, caplog) -> None:
|
||||
converter = PDFToImageContent()
|
||||
converter.run(sources=[123]) # Invalid source type
|
||||
assert "Could not read" in caplog.text
|
||||
|
||||
def test_run_with_non_existent_file(self, caplog) -> None:
|
||||
converter = PDFToImageContent()
|
||||
converter.run(sources=["./non_existent_file.png"])
|
||||
assert "Could not read" in caplog.text
|
||||
assert "No such file or directory:" in caplog.text
|
||||
|
||||
def test_run_with_bytestream_sources(self) -> None:
|
||||
file_path = "./test/test_files/pdf/sample_pdf_1.pdf"
|
||||
mime_type = "application/pdf"
|
||||
byte_stream = ByteStream.from_file_path(Path(file_path), mime_type=mime_type, meta={"file_path": file_path})
|
||||
|
||||
# Initialize the converter
|
||||
converter = PDFToImageContent()
|
||||
|
||||
# Run the converter with the ByteStream
|
||||
results = converter.run(sources=[byte_stream])
|
||||
|
||||
# Assertions
|
||||
assert len(results["image_contents"]) == 4
|
||||
assert results["image_contents"][0].base64_image is not None
|
||||
assert (
|
||||
results["image_contents"][0].base64_image
|
||||
== _convert_pdf_to_images(bytestream=byte_stream, size=None, page_range=[1], return_base64=True)[0][1]
|
||||
)
|
||||
assert results["image_contents"][0].mime_type == "image/jpeg"
|
||||
assert results["image_contents"][0].detail is None
|
||||
assert results["image_contents"][0].meta["file_path"] == file_path
|
||||
assert results["image_contents"][0].meta["page_number"] == 1
|
||||
|
||||
def test_run_with_empty_bytestream(self) -> None:
|
||||
# Create an empty ByteStream object
|
||||
byte_stream = ByteStream(data=b"", meta={"file_path": "empty_file.pdf"})
|
||||
|
||||
# Initialize the converter
|
||||
converter = PDFToImageContent()
|
||||
|
||||
# Run the converter with the empty ByteStream
|
||||
results = converter.run(sources=[byte_stream])
|
||||
|
||||
assert results["image_contents"] == []
|
||||
@@ -0,0 +1,244 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.converters.csv import CSVToDocument
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def csv_converter():
|
||||
return CSVToDocument()
|
||||
|
||||
|
||||
class TestCSVToDocument:
|
||||
def test_init(self, csv_converter):
|
||||
assert isinstance(csv_converter, CSVToDocument)
|
||||
|
||||
def test_run(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly.
|
||||
"""
|
||||
bytestream = ByteStream.from_file_path(test_files_path / "csv" / "sample_1.csv")
|
||||
bytestream.meta["file_path"] = str(test_files_path / "csv" / "sample_1.csv")
|
||||
bytestream.meta["key"] = "value"
|
||||
files = [bytestream, test_files_path / "csv" / "sample_2.csv", test_files_path / "csv" / "sample_3.csv"]
|
||||
converter = CSVToDocument()
|
||||
output = converter.run(sources=files)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 3
|
||||
assert docs[0].content == "Name,Age\r\nJohn Doe,27\r\nJane Smith,37\r\nMike Johnson,47\r\n"
|
||||
assert isinstance(docs[0].content, str)
|
||||
assert docs[0].meta == {"file_path": os.path.basename(bytestream.meta["file_path"]), "key": "value"}
|
||||
assert docs[1].meta["file_path"] == os.path.basename(files[1])
|
||||
assert docs[2].meta["file_path"] == os.path.basename(files[2])
|
||||
|
||||
def test_run_with_store_full_path_false(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly with store_full_path=False
|
||||
"""
|
||||
bytestream = ByteStream.from_file_path(test_files_path / "csv" / "sample_1.csv")
|
||||
bytestream.meta["file_path"] = str(test_files_path / "csv" / "sample_1.csv")
|
||||
bytestream.meta["key"] = "value"
|
||||
files = [bytestream, test_files_path / "csv" / "sample_2.csv", test_files_path / "csv" / "sample_3.csv"]
|
||||
converter = CSVToDocument(store_full_path=False)
|
||||
output = converter.run(sources=files)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 3
|
||||
assert docs[0].content == "Name,Age\r\nJohn Doe,27\r\nJane Smith,37\r\nMike Johnson,47\r\n"
|
||||
assert isinstance(docs[0].content, str)
|
||||
assert docs[0].meta["file_path"] == "sample_1.csv"
|
||||
assert docs[0].meta["key"] == "value"
|
||||
assert docs[1].meta["file_path"] == "sample_2.csv"
|
||||
assert docs[2].meta["file_path"] == "sample_3.csv"
|
||||
|
||||
def test_run_error_handling(self, test_files_path, caplog):
|
||||
"""
|
||||
Test if the component correctly handles errors.
|
||||
"""
|
||||
paths = [
|
||||
test_files_path / "csv" / "sample_2.csv",
|
||||
"non_existing_file.csv",
|
||||
test_files_path / "csv" / "sample_3.csv",
|
||||
]
|
||||
converter = CSVToDocument()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
output = converter.run(sources=paths)
|
||||
assert "non_existing_file.csv" in caplog.text
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 2
|
||||
assert docs[0].meta["file_path"] == os.path.basename(paths[0])
|
||||
|
||||
def test_encoding_override(self, test_files_path, caplog):
|
||||
"""
|
||||
Test if the encoding metadata field is used properly
|
||||
"""
|
||||
bytestream = ByteStream.from_file_path(test_files_path / "csv" / "sample_1.csv")
|
||||
bytestream.meta["key"] = "value"
|
||||
|
||||
converter = CSVToDocument(encoding="utf-16-le")
|
||||
_ = converter.run(sources=[bytestream])
|
||||
with caplog.at_level(logging.ERROR):
|
||||
_ = converter.run(sources=[bytestream])
|
||||
assert "codec can't decode" in caplog.text
|
||||
|
||||
converter = CSVToDocument(encoding="utf-8")
|
||||
output = converter.run(sources=[bytestream])
|
||||
assert "Name,Age\r\n" in output["documents"][0].content
|
||||
|
||||
def test_run_with_meta(self):
|
||||
bytestream = ByteStream(
|
||||
data=b"Name,Age,City\r\nAlice,30,New York\r\nBob,25,Los Angeles\r\nCharlie,35,Chicago\r\n",
|
||||
meta={"name": "test_name", "language": "en"},
|
||||
)
|
||||
converter = CSVToDocument()
|
||||
output = converter.run(sources=[bytestream], meta=[{"language": "it"}])
|
||||
document = output["documents"][0]
|
||||
assert document.meta == {"name": "test_name", "language": "it"}
|
||||
|
||||
# --- NEW TESTS for strict row mode ---
|
||||
|
||||
def test_row_mode_requires_content_column_param(self, tmp_path):
|
||||
# Missing content_column must raise in row mode
|
||||
f = tmp_path / "t.csv"
|
||||
f.write_text("a,b\r\n1,2\r\n", encoding="utf-8")
|
||||
conv = CSVToDocument(conversion_mode="row")
|
||||
with pytest.raises(ValueError):
|
||||
_ = conv.run(sources=[f]) # content_column missing
|
||||
|
||||
def test_row_mode_missing_header_raises(self, tmp_path):
|
||||
# content_column must exist in header
|
||||
f = tmp_path / "t.csv"
|
||||
f.write_text("a,b\r\n1,2\r\n", encoding="utf-8")
|
||||
conv = CSVToDocument(conversion_mode="row")
|
||||
with pytest.raises(ValueError):
|
||||
_ = conv.run(sources=[f], content_column="missing")
|
||||
|
||||
def test_row_mode_with_content_column(self, tmp_path):
|
||||
csv_text = "text,author,stars\r\nNice app,Ada,5\r\nBuggy,Bob,2\r\n"
|
||||
f = tmp_path / "fb.csv"
|
||||
f.write_text(csv_text, encoding="utf-8")
|
||||
|
||||
bytestream = ByteStream.from_file_path(f)
|
||||
bytestream.meta["file_path"] = str(f)
|
||||
|
||||
converter = CSVToDocument(conversion_mode="row")
|
||||
output = converter.run(sources=[bytestream], content_column="text")
|
||||
docs = output["documents"]
|
||||
|
||||
assert len(docs) == 2
|
||||
assert [d.content for d in docs] == ["Nice app", "Buggy"]
|
||||
assert docs[0].meta["author"] == "Ada"
|
||||
assert docs[0].meta["stars"] == "5"
|
||||
assert docs[0].meta["row_number"] == 0
|
||||
assert os.path.basename(f) == docs[0].meta["file_path"]
|
||||
|
||||
def test_row_mode_meta_collision_prefixed(self, tmp_path):
|
||||
# ByteStream meta has file_path and encoding; CSV also has those columns.
|
||||
csv_text = "file_path,encoding,comment\r\nrowpath.csv,latin1,ok\r\n"
|
||||
f = tmp_path / "collide.csv"
|
||||
f.write_text(csv_text, encoding="utf-8")
|
||||
bs = ByteStream.from_file_path(f)
|
||||
bs.meta["file_path"] = str(f)
|
||||
bs.meta["encoding"] = "utf-8"
|
||||
|
||||
conv = CSVToDocument(conversion_mode="row")
|
||||
out = conv.run(sources=[bs], content_column="comment")
|
||||
d = out["documents"][0]
|
||||
# Original meta preserved
|
||||
assert d.meta["file_path"] == os.path.basename(str(f))
|
||||
assert d.meta["encoding"] == "utf-8"
|
||||
# CSV columns stored with csv_ prefix (no clobber)
|
||||
assert d.meta["csv_file_path"] == "rowpath.csv"
|
||||
assert d.meta["csv_encoding"] == "latin1"
|
||||
# content column isn't duplicated in meta
|
||||
assert "comment" not in d.meta
|
||||
assert d.meta["row_number"] == 0
|
||||
assert d.content == "ok"
|
||||
|
||||
def test_row_mode_meta_collision_multiple_suffixes(self, tmp_path):
|
||||
"""
|
||||
If meta already has csv_file_path and csv_file_path_1, we should write the next as csv_file_path_2.
|
||||
"""
|
||||
csv_text = "file_path,comment\r\nrow.csv,ok\r\n"
|
||||
f = tmp_path / "multi.csv"
|
||||
f.write_text(csv_text, encoding="utf-8")
|
||||
|
||||
bs = ByteStream.from_file_path(f)
|
||||
bs.meta["file_path"] = str(f)
|
||||
|
||||
# Pre-seed meta so we force two collisions.
|
||||
extra_meta = {"csv_file_path": "existing0", "csv_file_path_1": "existing1"}
|
||||
|
||||
conv = CSVToDocument(conversion_mode="row")
|
||||
out = conv.run(sources=[bs], meta=[extra_meta], content_column="comment")
|
||||
d = out["documents"][0]
|
||||
|
||||
assert d.meta["csv_file_path"] == "existing0"
|
||||
assert d.meta["csv_file_path_1"] == "existing1"
|
||||
assert d.meta["csv_file_path_2"] == "row.csv"
|
||||
assert d.content == "ok"
|
||||
|
||||
def test_init_validates_delimiter_and_quotechar(self):
|
||||
with pytest.raises(ValueError):
|
||||
CSVToDocument(delimiter=";;")
|
||||
with pytest.raises(ValueError):
|
||||
CSVToDocument(quotechar='""')
|
||||
|
||||
def test_row_mode_large_file_warns(self, caplog, monkeypatch):
|
||||
# Make the threshold tiny so the warning always triggers.
|
||||
import haystack.components.converters.csv as csv_mod
|
||||
|
||||
monkeypatch.setattr(csv_mod, "_ROW_MODE_SIZE_WARN_BYTES", 1, raising=False)
|
||||
|
||||
bs = ByteStream(data=b"text,author\nhi,Ada\n", meta={"file_path": "big.csv"})
|
||||
conv = CSVToDocument(conversion_mode="row")
|
||||
with caplog.at_level(logging.WARNING, logger="haystack.components.converters.csv"):
|
||||
_ = conv.run(sources=[bs], content_column="text")
|
||||
assert "parsing a large CSV" in caplog.text
|
||||
|
||||
def test_row_mode_reader_failure_raises_runtimeerror(self, monkeypatch, tmp_path):
|
||||
# Simulate DictReader failing -> we should raise RuntimeError (no fallback).
|
||||
import haystack.components.converters.csv as csv_mod
|
||||
|
||||
f = tmp_path / "bad.csv"
|
||||
f.write_text("a,b\n1,2\n", encoding="utf-8")
|
||||
conv = CSVToDocument(conversion_mode="row")
|
||||
|
||||
class Boom(Exception):
|
||||
pass
|
||||
|
||||
def broken_reader(*_args, **_kwargs): # noqa: D401
|
||||
raise Boom("broken")
|
||||
|
||||
monkeypatch.setattr(csv_mod.csv, "DictReader", broken_reader, raising=True)
|
||||
with pytest.raises(RuntimeError):
|
||||
_ = conv.run(sources=[f], content_column="a")
|
||||
|
||||
def test_row_mode_ragged_row_does_not_crash(self):
|
||||
# A data row with more fields than the header (e.g. an unquoted comma inside a value).
|
||||
# Previously the surplus value landed under the None key, which broke Document id
|
||||
# generation (TypeError sorting None against str keys) and aborted the whole batch.
|
||||
valid = ByteStream(data=b"text,author\r\nfine,Ada\r\n", meta={"file_path": "valid.csv"})
|
||||
ragged = ByteStream(data=b"text,note\r\nhello,city,state\r\n", meta={"file_path": "ragged.csv"})
|
||||
|
||||
conv = CSVToDocument(conversion_mode="row")
|
||||
out = conv.run(sources=[valid, ragged], content_column="text")
|
||||
docs = out["documents"]
|
||||
|
||||
# Both sources yielded a Document; the earlier valid source is not lost.
|
||||
assert len(docs) == 2
|
||||
assert docs[0].content == "fine"
|
||||
assert docs[0].meta["author"] == "Ada"
|
||||
|
||||
ragged_doc = docs[1]
|
||||
assert ragged_doc.content == "hello"
|
||||
assert ragged_doc.meta["note"] == "city"
|
||||
# Surplus value is preserved under an explicit (non-None) string meta key.
|
||||
assert None not in ragged_doc.meta
|
||||
assert "state" in ragged_doc.meta["extra_columns"]
|
||||
@@ -0,0 +1,455 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack import Document, Pipeline
|
||||
from haystack.components.converters.docx import DOCXLinkFormat, DOCXMetadata, DOCXTableFormat, DOCXToDocument
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def docx_converter():
|
||||
return DOCXToDocument()
|
||||
|
||||
|
||||
class TestDOCXToDocument:
|
||||
def test_init(self, docx_converter):
|
||||
assert isinstance(docx_converter, DOCXToDocument)
|
||||
|
||||
def test_init_with_string(self):
|
||||
converter = DOCXToDocument(table_format="markdown")
|
||||
assert isinstance(converter, DOCXToDocument)
|
||||
assert converter.table_format == DOCXTableFormat.MARKDOWN
|
||||
|
||||
def test_init_with_invalid_string(self):
|
||||
with pytest.raises(ValueError, match="Unknown table format 'invalid_format'"):
|
||||
DOCXToDocument(table_format="invalid_format")
|
||||
|
||||
def test_to_dict(self):
|
||||
converter = DOCXToDocument()
|
||||
data = converter.to_dict()
|
||||
assert data == {
|
||||
"type": "haystack.components.converters.docx.DOCXToDocument",
|
||||
"init_parameters": {"store_full_path": False, "table_format": "csv", "link_format": "none"},
|
||||
}
|
||||
|
||||
def test_to_dict_custom_parameters(self):
|
||||
converter = DOCXToDocument(table_format="markdown", link_format="markdown")
|
||||
data = converter.to_dict()
|
||||
assert data == {
|
||||
"type": "haystack.components.converters.docx.DOCXToDocument",
|
||||
"init_parameters": {"store_full_path": False, "table_format": "markdown", "link_format": "markdown"},
|
||||
}
|
||||
|
||||
converter = DOCXToDocument(table_format="csv", link_format="plain")
|
||||
data = converter.to_dict()
|
||||
assert data == {
|
||||
"type": "haystack.components.converters.docx.DOCXToDocument",
|
||||
"init_parameters": {"store_full_path": False, "table_format": "csv", "link_format": "plain"},
|
||||
}
|
||||
|
||||
converter = DOCXToDocument(table_format=DOCXTableFormat.MARKDOWN, link_format=DOCXLinkFormat.MARKDOWN)
|
||||
data = converter.to_dict()
|
||||
assert data == {
|
||||
"type": "haystack.components.converters.docx.DOCXToDocument",
|
||||
"init_parameters": {"store_full_path": False, "table_format": "markdown", "link_format": "markdown"},
|
||||
}
|
||||
|
||||
converter = DOCXToDocument(table_format=DOCXTableFormat.CSV, link_format=DOCXLinkFormat.PLAIN)
|
||||
data = converter.to_dict()
|
||||
assert data == {
|
||||
"type": "haystack.components.converters.docx.DOCXToDocument",
|
||||
"init_parameters": {"store_full_path": False, "table_format": "csv", "link_format": "plain"},
|
||||
}
|
||||
|
||||
def test_from_dict(self):
|
||||
data = {
|
||||
"type": "haystack.components.converters.docx.DOCXToDocument",
|
||||
"init_parameters": {"table_format": "csv"},
|
||||
}
|
||||
converter = DOCXToDocument.from_dict(data)
|
||||
assert converter.table_format == DOCXTableFormat.CSV
|
||||
|
||||
def test_from_dict_custom_parameters(self):
|
||||
data = {
|
||||
"type": "haystack.components.converters.docx.DOCXToDocument",
|
||||
"init_parameters": {"table_format": "markdown", "link_format": "markdown"},
|
||||
}
|
||||
converter = DOCXToDocument.from_dict(data)
|
||||
assert converter.table_format == DOCXTableFormat.MARKDOWN
|
||||
assert converter.link_format == DOCXLinkFormat.MARKDOWN
|
||||
|
||||
def test_from_dict_invalid_table_format(self):
|
||||
data = {
|
||||
"type": "haystack.components.converters.docx.DOCXToDocument",
|
||||
"init_parameters": {"table_format": "invalid_format"},
|
||||
}
|
||||
with pytest.raises(ValueError, match="Unknown table format 'invalid_format'"):
|
||||
DOCXToDocument.from_dict(data)
|
||||
|
||||
def test_from_dict_empty_init_parameters(self):
|
||||
data = {"type": "haystack.components.converters.docx.DOCXToDocument", "init_parameters": {}}
|
||||
converter = DOCXToDocument.from_dict(data)
|
||||
assert converter.table_format == DOCXTableFormat.CSV
|
||||
|
||||
def test_pipeline_serde(self):
|
||||
pipeline = Pipeline()
|
||||
converter = DOCXToDocument(table_format=DOCXTableFormat.MARKDOWN)
|
||||
pipeline.add_component("converter", converter)
|
||||
|
||||
pipeline_str = pipeline.dumps()
|
||||
assert "haystack.components.converters.docx.DOCXToDocument" in pipeline_str
|
||||
assert "table_format" in pipeline_str
|
||||
assert "markdown" in pipeline_str
|
||||
|
||||
new_pipeline = Pipeline.loads(pipeline_str)
|
||||
new_converter = new_pipeline.get_component("converter")
|
||||
assert isinstance(new_converter, DOCXToDocument)
|
||||
assert new_converter.table_format == DOCXTableFormat.MARKDOWN
|
||||
|
||||
def test_run(self, test_files_path, docx_converter):
|
||||
"""
|
||||
Test if the component runs correctly
|
||||
"""
|
||||
paths = [test_files_path / "docx" / "sample_docx_1.docx"]
|
||||
output = docx_converter.run(sources=paths)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 1
|
||||
assert "History" in docs[0].content
|
||||
assert docs[0].meta.keys() == {"file_path", "docx"}
|
||||
assert docs[0].meta == {
|
||||
"file_path": os.path.basename(paths[0]),
|
||||
"docx": {
|
||||
"author": "Microsoft Office User",
|
||||
"category": "",
|
||||
"comments": "",
|
||||
"content_status": "",
|
||||
"created": "2024-06-09T21:17:00+00:00",
|
||||
"identifier": "",
|
||||
"keywords": "",
|
||||
"language": "",
|
||||
"last_modified_by": "Carlos Fernández Lorán",
|
||||
"last_printed": None,
|
||||
"modified": "2024-06-09T21:27:00+00:00",
|
||||
"revision": 2,
|
||||
"subject": "",
|
||||
"title": "",
|
||||
"version": "",
|
||||
},
|
||||
}
|
||||
|
||||
def test_run_with_table(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly
|
||||
"""
|
||||
docx_converter = DOCXToDocument(table_format=DOCXTableFormat.MARKDOWN)
|
||||
paths = [test_files_path / "docx" / "sample_docx.docx"]
|
||||
output = docx_converter.run(sources=paths)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 1
|
||||
assert "Donald Trump" in docs[0].content ## :-)
|
||||
assert docs[0].meta.keys() == {"file_path", "docx"}
|
||||
assert docs[0].meta == {
|
||||
"file_path": os.path.basename(paths[0]),
|
||||
"docx": {
|
||||
"author": "Saha, Anirban",
|
||||
"category": "",
|
||||
"comments": "",
|
||||
"content_status": "",
|
||||
"created": "2020-07-14T08:14:00+00:00",
|
||||
"identifier": "",
|
||||
"keywords": "",
|
||||
"language": "",
|
||||
"last_modified_by": "Saha, Anirban",
|
||||
"last_printed": None,
|
||||
"modified": "2020-07-14T08:16:00+00:00",
|
||||
"revision": 1,
|
||||
"subject": "",
|
||||
"title": "",
|
||||
"version": "",
|
||||
},
|
||||
}
|
||||
# let's now detect that the table markdown is correctly added and that order of elements is correct
|
||||
content_parts = docs[0].content.split("\n\n")
|
||||
table_index = next(i for i, part in enumerate(content_parts) if "| This | Is | Just a |" in part)
|
||||
# check that natural order of the document is preserved
|
||||
assert any("Donald Trump" in part for part in content_parts[:table_index]), "Text before table not found"
|
||||
assert any("Now we are in Page 2" in part for part in content_parts[table_index + 1 :]), (
|
||||
"Text after table not found"
|
||||
)
|
||||
|
||||
def test_run_with_store_full_path_false(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly with store_full_path=False
|
||||
"""
|
||||
docx_converter = DOCXToDocument(store_full_path=False)
|
||||
paths = [test_files_path / "docx" / "sample_docx_1.docx"]
|
||||
output = docx_converter.run(sources=paths)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 1
|
||||
assert "History" in docs[0].content
|
||||
assert docs[0].meta.keys() == {"file_path", "docx"}
|
||||
assert docs[0].meta == {
|
||||
"file_path": "sample_docx_1.docx",
|
||||
"docx": {
|
||||
"author": "Microsoft Office User",
|
||||
"category": "",
|
||||
"comments": "",
|
||||
"content_status": "",
|
||||
"created": "2024-06-09T21:17:00+00:00",
|
||||
"identifier": "",
|
||||
"keywords": "",
|
||||
"language": "",
|
||||
"last_modified_by": "Carlos Fernández Lorán",
|
||||
"last_printed": None,
|
||||
"modified": "2024-06-09T21:27:00+00:00",
|
||||
"revision": 2,
|
||||
"subject": "",
|
||||
"title": "",
|
||||
"version": "",
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("table_format", ["markdown", "csv"])
|
||||
def test_table_between_two_paragraphs(self, test_files_path, table_format):
|
||||
docx_converter = DOCXToDocument(table_format=table_format)
|
||||
paths = [test_files_path / "docx" / "sample_docx_3.docx"]
|
||||
output = docx_converter.run(sources=paths)
|
||||
|
||||
content = output["documents"][0].content
|
||||
|
||||
paragraphs_one = content.find("Table: AI Use Cases in Different Industries")
|
||||
paragraphs_two = content.find("Paragraph 2:")
|
||||
table = content[
|
||||
paragraphs_one + len("Table: AI Use Cases in Different Industries") + 1 : paragraphs_two
|
||||
].strip()
|
||||
|
||||
if table_format == "markdown":
|
||||
split = list(filter(None, table.split("\n")))
|
||||
expected_table_header = "| Industry | AI Use Case | Impact |"
|
||||
expected_last_row = "| Finance | Fraud detection and prevention | Reduced financial losses |"
|
||||
|
||||
assert split[0] == expected_table_header
|
||||
assert split[-1] == expected_last_row
|
||||
if table_format == "csv": # CSV format
|
||||
csv_reader = csv.reader(StringIO(table))
|
||||
rows = list(csv_reader)
|
||||
assert len(rows) == 3 # Header + 2 data rows
|
||||
assert rows[0] == ["Industry", "AI Use Case", "Impact"]
|
||||
assert rows[-1] == ["Finance", "Fraud detection and prevention", "Reduced financial losses"]
|
||||
|
||||
@pytest.mark.parametrize("table_format", ["markdown", "csv"])
|
||||
def test_table_content_correct_parsing(self, test_files_path, table_format):
|
||||
docx_converter = DOCXToDocument(table_format=table_format)
|
||||
paths = [test_files_path / "docx" / "sample_docx_3.docx"]
|
||||
output = docx_converter.run(sources=paths)
|
||||
content = output["documents"][0].content
|
||||
|
||||
paragraphs_one = content.find("Table: AI Use Cases in Different Industries")
|
||||
paragraphs_two = content.find("Paragraph 2:")
|
||||
table = content[
|
||||
paragraphs_one + len("Table: AI Use Cases in Different Industries") + 1 : paragraphs_two
|
||||
].strip()
|
||||
|
||||
if table_format == "markdown":
|
||||
split = list(filter(None, table.split("\n")))
|
||||
assert len(split) == 4
|
||||
|
||||
expected_table_header = "| Industry | AI Use Case | Impact |"
|
||||
expected_table_top_border = "| ---------- | ------------------------------ | ------------------------- |"
|
||||
expected_table_row_one = "| Healthcare | Predictive diagnostics | Improved patient outcomes |"
|
||||
expected_table_row_two = "| Finance | Fraud detection and prevention | Reduced financial losses |"
|
||||
|
||||
assert split[0] == expected_table_header
|
||||
assert split[1] == expected_table_top_border
|
||||
assert split[2] == expected_table_row_one
|
||||
assert split[3] == expected_table_row_two
|
||||
if table_format == "csv": # CSV format
|
||||
csv_reader = csv.reader(StringIO(table))
|
||||
rows = list(csv_reader)
|
||||
assert len(rows) == 3 # Header + 2 data rows
|
||||
|
||||
expected_header = ["Industry", "AI Use Case", "Impact"]
|
||||
expected_row_one = ["Healthcare", "Predictive diagnostics", "Improved patient outcomes"]
|
||||
expected_row_two = ["Finance", "Fraud detection and prevention", "Reduced financial losses"]
|
||||
|
||||
assert rows[0] == expected_header
|
||||
assert rows[1] == expected_row_one
|
||||
assert rows[2] == expected_row_two
|
||||
|
||||
def test_run_with_additional_meta(self, test_files_path, docx_converter):
|
||||
paths = [test_files_path / "docx" / "sample_docx_1.docx"]
|
||||
output = docx_converter.run(sources=paths, meta={"language": "it", "author": "test_author"})
|
||||
doc = output["documents"][0]
|
||||
assert doc.meta == {
|
||||
"file_path": os.path.basename(paths[0]),
|
||||
"docx": {
|
||||
"author": "Microsoft Office User",
|
||||
"category": "",
|
||||
"comments": "",
|
||||
"content_status": "",
|
||||
"created": "2024-06-09T21:17:00+00:00",
|
||||
"identifier": "",
|
||||
"keywords": "",
|
||||
"language": "",
|
||||
"last_modified_by": "Carlos Fernández Lorán",
|
||||
"last_printed": None,
|
||||
"modified": "2024-06-09T21:27:00+00:00",
|
||||
"revision": 2,
|
||||
"subject": "",
|
||||
"title": "",
|
||||
"version": "",
|
||||
},
|
||||
"language": "it",
|
||||
"author": "test_author",
|
||||
}
|
||||
|
||||
def test_run_error_wrong_file_type(self, caplog, test_files_path, docx_converter):
|
||||
sources = [str(test_files_path / "txt" / "doc_1.txt")]
|
||||
with caplog.at_level(logging.WARNING):
|
||||
results = docx_converter.run(sources=sources)
|
||||
assert "doc_1.txt and convert it" in caplog.text
|
||||
assert results["documents"] == []
|
||||
|
||||
def test_run_error_non_existent_file(self, docx_converter, caplog):
|
||||
"""
|
||||
Test if the component correctly handles errors.
|
||||
"""
|
||||
paths = ["non_existing_file.docx"]
|
||||
with caplog.at_level(logging.WARNING):
|
||||
docx_converter.run(sources=paths)
|
||||
assert "Could not read non_existing_file.docx" in caplog.text
|
||||
|
||||
def test_run_page_breaks(self, test_files_path, docx_converter):
|
||||
"""
|
||||
Test if the component correctly parses page breaks.
|
||||
"""
|
||||
paths = [test_files_path / "docx" / "sample_docx_2_page_breaks.docx"]
|
||||
output = docx_converter.run(sources=paths)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 1
|
||||
assert docs[0].content.count("\f") == 4
|
||||
|
||||
def test_mixed_sources_run(self, test_files_path, docx_converter):
|
||||
"""
|
||||
Test if the component runs correctly when mixed sources are provided.
|
||||
"""
|
||||
paths = [test_files_path / "docx" / "sample_docx_1.docx"]
|
||||
with open(test_files_path / "docx" / "sample_docx_1.docx", "rb") as f:
|
||||
paths.append(ByteStream(f.read()))
|
||||
|
||||
output = docx_converter.run(sources=paths)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 2
|
||||
assert "History and standardization" in docs[0].content
|
||||
assert "History and standardization" in docs[1].content
|
||||
|
||||
def test_document_with_docx_metadata_to_dict(self):
|
||||
docx_metadata = DOCXMetadata(
|
||||
author="Microsoft Office User",
|
||||
category="category",
|
||||
comments="comments",
|
||||
content_status="",
|
||||
created="2024-06-09T21:17:00+00:00",
|
||||
identifier="",
|
||||
keywords="",
|
||||
language="",
|
||||
last_modified_by="Carlos Fernández Lorán",
|
||||
last_printed=None,
|
||||
modified="2024-06-09T21:27:00+00:00",
|
||||
revision=2,
|
||||
subject="",
|
||||
title="",
|
||||
version="",
|
||||
)
|
||||
doc = Document(content="content", meta={"test": 1, "docx": docx_metadata}, id="1")
|
||||
assert doc.to_dict(flatten=False) == {
|
||||
"blob": None,
|
||||
"content": "content",
|
||||
"id": "1",
|
||||
"score": None,
|
||||
"embedding": None,
|
||||
"sparse_embedding": None,
|
||||
"meta": {
|
||||
"test": 1,
|
||||
"docx": {
|
||||
"author": "Microsoft Office User",
|
||||
"category": "category",
|
||||
"comments": "comments",
|
||||
"content_status": "",
|
||||
"created": "2024-06-09T21:17:00+00:00",
|
||||
"identifier": "",
|
||||
"keywords": "",
|
||||
"language": "",
|
||||
"last_modified_by": "Carlos Fernández Lorán",
|
||||
"last_printed": None,
|
||||
"modified": "2024-06-09T21:27:00+00:00",
|
||||
"revision": 2,
|
||||
"subject": "",
|
||||
"title": "",
|
||||
"version": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# check it is JSON serializable
|
||||
json_str = json.dumps(doc.to_dict(flatten=False))
|
||||
assert json.loads(json_str) == doc.to_dict(flatten=False)
|
||||
|
||||
def test_link_format_initialization(self):
|
||||
converter = DOCXToDocument(link_format="markdown")
|
||||
assert converter.link_format == DOCXLinkFormat.MARKDOWN
|
||||
|
||||
converter = DOCXToDocument(link_format=DOCXLinkFormat.PLAIN)
|
||||
assert converter.link_format == DOCXLinkFormat.PLAIN
|
||||
|
||||
def test_link_format_invalid(self):
|
||||
with pytest.raises(ValueError, match="Unknown link format 'invalid_format'"):
|
||||
DOCXToDocument(link_format="invalid_format")
|
||||
|
||||
@pytest.mark.parametrize("link_format", ["markdown", "plain"])
|
||||
def test_link_extraction(self, test_files_path, link_format):
|
||||
docx_converter = DOCXToDocument(link_format=link_format)
|
||||
paths = [test_files_path / "docx" / "sample_docx_with_single_link.docx"]
|
||||
output = docx_converter.run(sources=paths)
|
||||
content = output["documents"][0].content
|
||||
|
||||
if link_format == "markdown":
|
||||
assert "[PDF](https://en.wikipedia.org/wiki/PDF)" in content
|
||||
else: # plain format
|
||||
assert "PDF (https://en.wikipedia.org/wiki/PDF)" in content
|
||||
|
||||
@pytest.mark.parametrize("link_format", ["markdown", "plain"])
|
||||
def test_link_extraction_page_break(self, test_files_path, link_format):
|
||||
docx_converter = DOCXToDocument(link_format=link_format)
|
||||
paths = [test_files_path / "docx" / "sample_docx_with_links.docx"]
|
||||
output = docx_converter.run(sources=paths)
|
||||
content = output["documents"][0].content
|
||||
|
||||
if link_format == "markdown":
|
||||
assert "[PDF](https://en.wikipedia.org/wiki/PDF)" in content
|
||||
assert "[of](https://en.wikipedia.org/wiki/OF)" in content
|
||||
assert "[charge](https://en.wikipedia.org/wiki/Charge)" in content
|
||||
assert "[disambiguation link](https://en.wikipedia.org/wiki/PDF_(disambiguation))" in content
|
||||
else: # plain format
|
||||
assert "PDF (https://en.wikipedia.org/wiki/PDF)" in content
|
||||
assert "of (https://en.wikipedia.org/wiki/OF)" in content
|
||||
assert "charge (https://en.wikipedia.org/wiki/Charge)" in content
|
||||
assert "disambiguation link (https://en.wikipedia.org/wiki/PDF_(disambiguation))" in content
|
||||
|
||||
def test_no_link_extraction(self, test_files_path):
|
||||
docx_converter = DOCXToDocument()
|
||||
paths = [test_files_path / "docx" / "sample_docx_with_single_link.docx"]
|
||||
output = docx_converter.run(sources=paths)
|
||||
content = output["documents"][0].content
|
||||
|
||||
assert "[PDF](https://en.wikipedia.org/wiki/PDF)" not in content
|
||||
assert "PDF (https://en.wikipedia.org/wiki/PDF)" not in content
|
||||
@@ -0,0 +1,138 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.converters.file_to_file_content import FileToFileContent
|
||||
from haystack.core.serialization import component_from_dict, component_to_dict
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class TestFileToFileContent:
|
||||
def test_to_dict(self) -> None:
|
||||
converter = FileToFileContent()
|
||||
assert component_to_dict(converter, "converter") == {
|
||||
"init_parameters": {},
|
||||
"type": "haystack.components.converters.file_to_file_content.FileToFileContent",
|
||||
}
|
||||
|
||||
def test_from_dict(self) -> None:
|
||||
data = {"init_parameters": {}, "type": "haystack.components.converters.file_to_file_content.FileToFileContent"}
|
||||
converter = component_from_dict(FileToFileContent, data, "name")
|
||||
assert component_to_dict(converter, "converter") == data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_path", "mime_type"),
|
||||
[
|
||||
("./test/test_files/pdf/sample_pdf_1.pdf", "application/pdf"),
|
||||
("./test/test_files/txt/doc_1.txt", "text/plain"),
|
||||
],
|
||||
)
|
||||
def test_run_with_valid_sources(self, file_path: str, mime_type: str) -> None:
|
||||
converter = FileToFileContent()
|
||||
results = converter.run(sources=[file_path])
|
||||
|
||||
assert len(results["file_contents"]) == 1
|
||||
file_content = results["file_contents"][0]
|
||||
|
||||
assert file_content.base64_data is not None
|
||||
assert file_content.mime_type == mime_type
|
||||
assert file_content.filename == Path(file_path).name
|
||||
assert file_content.extra == {}
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
expected_base64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
assert file_content.base64_data == expected_base64
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_path", "mime_type"),
|
||||
[
|
||||
("./test/test_files/pdf/sample_pdf_1.pdf", "application/pdf"),
|
||||
("./test/test_files/audio/answer.wav", "audio/x-wav"),
|
||||
],
|
||||
)
|
||||
def test_run_with_bytestream_sources(self, file_path: str, mime_type: str) -> None:
|
||||
byte_stream = ByteStream.from_file_path(Path(file_path))
|
||||
|
||||
converter = FileToFileContent()
|
||||
results = converter.run(sources=[byte_stream])
|
||||
|
||||
assert len(results["file_contents"]) == 1
|
||||
file_content = results["file_contents"][0]
|
||||
|
||||
assert file_content.base64_data is not None
|
||||
assert file_content.mime_type == mime_type
|
||||
assert file_content.filename is None
|
||||
assert file_content.extra == {}
|
||||
|
||||
expected_base64 = base64.b64encode(byte_stream.data).decode("utf-8")
|
||||
assert file_content.base64_data == expected_base64
|
||||
|
||||
def test_run_with_no_sources(self) -> None:
|
||||
converter = FileToFileContent()
|
||||
results = converter.run(sources=[])
|
||||
assert results == {"file_contents": []}
|
||||
|
||||
def test_run_with_invalid_source_type(self, caplog) -> None:
|
||||
converter = FileToFileContent()
|
||||
converter.run(sources=[123])
|
||||
assert "Could not read" in caplog.text
|
||||
|
||||
def test_run_with_non_existent_file(self, caplog) -> None:
|
||||
converter = FileToFileContent()
|
||||
converter.run(sources=["./non_existent_file.pdf"])
|
||||
assert "Could not read" in caplog.text
|
||||
|
||||
def test_run_with_empty_bytestream(self) -> None:
|
||||
byte_stream = ByteStream(data=b"")
|
||||
|
||||
converter = FileToFileContent()
|
||||
results = converter.run(sources=[byte_stream])
|
||||
|
||||
assert results["file_contents"] == []
|
||||
|
||||
def test_run_with_extra_dict(self) -> None:
|
||||
converter = FileToFileContent()
|
||||
extra = {"key": "value"}
|
||||
results = converter.run(sources=["./test/test_files/txt/doc_1.txt"], extra=extra)
|
||||
|
||||
assert len(results["file_contents"]) == 1
|
||||
assert results["file_contents"][0].extra == extra
|
||||
|
||||
def test_run_with_extra_list(self) -> None:
|
||||
converter = FileToFileContent()
|
||||
sources = ["./test/test_files/txt/doc_1.txt", "./test/test_files/txt/doc_2.txt"]
|
||||
extra = [{"key": "value1"}, {"key": "value2"}]
|
||||
results = converter.run(sources=sources, extra=extra)
|
||||
|
||||
assert len(results["file_contents"]) == 2
|
||||
assert results["file_contents"][0].extra == {"key": "value1"}
|
||||
assert results["file_contents"][1].extra == {"key": "value2"}
|
||||
|
||||
def test_run_with_extra_dict_does_not_share_reference(self) -> None:
|
||||
# A single ``extra`` dict is applied to every source; each FileContent must get its own copy
|
||||
# so that mutating one file's ``extra`` downstream does not leak into the others.
|
||||
converter = FileToFileContent()
|
||||
sources = ["./test/test_files/txt/doc_1.txt", "./test/test_files/txt/doc_2.txt"]
|
||||
results = converter.run(sources=sources, extra={"tenant": "acme"})
|
||||
|
||||
file_contents = results["file_contents"]
|
||||
assert len(file_contents) == 2
|
||||
assert file_contents[0].extra is not file_contents[1].extra
|
||||
|
||||
file_contents[0].extra["page"] = 1
|
||||
assert "page" not in file_contents[1].extra
|
||||
|
||||
def test_run_skips_empty_files_among_valid(self, caplog) -> None:
|
||||
byte_stream_empty = ByteStream(data=b"")
|
||||
valid_source = "./test/test_files/txt/doc_1.txt"
|
||||
|
||||
converter = FileToFileContent()
|
||||
results = converter.run(sources=[byte_stream_empty, valid_source])
|
||||
|
||||
assert len(results["file_contents"]) == 1
|
||||
assert "empty" in caplog.text
|
||||
@@ -0,0 +1,254 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.converters import HTMLToDocument
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class TestHTMLToDocument:
|
||||
def test_run(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly.
|
||||
"""
|
||||
sources = [test_files_path / "html" / "what_is_haystack.html"]
|
||||
converter = HTMLToDocument()
|
||||
results = converter.run(sources=sources, meta={"test": "TEST"})
|
||||
docs = results["documents"]
|
||||
assert len(docs) == 1
|
||||
assert "Haystack" in docs[0].content
|
||||
assert docs[0].meta["test"] == "TEST"
|
||||
|
||||
def test_run_doc_metadata(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly when metadata is supplied by the user.
|
||||
"""
|
||||
converter = HTMLToDocument()
|
||||
sources = [test_files_path / "html" / "what_is_haystack.html"]
|
||||
metadata = [{"file_name": "what_is_haystack.html"}]
|
||||
results = converter.run(sources=sources, meta=metadata)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "Haystack" in docs[0].content
|
||||
assert docs[0].meta["file_name"] == "what_is_haystack.html"
|
||||
|
||||
def test_run_with_store_full_path(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly when metadata is supplied by the user.
|
||||
"""
|
||||
converter = HTMLToDocument(store_full_path=True)
|
||||
sources = [test_files_path / "html" / "what_is_haystack.html"]
|
||||
|
||||
results = converter.run(sources=sources) # store_full_path is True by default
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "Haystack" in docs[0].content
|
||||
assert docs[0].meta["file_path"] == str(sources[0])
|
||||
|
||||
converter_2 = HTMLToDocument(store_full_path=False)
|
||||
results = converter_2.run(sources=sources)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "Haystack" in docs[0].content
|
||||
assert docs[0].meta["file_path"] == "what_is_haystack.html"
|
||||
|
||||
def test_incorrect_meta(self, test_files_path):
|
||||
"""
|
||||
Test if the component raises an error when incorrect metadata is supplied by the user.
|
||||
"""
|
||||
converter = HTMLToDocument()
|
||||
sources = [test_files_path / "html" / "what_is_haystack.html"]
|
||||
metadata = [{"file_name": "what_is_haystack.html"}, {"file_name": "haystack.html"}]
|
||||
with pytest.raises(ValueError, match="The length of the metadata list must match the number of sources."):
|
||||
converter.run(sources=sources, meta=metadata)
|
||||
|
||||
def test_run_bytestream_metadata(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly when metadata is read from the ByteStream object.
|
||||
"""
|
||||
converter = HTMLToDocument()
|
||||
with open(test_files_path / "html" / "what_is_haystack.html", "rb") as file:
|
||||
byte_stream = file.read()
|
||||
stream = ByteStream(byte_stream, meta={"content_type": "text/html", "url": "test_url"})
|
||||
|
||||
results = converter.run(sources=[stream])
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "Haystack" in docs[0].content
|
||||
assert docs[0].meta == {"content_type": "text/html", "url": "test_url"}
|
||||
|
||||
def test_run_bytestream_and_doc_metadata(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly when metadata is read from the ByteStream object and supplied by the user.
|
||||
|
||||
There is no overlap between the metadata received.
|
||||
"""
|
||||
converter = HTMLToDocument()
|
||||
with open(test_files_path / "html" / "what_is_haystack.html", "rb") as file:
|
||||
byte_stream = file.read()
|
||||
stream = ByteStream(byte_stream, meta={"content_type": "text/html", "url": "test_url"})
|
||||
|
||||
metadata = [{"file_name": "what_is_haystack.html"}]
|
||||
results = converter.run(sources=[stream], meta=metadata)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "Haystack" in docs[0].content
|
||||
assert docs[0].meta == {"file_name": "what_is_haystack.html", "content_type": "text/html", "url": "test_url"}
|
||||
|
||||
def test_run_bytestream_doc_overlapping_metadata(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly when metadata is read from the ByteStream object and supplied by the user.
|
||||
|
||||
There is an overlap between the metadata received.
|
||||
|
||||
The component should use the supplied metadata to overwrite the values if there is an overlap between the keys.
|
||||
"""
|
||||
converter = HTMLToDocument()
|
||||
with open(test_files_path / "html" / "what_is_haystack.html", "rb") as file:
|
||||
byte_stream = file.read()
|
||||
# ByteStream has "url" present in metadata
|
||||
stream = ByteStream(byte_stream, meta={"content_type": "text/html", "url": "test_url_correct"})
|
||||
|
||||
# "url" supplied by the user overwrites value present in metadata
|
||||
metadata = [{"file_name": "what_is_haystack.html", "url": "test_url_new"}]
|
||||
results = converter.run(sources=[stream], meta=metadata)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "Haystack" in docs[0].content
|
||||
assert docs[0].meta == {
|
||||
"file_name": "what_is_haystack.html",
|
||||
"content_type": "text/html",
|
||||
"url": "test_url_new",
|
||||
}
|
||||
|
||||
def test_run_wrong_file_type(self, test_files_path, caplog):
|
||||
"""
|
||||
Test if the component runs correctly when an input file is not of the expected type.
|
||||
"""
|
||||
sources = [test_files_path / "audio" / "answer.wav"]
|
||||
converter = HTMLToDocument()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
results = converter.run(sources=sources)
|
||||
assert "Failed to extract text from" in caplog.text
|
||||
|
||||
assert results["documents"] == []
|
||||
|
||||
def test_run_error_handling(self, caplog):
|
||||
"""
|
||||
Test if the component correctly handles errors.
|
||||
"""
|
||||
sources = ["non_existing_file.html"]
|
||||
converter = HTMLToDocument()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
results = converter.run(sources=sources)
|
||||
assert "Could not read non_existing_file.html" in caplog.text
|
||||
assert results["documents"] == []
|
||||
|
||||
def test_run_empty_bytestream(self, caplog):
|
||||
"""
|
||||
Test that an empty ByteStream is skipped without invoking extraction,
|
||||
so no noisy lxml parse errors are emitted.
|
||||
"""
|
||||
empty_stream = ByteStream(data=b"")
|
||||
empty_stream.mime_type = "text/html"
|
||||
converter = HTMLToDocument()
|
||||
|
||||
with patch("haystack.components.converters.html.extract") as mock_extract:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
results = converter.run(sources=[empty_stream])
|
||||
|
||||
assert results["documents"] == []
|
||||
mock_extract.assert_not_called()
|
||||
assert "because it is empty" in caplog.text
|
||||
|
||||
def test_mixed_sources_run(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly if the input is a mix of paths and ByteStreams.
|
||||
"""
|
||||
sources = [
|
||||
test_files_path / "html" / "what_is_haystack.html",
|
||||
str((test_files_path / "html" / "what_is_haystack.html").absolute()),
|
||||
]
|
||||
with open(test_files_path / "html" / "what_is_haystack.html", "rb") as f:
|
||||
byte_stream = f.read()
|
||||
sources.append(ByteStream(byte_stream))
|
||||
|
||||
converter = HTMLToDocument()
|
||||
results = converter.run(sources=sources)
|
||||
docs = results["documents"]
|
||||
assert len(docs) == 3
|
||||
for doc in docs:
|
||||
assert "Haystack" in doc.content
|
||||
|
||||
def test_bytestream_encoding_from_meta(self):
|
||||
"""
|
||||
Test that a non-UTF-8 ByteStream is decoded using the encoding specified in its meta.
|
||||
"""
|
||||
# "caf\xe9" is "café" in latin-1; decoding as utf-8 would raise UnicodeDecodeError.
|
||||
latin1_html = b"<html><body><p>caf\xe9</p></body></html>"
|
||||
bytestream = ByteStream(data=latin1_html, meta={"encoding": "latin-1"})
|
||||
|
||||
converter = HTMLToDocument()
|
||||
results = converter.run(sources=[bytestream])
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "café" in docs[0].content
|
||||
|
||||
def test_bytestream_encoding_from_init(self):
|
||||
"""
|
||||
Test that the encoding passed to __init__ is used as a fallback when not set in ByteStream meta.
|
||||
"""
|
||||
latin1_html = b"<html><body><p>caf\xe9</p></body></html>"
|
||||
bytestream = ByteStream(data=latin1_html)
|
||||
|
||||
converter = HTMLToDocument(encoding="latin-1")
|
||||
results = converter.run(sources=[bytestream])
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "café" in docs[0].content
|
||||
|
||||
def test_serde(self):
|
||||
"""
|
||||
Test if the component runs correctly gets serialized and deserialized.
|
||||
"""
|
||||
converter = HTMLToDocument(encoding="latin-1")
|
||||
serde_data = converter.to_dict()
|
||||
new_converter = HTMLToDocument.from_dict(serde_data)
|
||||
assert new_converter.extraction_kwargs == converter.extraction_kwargs
|
||||
assert new_converter.encoding == converter.encoding
|
||||
|
||||
def test_run_difficult_html(self, test_files_path):
|
||||
converter = HTMLToDocument()
|
||||
result = converter.run(sources=[Path(test_files_path / "html" / "paul_graham_superlinear.html")])
|
||||
|
||||
assert len(result["documents"]) == 1
|
||||
assert "Superlinear" in result["documents"][0].content
|
||||
|
||||
@patch("haystack.components.converters.html.extract", return_value="test")
|
||||
def test_run_with_extraction_kwargs(self, mock_extract, test_files_path):
|
||||
sources = [test_files_path / "html" / "what_is_haystack.html"]
|
||||
|
||||
converter = HTMLToDocument()
|
||||
converter.run(sources=sources)
|
||||
assert mock_extract.call_count == 1
|
||||
assert "favor_precision" not in mock_extract.call_args[1]
|
||||
|
||||
precise_converter = HTMLToDocument(extraction_kwargs={"favor_precision": True})
|
||||
mock_extract.reset_mock()
|
||||
precise_converter.run(sources=sources)
|
||||
assert mock_extract.call_count == 1
|
||||
assert mock_extract.call_args[1]["favor_precision"] is True
|
||||
@@ -0,0 +1,518 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.converters import JSONConverter
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
test_data = [
|
||||
{
|
||||
"year": "1997",
|
||||
"category": "literature",
|
||||
"laureates": [
|
||||
{
|
||||
"id": "674",
|
||||
"firstname": "Dario",
|
||||
"surname": "Fokin",
|
||||
"motivation": "who emulates the jesters of the Middle Ages in scourging authority and upholding the "
|
||||
"dignity of the downtrodden",
|
||||
"share": "1",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"year": "1986",
|
||||
"category": "medicine",
|
||||
"laureates": [
|
||||
{
|
||||
"id": "434",
|
||||
"firstname": "Stanley",
|
||||
"surname": "Cohen",
|
||||
"motivation": "for their discoveries of growth factors",
|
||||
"share": "2",
|
||||
},
|
||||
{
|
||||
"id": "435",
|
||||
"firstname": "Rita",
|
||||
"surname": "Levi-Montalcini",
|
||||
"motivation": "for their discoveries of growth factors",
|
||||
"share": "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"year": "1938",
|
||||
"category": "physics",
|
||||
"laureates": [
|
||||
{
|
||||
"id": "46",
|
||||
"firstname": "Enrico",
|
||||
"surname": "Fermi",
|
||||
"motivation": "for his demonstrations of the existence of new radioactive elements produced by neutron "
|
||||
"irradiation, and for his related discovery of nuclear reactions brought about by slow "
|
||||
"neutrons",
|
||||
"share": "1",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_init_without_jq_schema_and_content_key():
|
||||
with pytest.raises(
|
||||
ValueError, match="No `jq_schema` nor `content_key` specified. Set either or both to extract data."
|
||||
):
|
||||
JSONConverter()
|
||||
|
||||
|
||||
@patch("haystack.components.converters.json.jq_import")
|
||||
def test_init_without_jq_schema_and_missing_dependency(jq_import):
|
||||
converter = JSONConverter(content_key="foo")
|
||||
jq_import.check.assert_not_called()
|
||||
assert converter._jq_schema is None
|
||||
assert converter._content_key == "foo"
|
||||
assert converter._meta_fields is None
|
||||
|
||||
|
||||
@patch("haystack.components.converters.json.jq_import")
|
||||
def test_init_with_jq_schema_and_missing_dependency(jq_import):
|
||||
jq_import.check.side_effect = ImportError
|
||||
with pytest.raises(ImportError):
|
||||
JSONConverter(jq_schema=".laureates[].motivation")
|
||||
|
||||
|
||||
def test_init_with_jq_schema():
|
||||
converter = JSONConverter(jq_schema=".")
|
||||
assert converter._jq_schema == "."
|
||||
assert converter._content_key is None
|
||||
assert converter._meta_fields is None
|
||||
|
||||
|
||||
def test_to_dict():
|
||||
converter = JSONConverter(
|
||||
jq_schema=".laureates[]", content_key="motivation", extra_meta_fields={"firstname", "surname"}
|
||||
)
|
||||
|
||||
assert converter.to_dict() == {
|
||||
"type": "haystack.components.converters.json.JSONConverter",
|
||||
"init_parameters": {
|
||||
"content_key": "motivation",
|
||||
"jq_schema": ".laureates[]",
|
||||
"extra_meta_fields": {"firstname", "surname"},
|
||||
"store_full_path": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_from_dict():
|
||||
data = {
|
||||
"type": "haystack.components.converters.json.JSONConverter",
|
||||
"init_parameters": {
|
||||
"content_key": "motivation",
|
||||
"jq_schema": ".laureates[]",
|
||||
"extra_meta_fields": ["firstname", "surname"],
|
||||
"store_full_path": True,
|
||||
},
|
||||
}
|
||||
converter = JSONConverter.from_dict(data)
|
||||
|
||||
assert converter._jq_schema == ".laureates[]"
|
||||
assert converter._content_key == "motivation"
|
||||
assert converter._meta_fields == ["firstname", "surname"]
|
||||
|
||||
|
||||
def test_run(tmpdir):
|
||||
first_test_file = Path(tmpdir / "first_test_file.json")
|
||||
second_test_file = Path(tmpdir / "second_test_file.json")
|
||||
|
||||
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
|
||||
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
|
||||
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
|
||||
|
||||
sources = [str(first_test_file), second_test_file, byte_stream]
|
||||
|
||||
converter = JSONConverter(jq_schema='.laureates[] | .firstname + " " + .surname + " " + .motivation')
|
||||
result = converter.run(sources=sources)
|
||||
assert len(result) == 1
|
||||
assert len(result["documents"]) == 4
|
||||
assert (
|
||||
result["documents"][0].content
|
||||
== "Dario Fokin who emulates the jesters of the Middle Ages in scourging authority and "
|
||||
"upholding the dignity of the downtrodden"
|
||||
)
|
||||
assert result["documents"][0].meta == {"file_path": os.path.basename(first_test_file)}
|
||||
assert result["documents"][1].content == "Stanley Cohen for their discoveries of growth factors"
|
||||
assert result["documents"][1].meta == {"file_path": os.path.basename(second_test_file)}
|
||||
assert result["documents"][2].content == "Rita Levi-Montalcini for their discoveries of growth factors"
|
||||
assert result["documents"][2].meta == {"file_path": os.path.basename(second_test_file)}
|
||||
assert (
|
||||
result["documents"][3].content == "Enrico Fermi for his demonstrations of the existence of new "
|
||||
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
|
||||
"reactions brought about by slow neutrons"
|
||||
)
|
||||
assert result["documents"][3].meta == {}
|
||||
|
||||
|
||||
def test_run_with_store_full_path_false(tmpdir):
|
||||
"""
|
||||
Test if the component runs correctly with store_full_path=False
|
||||
"""
|
||||
first_test_file = Path(tmpdir / "first_test_file.json")
|
||||
second_test_file = Path(tmpdir / "second_test_file.json")
|
||||
|
||||
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
|
||||
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
|
||||
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
|
||||
|
||||
sources = [str(first_test_file), second_test_file, byte_stream]
|
||||
|
||||
converter = JSONConverter(
|
||||
jq_schema='.laureates[] | .firstname + " " + .surname + " " + .motivation', store_full_path=False
|
||||
)
|
||||
result = converter.run(sources=sources)
|
||||
assert len(result) == 1
|
||||
assert len(result["documents"]) == 4
|
||||
assert (
|
||||
result["documents"][0].content
|
||||
== "Dario Fokin who emulates the jesters of the Middle Ages in scourging authority and "
|
||||
"upholding the dignity of the downtrodden"
|
||||
)
|
||||
assert result["documents"][0].meta == {"file_path": "first_test_file.json"}
|
||||
assert result["documents"][1].content == "Stanley Cohen for their discoveries of growth factors"
|
||||
assert result["documents"][1].meta == {"file_path": "second_test_file.json"}
|
||||
assert result["documents"][2].content == "Rita Levi-Montalcini for their discoveries of growth factors"
|
||||
assert result["documents"][2].meta == {"file_path": "second_test_file.json"}
|
||||
assert (
|
||||
result["documents"][3].content == "Enrico Fermi for his demonstrations of the existence of new "
|
||||
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
|
||||
"reactions brought about by slow neutrons"
|
||||
)
|
||||
assert result["documents"][3].meta == {}
|
||||
|
||||
|
||||
def test_run_with_non_json_file(tmpdir, caplog):
|
||||
test_file = Path(tmpdir / "test_file.md")
|
||||
test_file.write_text("This is not a JSON file.", "utf-8")
|
||||
|
||||
sources = [test_file]
|
||||
converter = JSONConverter(".laureates | .motivation")
|
||||
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = converter.run(sources=sources)
|
||||
|
||||
records = caplog.records
|
||||
assert len(records) == 1
|
||||
assert (
|
||||
records[0].msg
|
||||
== f"Failed to extract text from {test_file}. Skipping it. Error: parse error: Invalid numeric literal at "
|
||||
f"line 1, column 5"
|
||||
)
|
||||
assert result == {"documents": []}
|
||||
|
||||
|
||||
def test_run_with_bad_filter(tmpdir, caplog):
|
||||
test_file = Path(tmpdir / "test_file.json")
|
||||
test_file.write_text(json.dumps(test_data[0]), "utf-8")
|
||||
|
||||
sources = [test_file]
|
||||
converter = JSONConverter(".laureates | .motivation")
|
||||
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = converter.run(sources=sources)
|
||||
|
||||
records = caplog.records
|
||||
assert len(records) == 1
|
||||
assert (
|
||||
records[0].msg
|
||||
== f'Failed to extract text from {test_file}. Skipping it. Error: Cannot index array with string "motivation"'
|
||||
)
|
||||
assert result == {"documents": []}
|
||||
|
||||
|
||||
def test_run_with_bad_encoding(tmpdir, caplog):
|
||||
test_file = Path(tmpdir / "test_file.json")
|
||||
test_file.write_text(json.dumps(test_data[0]), "utf-16")
|
||||
|
||||
sources = [test_file]
|
||||
converter = JSONConverter(".laureates")
|
||||
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = converter.run(sources=sources)
|
||||
|
||||
records = caplog.records
|
||||
assert len(records) == 1
|
||||
assert records[0].msg.startswith(
|
||||
f"Failed to extract text from {test_file}. Skipping it. Error: 'utf-8' codec can't decode byte"
|
||||
)
|
||||
assert result == {"documents": []}
|
||||
|
||||
|
||||
def test_run_with_single_meta(tmpdir):
|
||||
first_test_file = Path(tmpdir / "first_test_file.json")
|
||||
second_test_file = Path(tmpdir / "second_test_file.json")
|
||||
|
||||
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
|
||||
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
|
||||
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
|
||||
|
||||
sources = [str(first_test_file), second_test_file, byte_stream]
|
||||
meta = {"creation_date": "1945-05-25T00:00:00"}
|
||||
converter = JSONConverter(jq_schema='.laureates[] | .firstname + " " + .surname + " " + .motivation')
|
||||
result = converter.run(sources=sources, meta=meta)
|
||||
assert len(result) == 1
|
||||
assert len(result["documents"]) == 4
|
||||
assert (
|
||||
result["documents"][0].content
|
||||
== "Dario Fokin who emulates the jesters of the Middle Ages in scourging authority and "
|
||||
"upholding the dignity of the downtrodden"
|
||||
)
|
||||
assert result["documents"][0].meta == {
|
||||
"file_path": os.path.basename(first_test_file),
|
||||
"creation_date": "1945-05-25T00:00:00",
|
||||
}
|
||||
assert result["documents"][1].content == "Stanley Cohen for their discoveries of growth factors"
|
||||
assert result["documents"][1].meta == {
|
||||
"file_path": os.path.basename(second_test_file),
|
||||
"creation_date": "1945-05-25T00:00:00",
|
||||
}
|
||||
assert result["documents"][2].content == "Rita Levi-Montalcini for their discoveries of growth factors"
|
||||
assert result["documents"][2].meta == {
|
||||
"file_path": os.path.basename(second_test_file),
|
||||
"creation_date": "1945-05-25T00:00:00",
|
||||
}
|
||||
assert (
|
||||
result["documents"][3].content == "Enrico Fermi for his demonstrations of the existence of new "
|
||||
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
|
||||
"reactions brought about by slow neutrons"
|
||||
)
|
||||
assert result["documents"][3].meta == {"creation_date": "1945-05-25T00:00:00"}
|
||||
|
||||
|
||||
def test_run_with_meta_list(tmpdir):
|
||||
first_test_file = Path(tmpdir / "first_test_file.json")
|
||||
second_test_file = Path(tmpdir / "second_test_file.json")
|
||||
|
||||
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
|
||||
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
|
||||
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
|
||||
|
||||
sources = [str(first_test_file), second_test_file, byte_stream]
|
||||
meta = [
|
||||
{"creation_date": "1945-05-25T00:00:00"},
|
||||
{"creation_date": "1943-09-03T00:00:00"},
|
||||
{"creation_date": "1989-11-09T00:00:00"},
|
||||
]
|
||||
converter = JSONConverter(jq_schema='.laureates[] | .firstname + " " + .surname + " " + .motivation')
|
||||
result = converter.run(sources=sources, meta=meta)
|
||||
assert len(result) == 1
|
||||
assert len(result["documents"]) == 4
|
||||
assert (
|
||||
result["documents"][0].content
|
||||
== "Dario Fokin who emulates the jesters of the Middle Ages in scourging authority and "
|
||||
"upholding the dignity of the downtrodden"
|
||||
)
|
||||
assert result["documents"][0].meta == {
|
||||
"file_path": os.path.basename(first_test_file),
|
||||
"creation_date": "1945-05-25T00:00:00",
|
||||
}
|
||||
assert result["documents"][1].content == "Stanley Cohen for their discoveries of growth factors"
|
||||
assert result["documents"][1].meta == {
|
||||
"file_path": os.path.basename(second_test_file),
|
||||
"creation_date": "1943-09-03T00:00:00",
|
||||
}
|
||||
assert result["documents"][2].content == "Rita Levi-Montalcini for their discoveries of growth factors"
|
||||
assert result["documents"][2].meta == {
|
||||
"file_path": os.path.basename(second_test_file),
|
||||
"creation_date": "1943-09-03T00:00:00",
|
||||
}
|
||||
assert (
|
||||
result["documents"][3].content == "Enrico Fermi for his demonstrations of the existence of new "
|
||||
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
|
||||
"reactions brought about by slow neutrons"
|
||||
)
|
||||
assert result["documents"][3].meta == {"creation_date": "1989-11-09T00:00:00"}
|
||||
|
||||
|
||||
def test_run_with_meta_list_of_differing_length(tmpdir):
|
||||
sources = ["random_file.json"]
|
||||
|
||||
meta = [{}, {}]
|
||||
converter = JSONConverter(jq_schema=".")
|
||||
with pytest.raises(ValueError, match="The length of the metadata list must match the number of sources."):
|
||||
converter.run(sources=sources, meta=meta)
|
||||
|
||||
|
||||
def test_run_with_jq_schema_and_content_key(tmpdir):
|
||||
first_test_file = Path(tmpdir / "first_test_file.json")
|
||||
second_test_file = Path(tmpdir / "second_test_file.json")
|
||||
|
||||
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
|
||||
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
|
||||
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
|
||||
|
||||
sources = [str(first_test_file), second_test_file, byte_stream]
|
||||
converter = JSONConverter(jq_schema=".laureates[]", content_key="motivation")
|
||||
result = converter.run(sources=sources)
|
||||
assert len(result) == 1
|
||||
assert len(result["documents"]) == 4
|
||||
assert (
|
||||
result["documents"][0].content == "who emulates the jesters of the Middle Ages in scourging authority and "
|
||||
"upholding the dignity of the downtrodden"
|
||||
)
|
||||
assert result["documents"][0].meta == {"file_path": os.path.basename(first_test_file)}
|
||||
assert result["documents"][1].content == "for their discoveries of growth factors"
|
||||
assert result["documents"][1].meta == {"file_path": os.path.basename(second_test_file)}
|
||||
assert result["documents"][2].content == "for their discoveries of growth factors"
|
||||
assert result["documents"][2].meta == {"file_path": os.path.basename(second_test_file)}
|
||||
assert (
|
||||
result["documents"][3].content == "for his demonstrations of the existence of new "
|
||||
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
|
||||
"reactions brought about by slow neutrons"
|
||||
)
|
||||
assert result["documents"][3].meta == {}
|
||||
|
||||
|
||||
def test_run_with_jq_schema_content_key_and_extra_meta_fields(tmpdir):
|
||||
first_test_file = Path(tmpdir / "first_test_file.json")
|
||||
second_test_file = Path(tmpdir / "second_test_file.json")
|
||||
|
||||
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
|
||||
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
|
||||
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
|
||||
|
||||
sources = [str(first_test_file), second_test_file, byte_stream]
|
||||
converter = JSONConverter(
|
||||
jq_schema=".laureates[]", content_key="motivation", extra_meta_fields={"firstname", "surname"}
|
||||
)
|
||||
result = converter.run(sources=sources)
|
||||
assert len(result) == 1
|
||||
assert len(result["documents"]) == 4
|
||||
assert (
|
||||
result["documents"][0].content == "who emulates the jesters of the Middle Ages in scourging authority and "
|
||||
"upholding the dignity of the downtrodden"
|
||||
)
|
||||
assert result["documents"][0].meta == {
|
||||
"file_path": os.path.basename(first_test_file),
|
||||
"firstname": "Dario",
|
||||
"surname": "Fokin",
|
||||
}
|
||||
assert result["documents"][1].content == "for their discoveries of growth factors"
|
||||
assert result["documents"][1].meta == {
|
||||
"file_path": os.path.basename(second_test_file),
|
||||
"firstname": "Stanley",
|
||||
"surname": "Cohen",
|
||||
}
|
||||
assert result["documents"][2].content == "for their discoveries of growth factors"
|
||||
assert result["documents"][2].meta == {
|
||||
"file_path": os.path.basename(second_test_file),
|
||||
"firstname": "Rita",
|
||||
"surname": "Levi-Montalcini",
|
||||
}
|
||||
assert (
|
||||
result["documents"][3].content == "for his demonstrations of the existence of new "
|
||||
"radioactive elements produced by neutron irradiation, and for his related discovery of nuclear "
|
||||
"reactions brought about by slow neutrons"
|
||||
)
|
||||
assert result["documents"][3].meta == {"firstname": "Enrico", "surname": "Fermi"}
|
||||
|
||||
|
||||
def test_run_with_content_key(tmpdir):
|
||||
first_test_file = Path(tmpdir / "first_test_file.json")
|
||||
second_test_file = Path(tmpdir / "second_test_file.json")
|
||||
|
||||
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
|
||||
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
|
||||
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
|
||||
|
||||
sources = [str(first_test_file), second_test_file, byte_stream]
|
||||
converter = JSONConverter(content_key="category")
|
||||
result = converter.run(sources=sources)
|
||||
assert len(result) == 1
|
||||
assert len(result["documents"]) == 3
|
||||
assert result["documents"][0].content == "literature"
|
||||
assert result["documents"][0].meta == {"file_path": os.path.basename(first_test_file)}
|
||||
assert result["documents"][1].content == "medicine"
|
||||
assert result["documents"][1].meta == {"file_path": os.path.basename(second_test_file)}
|
||||
assert result["documents"][2].content == "physics"
|
||||
assert result["documents"][2].meta == {}
|
||||
|
||||
|
||||
def test_run_with_content_key_and_extra_meta_fields(tmpdir):
|
||||
first_test_file = Path(tmpdir / "first_test_file.json")
|
||||
second_test_file = Path(tmpdir / "second_test_file.json")
|
||||
|
||||
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
|
||||
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
|
||||
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
|
||||
|
||||
sources = [str(first_test_file), second_test_file, byte_stream]
|
||||
converter = JSONConverter(content_key="category", extra_meta_fields={"year"})
|
||||
result = converter.run(sources=sources)
|
||||
assert len(result) == 1
|
||||
assert len(result["documents"]) == 3
|
||||
assert result["documents"][0].content == "literature"
|
||||
assert result["documents"][0].meta == {"file_path": os.path.basename(first_test_file), "year": "1997"}
|
||||
assert result["documents"][1].content == "medicine"
|
||||
assert result["documents"][1].meta == {"file_path": os.path.basename(second_test_file), "year": "1986"}
|
||||
assert result["documents"][2].content == "physics"
|
||||
assert result["documents"][2].meta == {"year": "1938"}
|
||||
|
||||
|
||||
def test_run_with_jq_schema_content_key_and_extra_meta_fields_literal(tmpdir):
|
||||
first_test_file = Path(tmpdir / "first_test_file.json")
|
||||
second_test_file = Path(tmpdir / "second_test_file.json")
|
||||
|
||||
first_test_file.write_text(json.dumps(test_data[0]), "utf-8")
|
||||
second_test_file.write_text(json.dumps(test_data[1]), "utf-8")
|
||||
byte_stream = ByteStream.from_string(json.dumps(test_data[2]))
|
||||
|
||||
sources = [str(first_test_file), second_test_file, byte_stream]
|
||||
converter = JSONConverter(jq_schema=".laureates[]", content_key="motivation", extra_meta_fields="*")
|
||||
result = converter.run(sources=sources)
|
||||
assert len(result) == 1
|
||||
assert len(result["documents"]) == 4
|
||||
assert (
|
||||
result["documents"][0].content
|
||||
== "who emulates the jesters of the Middle Ages in scourging authority and upholding the dignity of the "
|
||||
"downtrodden"
|
||||
)
|
||||
assert result["documents"][0].meta == {
|
||||
"file_path": os.path.basename(first_test_file),
|
||||
"id": "674",
|
||||
"firstname": "Dario",
|
||||
"surname": "Fokin",
|
||||
"share": "1",
|
||||
}
|
||||
assert result["documents"][1].content == "for their discoveries of growth factors"
|
||||
assert result["documents"][1].meta == {
|
||||
"file_path": os.path.basename(second_test_file),
|
||||
"id": "434",
|
||||
"firstname": "Stanley",
|
||||
"surname": "Cohen",
|
||||
"share": "2",
|
||||
}
|
||||
assert result["documents"][2].content == "for their discoveries of growth factors"
|
||||
assert result["documents"][2].meta == {
|
||||
"file_path": os.path.basename(second_test_file),
|
||||
"id": "435",
|
||||
"firstname": "Rita",
|
||||
"surname": "Levi-Montalcini",
|
||||
"share": "2",
|
||||
}
|
||||
assert (
|
||||
result["documents"][3].content
|
||||
== "for his demonstrations of the existence of new radioactive elements produced by neutron irradiation, "
|
||||
"and for his related discovery of nuclear reactions brought about by slow neutrons"
|
||||
)
|
||||
assert result["documents"][3].meta == {"id": "46", "firstname": "Enrico", "surname": "Fermi", "share": "1"}
|
||||
@@ -0,0 +1,233 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.converters.markdown import MarkdownToDocument
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class TestMarkdownToDocument:
|
||||
def test_init_params_default(self):
|
||||
converter = MarkdownToDocument()
|
||||
assert converter.table_to_single_line is False
|
||||
assert converter.progress_bar is True
|
||||
assert converter.encoding == "utf-8"
|
||||
assert converter.extract_frontmatter is False
|
||||
|
||||
def test_init_params_custom(self):
|
||||
converter = MarkdownToDocument(table_to_single_line=True, progress_bar=False, store_full_path=False)
|
||||
assert converter.table_to_single_line is True
|
||||
assert converter.progress_bar is False
|
||||
assert converter.store_full_path is False
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_run(self, test_files_path):
|
||||
converter = MarkdownToDocument()
|
||||
sources = [test_files_path / "markdown" / "sample.md"]
|
||||
results = converter.run(sources=sources)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
for doc in docs:
|
||||
assert "What to build with Haystack" in doc.content
|
||||
assert "# git clone https://github.com/deepset-ai/haystack.git" in doc.content
|
||||
|
||||
def test_run_with_store_full_path_false(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly with store_full_path=False
|
||||
"""
|
||||
converter = MarkdownToDocument(store_full_path=False)
|
||||
sources = [test_files_path / "markdown" / "sample.md"]
|
||||
results = converter.run(sources=sources)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
for doc in docs:
|
||||
assert "What to build with Haystack" in doc.content
|
||||
assert "# git clone https://github.com/deepset-ai/haystack.git" in doc.content
|
||||
assert doc.meta["file_path"] == "sample.md"
|
||||
|
||||
def test_run_calls_normalize_metadata(self, test_files_path):
|
||||
bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
|
||||
|
||||
converter = MarkdownToDocument()
|
||||
|
||||
with patch("haystack.components.converters.markdown.normalize_metadata") as normalize_metadata:
|
||||
normalize_metadata.return_value = [{"language": "it"}, {"language": "it"}]
|
||||
|
||||
converter.run(sources=[bytestream, test_files_path / "markdown" / "sample.md"], meta={"language": "it"})
|
||||
|
||||
# check that the metadata normalizer is called properly
|
||||
normalize_metadata.assert_called_with(meta={"language": "it"}, sources_count=2)
|
||||
|
||||
def test_run_with_meta(self, test_files_path):
|
||||
bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
|
||||
|
||||
converter = MarkdownToDocument()
|
||||
|
||||
with patch("haystack.components.converters.markdown.MarkdownIt.render") as mock_render:
|
||||
mock_render.return_value = "test"
|
||||
output = converter.run(
|
||||
sources=[bytestream, test_files_path / "markdown" / "sample.md"], meta={"language": "it"}
|
||||
)
|
||||
|
||||
# check that the metadata from the bytestream is merged with that from the meta parameter
|
||||
assert output["documents"][0].meta["author"] == "test_author"
|
||||
assert output["documents"][0].meta["language"] == "it"
|
||||
assert output["documents"][1].meta["language"] == "it"
|
||||
|
||||
def test_run_extracts_yaml_frontmatter_into_metadata(self):
|
||||
bytestream = ByteStream(
|
||||
data=(
|
||||
b"---\n"
|
||||
b"ticker: AAPL\n"
|
||||
b"date: 2026-06-12\n"
|
||||
b"rating_score: 4\n"
|
||||
b"source: earnings_call\n"
|
||||
b"tags:\n"
|
||||
b" - guidance\n"
|
||||
b"---\n"
|
||||
b"# Thesis\n"
|
||||
b"Revenue guidance improved.\n"
|
||||
),
|
||||
meta={"file_path": "/tmp/aapl.md"},
|
||||
)
|
||||
|
||||
converter = MarkdownToDocument(progress_bar=False, extract_frontmatter=True)
|
||||
output = converter.run(sources=[bytestream])
|
||||
document = output["documents"][0]
|
||||
|
||||
assert "Revenue guidance improved." in document.content
|
||||
assert "ticker: AAPL" not in document.content
|
||||
assert document.meta["ticker"] == "AAPL"
|
||||
assert document.meta["date"] == "2026-06-12"
|
||||
assert document.meta["rating_score"] == 4
|
||||
assert document.meta["source"] == "earnings_call"
|
||||
assert document.meta["tags"] == ["guidance"]
|
||||
assert document.meta["file_path"] == "aapl.md"
|
||||
|
||||
def test_run_keeps_frontmatter_as_content_by_default(self):
|
||||
bytestream = ByteStream(data=b"---\nticker: AAPL\n---\n# Thesis\n")
|
||||
|
||||
converter = MarkdownToDocument(progress_bar=False)
|
||||
output = converter.run(sources=[bytestream])
|
||||
document = output["documents"][0]
|
||||
|
||||
assert "ticker: AAPL" in document.content
|
||||
assert "ticker" not in document.meta
|
||||
|
||||
def test_run_meta_overrides_frontmatter_metadata(self):
|
||||
bytestream = ByteStream(
|
||||
data=b"---\nticker: AAPL\nsource: filing\n---\n# Thesis\n", meta={"source": "bytestream"}
|
||||
)
|
||||
|
||||
converter = MarkdownToDocument(progress_bar=False, extract_frontmatter=True)
|
||||
output = converter.run(sources=[bytestream], meta={"ticker": "MSFT"})
|
||||
document = output["documents"][0]
|
||||
|
||||
assert document.meta["ticker"] == "MSFT"
|
||||
assert document.meta["source"] == "filing"
|
||||
|
||||
def test_run_keeps_malformed_frontmatter_as_content_and_logs_warning(self, caplog):
|
||||
bytestream = ByteStream(data=b"---\nticker: [AAPL\n---\n# Thesis\n")
|
||||
|
||||
converter = MarkdownToDocument(progress_bar=False, extract_frontmatter=True)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
output = converter.run(sources=[bytestream])
|
||||
|
||||
document = output["documents"][0]
|
||||
assert "ticker: [AAPL" in document.content
|
||||
assert "ticker" not in document.meta
|
||||
assert "Could not parse YAML frontmatter" in caplog.text
|
||||
|
||||
def test_run_keeps_unserializable_frontmatter_as_content_and_logs_warning(self, caplog):
|
||||
bytestream = ByteStream(data=b"---\ncycle: &cycle\n - *cycle\n---\n# Thesis\n")
|
||||
|
||||
converter = MarkdownToDocument(progress_bar=False, extract_frontmatter=True)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
output = converter.run(sources=[bytestream])
|
||||
|
||||
document = output["documents"][0]
|
||||
assert "cycle:" in document.content
|
||||
assert "cycle" not in document.meta
|
||||
assert "Could not convert YAML frontmatter" in caplog.text
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_run_wrong_file_type(self, test_files_path, caplog):
|
||||
"""
|
||||
Test if the component runs correctly when an input file is not of the expected type.
|
||||
"""
|
||||
sources = [test_files_path / "audio" / "answer.wav"]
|
||||
converter = MarkdownToDocument()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
output = converter.run(sources=sources)
|
||||
assert "codec can't decode byte" in caplog.text
|
||||
|
||||
docs = output["documents"]
|
||||
assert not docs
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_run_error_handling(self, caplog):
|
||||
"""
|
||||
Test if the component correctly handles errors.
|
||||
"""
|
||||
sources = ["non_existing_file.md"]
|
||||
converter = MarkdownToDocument()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = converter.run(sources=sources)
|
||||
assert "Could not read non_existing_file.md" in caplog.text
|
||||
assert not result["documents"]
|
||||
|
||||
def test_mixed_sources_run(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly if the input is a mix of strings, paths and ByteStreams.
|
||||
"""
|
||||
sources = [
|
||||
test_files_path / "markdown" / "sample.md",
|
||||
str((test_files_path / "markdown" / "sample.md").absolute()),
|
||||
]
|
||||
with open(test_files_path / "markdown" / "sample.md", "rb") as f:
|
||||
byte_stream = f.read()
|
||||
sources.append(ByteStream(byte_stream))
|
||||
|
||||
converter = MarkdownToDocument()
|
||||
output = converter.run(sources=sources)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 3
|
||||
for doc in docs:
|
||||
assert "What to build with Haystack" in doc.content
|
||||
assert "# git clone https://github.com/deepset-ai/haystack.git" in doc.content
|
||||
|
||||
def test_bytestream_encoding_from_meta(self):
|
||||
"""
|
||||
Test that a non-UTF-8 ByteStream is decoded using the encoding specified in its meta.
|
||||
"""
|
||||
# "caf\xe9" is "café" in latin-1; decoding as utf-8 would raise UnicodeDecodeError.
|
||||
latin1_md = "# caf\xe9".encode("latin-1")
|
||||
bytestream = ByteStream(data=latin1_md, meta={"encoding": "latin-1"})
|
||||
|
||||
converter = MarkdownToDocument(progress_bar=False)
|
||||
output = converter.run(sources=[bytestream])
|
||||
docs = output["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "café" in docs[0].content
|
||||
|
||||
def test_bytestream_encoding_from_init(self):
|
||||
"""
|
||||
Test that the encoding passed to __init__ is used as a fallback when not set in ByteStream meta.
|
||||
"""
|
||||
latin1_md = "# caf\xe9".encode("latin-1")
|
||||
bytestream = ByteStream(data=latin1_md)
|
||||
|
||||
converter = MarkdownToDocument(encoding="latin-1", progress_bar=False)
|
||||
output = converter.run(sources=[bytestream])
|
||||
docs = output["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "café" in docs[0].content
|
||||
@@ -0,0 +1,38 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from haystack.components.converters.msg import MSGToDocument
|
||||
|
||||
|
||||
class TestMSGToDocument:
|
||||
def test_run(self, test_files_path):
|
||||
converter = MSGToDocument(store_full_path=True)
|
||||
paths = [test_files_path / "msg" / "sample.msg"]
|
||||
result = converter.run(sources=paths, meta={"date_added": "2021-09-01T00:00:00"})
|
||||
assert len(result["documents"]) == 1
|
||||
assert result["documents"][0].content.startswith('From: "Sebastian Lee"')
|
||||
assert result["documents"][0].meta == {
|
||||
"date_added": "2021-09-01T00:00:00",
|
||||
"file_path": str(test_files_path / "msg" / "sample.msg"),
|
||||
}
|
||||
assert len(result["attachments"]) == 1
|
||||
assert result["attachments"][0].mime_type == "application/pdf"
|
||||
assert result["attachments"][0].meta == {
|
||||
"date_added": "2021-09-01T00:00:00",
|
||||
"parent_file_path": str(test_files_path / "msg" / "sample.msg"),
|
||||
"file_path": "sample_pdf_1.pdf",
|
||||
}
|
||||
|
||||
def test_run_wrong_file_type(self, test_files_path, caplog):
|
||||
converter = MSGToDocument(store_full_path=False)
|
||||
paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
|
||||
result = converter.run(sources=paths, meta={"date_added": "2021-09-01T00:00:00"})
|
||||
assert len(result["documents"]) == 0
|
||||
assert "msg_file is not an Outlook MSG file" in caplog.text
|
||||
|
||||
def test_run_empty_sources(self, test_files_path):
|
||||
converter = MSGToDocument(store_full_path=False)
|
||||
result = converter.run(sources=[])
|
||||
assert len(result["documents"]) == 0
|
||||
assert len(result["attachments"]) == 0
|
||||
@@ -0,0 +1,145 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack import Document, Pipeline
|
||||
from haystack.components.converters.multi_file_converter import MultiFileConverter
|
||||
from haystack.core.component.component import Component
|
||||
from haystack.core.pipeline.base import component_from_dict, component_to_dict
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def converter():
|
||||
converter = MultiFileConverter()
|
||||
converter.warm_up()
|
||||
return converter
|
||||
|
||||
|
||||
class TestMultiFileConverter:
|
||||
def test_init_default_params(self, converter):
|
||||
"""Test initialization with default parameters"""
|
||||
assert converter.encoding == "utf-8"
|
||||
assert converter.json_content_key == "content"
|
||||
assert isinstance(converter, Component)
|
||||
|
||||
def test_init_custom_params(self):
|
||||
"""Test initialization with custom parameters"""
|
||||
converter = MultiFileConverter(encoding="latin-1", json_content_key="text")
|
||||
assert converter.encoding == "latin-1"
|
||||
assert converter.json_content_key == "text"
|
||||
|
||||
def test_to_dict(self, converter):
|
||||
"""Test serialization to dictionary"""
|
||||
data = component_to_dict(converter, "converter")
|
||||
assert data == {
|
||||
"type": "haystack.components.converters.multi_file_converter.MultiFileConverter",
|
||||
"init_parameters": {"encoding": "utf-8", "json_content_key": "content"},
|
||||
}
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test deserialization from dictionary"""
|
||||
data = {
|
||||
"type": "haystack.components.converters.multi_file_converter.MultiFileConverter",
|
||||
"init_parameters": {"encoding": "latin-1", "json_content_key": "text"},
|
||||
}
|
||||
conv = component_from_dict(MultiFileConverter, data, "converter")
|
||||
assert conv.encoding == "latin-1"
|
||||
assert conv.json_content_key == "text"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"suffix,file_path",
|
||||
[
|
||||
("csv", "csv/sample_1.csv"),
|
||||
("docx", "docx/sample_docx.docx"),
|
||||
("html", "html/what_is_haystack.html"),
|
||||
("json", "json/json_conversion_testfile.json"),
|
||||
("md", "markdown/sample.md"),
|
||||
("pdf", "pdf/sample_pdf_1.pdf"),
|
||||
("pptx", "pptx/sample_pptx.pptx"),
|
||||
("txt", "txt/doc_1.txt"),
|
||||
("xlsx", "xlsx/table_empty_rows_and_columns.xlsx"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.integration
|
||||
def test_run(self, test_files_path, converter, suffix, file_path):
|
||||
unclassified_bytestream = ByteStream(b"unclassified content")
|
||||
unclassified_bytestream.meta["content_type"] = "unknown_type"
|
||||
|
||||
paths = [test_files_path / file_path, unclassified_bytestream]
|
||||
|
||||
output = converter.run(sources=paths)
|
||||
docs = output["documents"]
|
||||
unclassified = output["unclassified"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert isinstance(docs[0], Document)
|
||||
assert docs[0].content is not None
|
||||
assert docs[0].meta["file_path"].endswith(suffix)
|
||||
|
||||
assert len(unclassified) == 1
|
||||
assert isinstance(unclassified[0], ByteStream)
|
||||
assert unclassified[0].meta["content_type"] == "unknown_type"
|
||||
|
||||
def test_run_with_meta(self, test_files_path, converter):
|
||||
"""Test conversion with metadata"""
|
||||
paths = [test_files_path / "txt" / "doc_1.txt"]
|
||||
meta = {"language": "en", "author": "test"}
|
||||
output = converter.run(sources=paths, meta=meta)
|
||||
docs = output["documents"]
|
||||
assert docs[0].meta["language"] == "en"
|
||||
assert docs[0].meta["author"] == "test"
|
||||
|
||||
def test_run_with_bytestream(self, converter):
|
||||
"""Test converting ByteStream input"""
|
||||
bytestream = ByteStream(data=b"test content", mime_type="text/plain", meta={"file_path": "test.txt"})
|
||||
output = converter.run(sources=[bytestream])
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 1
|
||||
assert docs[0].content == "test content"
|
||||
assert docs[0].meta["file_path"] == "test.txt"
|
||||
|
||||
def test_run_error_handling(self, test_files_path, converter, caplog):
|
||||
"""Test error handling for non-existent files"""
|
||||
paths = [test_files_path / "non_existent.txt"]
|
||||
with caplog.at_level("WARNING"):
|
||||
output = converter.run(sources=paths)
|
||||
assert "File not found" in caplog.text
|
||||
assert len(output["failed"]) == 1
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_run_all_file_types(self, test_files_path, converter):
|
||||
"""Test converting all supported file types in parallel"""
|
||||
paths = [
|
||||
test_files_path / "csv" / "sample_1.csv",
|
||||
test_files_path / "docx" / "sample_docx.docx",
|
||||
test_files_path / "html" / "what_is_haystack.html",
|
||||
test_files_path / "json" / "json_conversion_testfile.json",
|
||||
test_files_path / "markdown" / "sample.md",
|
||||
test_files_path / "txt" / "doc_1.txt",
|
||||
test_files_path / "pdf" / "sample_pdf_1.pdf",
|
||||
test_files_path / "pptx" / "sample_pptx.pptx",
|
||||
test_files_path / "xlsx" / "table_empty_rows_and_columns.xlsx",
|
||||
]
|
||||
output = converter.run(sources=paths)
|
||||
docs = output["documents"]
|
||||
|
||||
# Verify we got a document for each file
|
||||
assert len(docs) == len(paths)
|
||||
assert all(isinstance(doc, Document) for doc in docs)
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_run_in_pipeline(self, test_files_path, converter):
|
||||
pipeline = Pipeline(max_runs_per_component=1)
|
||||
pipeline.add_component("converter", converter)
|
||||
|
||||
paths = [test_files_path / "txt" / "doc_1.txt", test_files_path / "pdf" / "sample_pdf_1.pdf"]
|
||||
|
||||
output = pipeline.run(data={"sources": paths})
|
||||
docs = output["converter"]["documents"]
|
||||
|
||||
assert len(docs) == 2
|
||||
assert all(isinstance(doc, Document) for doc in docs)
|
||||
assert all(doc.content is not None for doc in docs)
|
||||
@@ -0,0 +1,219 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import json
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack import Pipeline, component
|
||||
from haystack.components.converters import OutputAdapter
|
||||
from haystack.components.converters.output_adapter import OutputAdaptationException
|
||||
from haystack.core.component.sockets import InputSocket
|
||||
from haystack.dataclasses import Document
|
||||
|
||||
|
||||
def custom_filter_to_sede(value):
|
||||
return value.upper()
|
||||
|
||||
|
||||
def another_custom_filter(value):
|
||||
return value.upper()
|
||||
|
||||
|
||||
class TestOutputAdapter:
|
||||
# OutputAdapter can be initialized with a valid Jinja2 template string and output type.
|
||||
def test_initialized_with_valid_template_and_output_type(self):
|
||||
template = "{{ documents[0].content }}"
|
||||
output_type = str
|
||||
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
|
||||
|
||||
assert adapter.template == template
|
||||
assert adapter.__haystack_output__.output.name == "output"
|
||||
assert adapter.__haystack_output__.output.type == output_type
|
||||
|
||||
# OutputAdapter can adapt the output of one component to be compatible with the input of another
|
||||
# component using Jinja2 template expressions.
|
||||
def test_output_adaptation(self):
|
||||
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
|
||||
|
||||
input_data = {"documents": [{"content": "Test content"}]}
|
||||
expected_output = {"output": "Test content"}
|
||||
|
||||
assert adapter.run(**input_data) == expected_output
|
||||
|
||||
# OutputAdapter can add filter 'json_loads' and use it
|
||||
def test_predefined_filters(self):
|
||||
adapter = OutputAdapter(
|
||||
template="{{ documents[0].content|json_loads }}",
|
||||
output_type=dict,
|
||||
custom_filters={"json_loads": lambda s: json.loads(str(s))},
|
||||
)
|
||||
|
||||
input_data = {"documents": [{"content": '{"key": "value"}'}]}
|
||||
expected_output = {"output": {"key": "value"}}
|
||||
|
||||
assert adapter.run(**input_data) == expected_output
|
||||
|
||||
# OutputAdapter can handle custom filters provided in the component configuration.
|
||||
def test_custom_filters(self):
|
||||
def custom_filter(value):
|
||||
return value.upper()
|
||||
|
||||
custom_filters = {"custom_filter": custom_filter}
|
||||
adapter = OutputAdapter(
|
||||
template="{{ documents[0].content|custom_filter }}", output_type=str, custom_filters=custom_filters
|
||||
)
|
||||
|
||||
input_data = {"documents": [{"content": "test content"}]}
|
||||
expected_output = {"output": "TEST CONTENT"}
|
||||
|
||||
assert adapter.run(**input_data) == expected_output
|
||||
|
||||
# OutputAdapter raises an exception on init if the Jinja2 template string is invalid.
|
||||
def test_invalid_template_string(self):
|
||||
with pytest.raises(ValueError):
|
||||
OutputAdapter(template="{{ documents[0].content }", output_type=str)
|
||||
|
||||
# OutputAdapter raises an exception if no input data is provided for output adaptation.
|
||||
def test_no_input_data_provided(self):
|
||||
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
|
||||
with pytest.raises(ValueError):
|
||||
adapter.run()
|
||||
|
||||
# OutputAdapter raises an exception if there's an error during the adaptation process.
|
||||
def test_error_during_adaptation(self):
|
||||
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
|
||||
input_data = {"documents": [{"title": "Test title"}]}
|
||||
|
||||
with pytest.raises(OutputAdaptationException):
|
||||
adapter.run(**input_data)
|
||||
|
||||
# OutputAdapter can be serialized to a dictionary and deserialized back to an OutputAdapter instance.
|
||||
def test_sede(self):
|
||||
adapter = OutputAdapter(template="{{ documents[0].content }}", output_type=str)
|
||||
adapter_dict = adapter.to_dict()
|
||||
deserialized_adapter = OutputAdapter.from_dict(adapter_dict)
|
||||
|
||||
assert adapter.template == deserialized_adapter.template
|
||||
assert adapter.output_type == deserialized_adapter.output_type
|
||||
|
||||
# OutputAdapter can be serialized to a dictionary and deserialized along with custom filters
|
||||
def test_sede_with_custom_filters(self):
|
||||
# NOTE: filters need to be declared in a namespace visible to the deserialization function
|
||||
custom_filters = {"custom_filter": custom_filter_to_sede}
|
||||
adapter = OutputAdapter(
|
||||
template="{{ documents[0].content|custom_filter }}", output_type=str, custom_filters=custom_filters
|
||||
)
|
||||
adapter_dict = adapter.to_dict()
|
||||
deserialized_adapter = OutputAdapter.from_dict(adapter_dict)
|
||||
|
||||
assert adapter.template == deserialized_adapter.template
|
||||
assert adapter.output_type == deserialized_adapter.output_type
|
||||
assert adapter.custom_filters == deserialized_adapter.custom_filters == custom_filters
|
||||
|
||||
# invoke the custom filter to check if it is deserialized correctly
|
||||
assert deserialized_adapter.custom_filters["custom_filter"]("test") == "TEST"
|
||||
|
||||
# OutputAdapter can be serialized to a dictionary and deserialized along with multiple custom filters
|
||||
def test_sede_with_multiple_custom_filters(self):
|
||||
# NOTE: filters need to be declared in a namespace visible to the deserialization function
|
||||
custom_filters = {"custom_filter": custom_filter_to_sede, "another_custom_filter": another_custom_filter}
|
||||
adapter = OutputAdapter(
|
||||
template="{{ documents[0].content|custom_filter }}", output_type=str, custom_filters=custom_filters
|
||||
)
|
||||
adapter_dict = adapter.to_dict()
|
||||
deserialized_adapter = OutputAdapter.from_dict(adapter_dict)
|
||||
|
||||
assert adapter.template == deserialized_adapter.template
|
||||
assert adapter.output_type == deserialized_adapter.output_type
|
||||
assert adapter.custom_filters == deserialized_adapter.custom_filters == custom_filters
|
||||
|
||||
# invoke the custom filter to check if it is deserialized correctly
|
||||
assert deserialized_adapter.custom_filters["custom_filter"]("test") == "TEST"
|
||||
|
||||
def test_sede_with_list_output_type_in_pipeline(self):
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("adapter", OutputAdapter(template="{{ test }}", output_type=list[str]))
|
||||
serialized_pipe = pipe.dumps()
|
||||
|
||||
# we serialize the pipeline and check if the output type is serialized correctly (as list[str])
|
||||
assert "list[str]" in serialized_pipe
|
||||
|
||||
deserialized_pipe = Pipeline.loads(serialized_pipe)
|
||||
assert deserialized_pipe.get_component("adapter").output_type == list[str]
|
||||
|
||||
def test_sede_with_typing_list_output_type_in_pipeline(self):
|
||||
pipe = Pipeline()
|
||||
pipe.add_component("adapter", OutputAdapter(template="{{ test }}", output_type=List[str]))
|
||||
serialized_pipe = pipe.dumps()
|
||||
|
||||
# we serialize the pipeline and check if the output type is serialized correctly (as list[str])
|
||||
assert "typing.List[str]" in serialized_pipe
|
||||
|
||||
deserialized_pipe = Pipeline.loads(serialized_pipe)
|
||||
assert deserialized_pipe.get_component("adapter").output_type == List[str]
|
||||
|
||||
def test_output_adapter_from_dict_custom_filters_none(self):
|
||||
component = OutputAdapter.from_dict(
|
||||
data={
|
||||
"type": "haystack.components.converters.output_adapter.OutputAdapter",
|
||||
"init_parameters": {
|
||||
"template": "{{ documents[0].content}}",
|
||||
"output_type": "str",
|
||||
"custom_filters": None,
|
||||
"unsafe": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert component.template == "{{ documents[0].content}}"
|
||||
assert component.output_type == str
|
||||
assert component.custom_filters == {}
|
||||
assert not component._unsafe
|
||||
|
||||
def test_output_adapter_in_pipeline(self):
|
||||
@component
|
||||
class DocumentProducer:
|
||||
@component.output_types(documents=dict)
|
||||
def run(self):
|
||||
return {"documents": [{"content": '{"framework": "Haystack"}'}]}
|
||||
|
||||
pipe = Pipeline()
|
||||
pipe.add_component(
|
||||
name="output_adapter",
|
||||
instance=OutputAdapter(
|
||||
template="{{ documents[0].content | json_loads}}",
|
||||
output_type=str,
|
||||
custom_filters={"json_loads": lambda s: json.loads(str(s))},
|
||||
),
|
||||
)
|
||||
pipe.add_component(name="document_producer", instance=DocumentProducer())
|
||||
pipe.connect("document_producer", "output_adapter")
|
||||
result = pipe.run(data={})
|
||||
assert result
|
||||
assert result["output_adapter"]["output"] == {"framework": "Haystack"}
|
||||
|
||||
def test_unsafe(self):
|
||||
adapter = OutputAdapter(template="{{ documents[0] }}", output_type=Document, unsafe=True)
|
||||
documents = [
|
||||
Document(content="Test document"),
|
||||
Document(content="Another test document"),
|
||||
Document(content="Yet another test document"),
|
||||
]
|
||||
res = adapter.run(documents=documents)
|
||||
assert res["output"] == documents[0]
|
||||
|
||||
def test_variables_correct_with_assignment(self) -> None:
|
||||
template = """{% if control == 'something' %}
|
||||
{% set output = 1 %}
|
||||
{% else %}
|
||||
{% set output = 3 %}
|
||||
{% endif %}
|
||||
{{ output }}
|
||||
"""
|
||||
adapter = OutputAdapter(template=template, output_type=int)
|
||||
assert adapter.__haystack_input__._sockets_dict == {"control": InputSocket(name="control", type=Any)}
|
||||
res = adapter.run(control="something")
|
||||
assert res["output"] == 1
|
||||
@@ -0,0 +1,242 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack import Document
|
||||
from haystack.components.converters.pdfminer import PDFMinerToDocument
|
||||
from haystack.components.preprocessors import DocumentSplitter
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class TestPDFMinerToDocument:
|
||||
def test_run(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly.
|
||||
"""
|
||||
converter = PDFMinerToDocument()
|
||||
sources = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
|
||||
results = converter.run(sources=sources)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
for doc in docs:
|
||||
assert "the page 3 is empty" in doc.content
|
||||
assert "Page 4 of Sample PDF" in doc.content
|
||||
|
||||
def test_init_params_custom(self, test_files_path):
|
||||
"""
|
||||
Test if init arguments are passed successfully to PDFMinerToDocument layout parameters
|
||||
"""
|
||||
converter = PDFMinerToDocument(char_margin=0.5, all_texts=True, store_full_path=False)
|
||||
assert converter.layout_params.char_margin == 0.5
|
||||
assert converter.layout_params.all_texts is True
|
||||
assert converter.store_full_path is False
|
||||
|
||||
def test_run_with_store_full_path_false(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly with store_full_path=False
|
||||
"""
|
||||
converter = PDFMinerToDocument(store_full_path=False)
|
||||
sources = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
|
||||
results = converter.run(sources=sources)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
for doc in docs:
|
||||
assert "the page 3 is empty" in doc.content
|
||||
assert "Page 4 of Sample PDF" in doc.content
|
||||
assert doc.meta["file_path"] == "sample_pdf_1.pdf"
|
||||
|
||||
def test_run_wrong_file_type(self, test_files_path, caplog):
|
||||
"""
|
||||
Test if the component runs correctly when an input file is not of the expected type.
|
||||
"""
|
||||
sources = [test_files_path / "audio" / "answer.wav"]
|
||||
converter = PDFMinerToDocument()
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
output = converter.run(sources=sources)
|
||||
assert "Is this really a PDF?" in caplog.text
|
||||
|
||||
docs = output["documents"]
|
||||
assert not docs
|
||||
|
||||
def test_arg_is_none(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly when an argument is None.
|
||||
"""
|
||||
converter = PDFMinerToDocument(char_margin=None)
|
||||
assert converter.layout_params.char_margin is None
|
||||
|
||||
def test_run_doc_metadata(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly when metadata is supplied by the user.
|
||||
"""
|
||||
converter = PDFMinerToDocument()
|
||||
sources = [test_files_path / "pdf" / "sample_pdf_2.pdf"]
|
||||
metadata = [{"file_name": "sample_pdf_2.pdf"}]
|
||||
results = converter.run(sources=sources, meta=metadata)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "Ward Cunningham" in docs[0].content
|
||||
assert docs[0].meta["file_name"] == "sample_pdf_2.pdf"
|
||||
|
||||
def test_incorrect_meta(self, test_files_path):
|
||||
"""
|
||||
Test if the component raises an error when incorrect metadata is supplied by the user.
|
||||
"""
|
||||
converter = PDFMinerToDocument()
|
||||
sources = [test_files_path / "pdf" / "sample_pdf_3.pdf"]
|
||||
metadata = [{"file_name": "sample_pdf_3.pdf"}, {"file_name": "sample_pdf_2.pdf"}]
|
||||
with pytest.raises(ValueError, match="The length of the metadata list must match the number of sources."):
|
||||
converter.run(sources=sources, meta=metadata)
|
||||
|
||||
def test_run_bytestream_metadata(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly when metadata is read from the ByteStream object.
|
||||
"""
|
||||
converter = PDFMinerToDocument()
|
||||
with open(test_files_path / "pdf" / "sample_pdf_2.pdf", "rb") as file:
|
||||
byte_stream = file.read()
|
||||
stream = ByteStream(byte_stream, meta={"content_type": "text/pdf", "url": "test_url"})
|
||||
|
||||
results = converter.run(sources=[stream])
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "Ward Cunningham" in docs[0].content
|
||||
assert docs[0].meta == {"content_type": "text/pdf", "url": "test_url"}
|
||||
|
||||
def test_run_bytestream_doc_overlapping_metadata(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly when metadata is read from the ByteStream object and supplied by the user.
|
||||
|
||||
There is an overlap between the metadata received.
|
||||
|
||||
The component should use the supplied metadata to overwrite the values if there is an overlap between the keys.
|
||||
"""
|
||||
converter = PDFMinerToDocument()
|
||||
with open(test_files_path / "pdf" / "sample_pdf_2.pdf", "rb") as file:
|
||||
byte_stream = file.read()
|
||||
# ByteStream has "url" present in metadata
|
||||
stream = ByteStream(byte_stream, meta={"content_type": "text/pdf", "url": "test_url_correct"})
|
||||
|
||||
# "url" supplied by the user overwrites value present in metadata
|
||||
metadata = [{"file_name": "sample_pdf_2.pdf", "url": "test_url_new"}]
|
||||
results = converter.run(sources=[stream], meta=metadata)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert "Ward Cunningham" in docs[0].content
|
||||
assert docs[0].meta == {"file_name": "sample_pdf_2.pdf", "content_type": "text/pdf", "url": "test_url_new"}
|
||||
|
||||
def test_run_error_handling(self, caplog):
|
||||
"""
|
||||
Test if the component correctly handles errors.
|
||||
"""
|
||||
sources = ["non_existing_file.pdf"]
|
||||
converter = PDFMinerToDocument()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
results = converter.run(sources=sources)
|
||||
assert "Could not read non_existing_file.pdf" in caplog.text
|
||||
assert results["documents"] == []
|
||||
|
||||
def test_run_empty_document(self, caplog, test_files_path):
|
||||
sources = [test_files_path / "pdf" / "non_text_searchable.pdf"]
|
||||
converter = PDFMinerToDocument()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
results = converter.run(sources=sources)
|
||||
assert "PDFMinerToDocument could not extract text from the file" in caplog.text
|
||||
assert results["documents"][0].content == ""
|
||||
|
||||
# Check that not only content is used when the returned document is initialized and doc id is generated
|
||||
assert results["documents"][0].meta["file_path"] == "non_text_searchable.pdf"
|
||||
assert results["documents"][0].id != Document(content="").id
|
||||
|
||||
def test_run_detect_pages_and_split_by_passage(self, test_files_path):
|
||||
converter = PDFMinerToDocument()
|
||||
sources = [test_files_path / "pdf" / "sample_pdf_2.pdf"]
|
||||
pdf_doc = converter.run(sources=sources)
|
||||
splitter = DocumentSplitter(split_length=1, split_by="page")
|
||||
docs = splitter.run(pdf_doc["documents"])
|
||||
assert len(docs["documents"]) == 4
|
||||
|
||||
def test_run_detect_paragraphs_to_be_used_in_split_passage(self, test_files_path):
|
||||
converter = PDFMinerToDocument()
|
||||
sources = [test_files_path / "pdf" / "sample_pdf_2.pdf"]
|
||||
pdf_doc = converter.run(sources=sources)
|
||||
splitter = DocumentSplitter(split_length=1, split_by="passage")
|
||||
docs = splitter.run(pdf_doc["documents"])
|
||||
|
||||
assert len(docs["documents"]) == 29
|
||||
|
||||
expected = (
|
||||
"\nA wiki (/ˈwɪki/ (About this soundlisten) WIK-ee) is a hypertext publication collaboratively"
|
||||
" \nedited and managed by its own audience directly using a web browser. A typical wiki \ncontains "
|
||||
"multiple pages for the subjects or scope of the project and may be either open \nto the public or "
|
||||
"limited to use within an organization for maintaining its internal knowledge \nbase. Wikis are "
|
||||
"enabled by wiki software, otherwise known as wiki engines. A wiki engine, \nbeing a form of a "
|
||||
"content management system, differs from other web-based systems \nsuch as blog software, in that "
|
||||
"the content is created without any defined owner or leader, \nand wikis have little inherent "
|
||||
"structure, allowing structure to emerge according to the \nneeds of the users.[1] \n\n"
|
||||
)
|
||||
assert docs["documents"][6].content == expected
|
||||
|
||||
def test_detect_undecoded_cid_characters(self):
|
||||
"""
|
||||
Test if the component correctly detects and reports undecoded CID characters in text.
|
||||
"""
|
||||
converter = PDFMinerToDocument()
|
||||
|
||||
# Test text with no CID characters
|
||||
text = "This is a normal text without any CID characters."
|
||||
result = converter.detect_undecoded_cid_characters(text)
|
||||
assert result["total_chars"] == len(text)
|
||||
assert result["cid_chars"] == 0
|
||||
assert result["percentage"] == 0
|
||||
|
||||
# Test text with CID characters
|
||||
text = "Some text with (cid:123) and (cid:456) characters"
|
||||
result = converter.detect_undecoded_cid_characters(text)
|
||||
assert result["total_chars"] == len(text)
|
||||
assert result["cid_chars"] == len("(cid:123)") + len("(cid:456)") # 18 characters total
|
||||
assert result["percentage"] == round((18 / len(text)) * 100, 2)
|
||||
|
||||
# Test text with multiple consecutive CID characters
|
||||
text = "(cid:123)(cid:456)(cid:789)"
|
||||
result = converter.detect_undecoded_cid_characters(text)
|
||||
assert result["total_chars"] == len(text)
|
||||
assert result["cid_chars"] == len("(cid:123)(cid:456)(cid:789)")
|
||||
assert result["percentage"] == 100.0
|
||||
|
||||
# Test empty text
|
||||
text = ""
|
||||
result = converter.detect_undecoded_cid_characters(text)
|
||||
assert result["total_chars"] == 0
|
||||
assert result["cid_chars"] == 0
|
||||
assert result["percentage"] == 0
|
||||
|
||||
def test_pdfminer_logs_warning_for_cid_characters(self, caplog, monkeypatch):
|
||||
"""
|
||||
Test if the component correctly logs a warning when undecoded CID characters are detected.
|
||||
"""
|
||||
test_data = ByteStream(data=b"fake", meta={"file_path": "test.pdf"})
|
||||
|
||||
def mock_converter(*args, **kwargs):
|
||||
return "This is text with (cid:123) and (cid:456) characters"
|
||||
|
||||
def mock_extract_pages(*args, **kwargs):
|
||||
return ["mocked page"]
|
||||
|
||||
with patch("haystack.components.converters.pdfminer.extract_pages", side_effect=mock_extract_pages):
|
||||
with patch.object(PDFMinerToDocument, "_converter", side_effect=mock_converter):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
converter = PDFMinerToDocument()
|
||||
converter.run(sources=[test_data])
|
||||
assert "Detected 18 undecoded CID characters in 52 characters (34.62%)" in caplog.text
|
||||
@@ -0,0 +1,123 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.converters.pptx import PPTXToDocument
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class TestPPTXToDocument:
|
||||
def test_run(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly.
|
||||
"""
|
||||
bytestream = ByteStream.from_file_path(test_files_path / "pptx" / "sample_pptx.pptx")
|
||||
bytestream.meta["file_path"] = str(test_files_path / "pptx" / "sample_pptx.pptx")
|
||||
bytestream.meta["key"] = "value"
|
||||
files = [str(test_files_path / "pptx" / "sample_pptx.pptx"), bytestream]
|
||||
converter = PPTXToDocument()
|
||||
output = converter.run(sources=files)
|
||||
docs = output["documents"]
|
||||
|
||||
assert len(docs) == 2
|
||||
assert (
|
||||
"Sample Title Slide\nJane Doe\fTitle of First Slide\nThis is a bullet point\nThis is another bullet point"
|
||||
in docs[0].content
|
||||
)
|
||||
assert (
|
||||
"Sample Title Slide\nJane Doe\fTitle of First Slide\nThis is a bullet point\nThis is another bullet point"
|
||||
in docs[0].content
|
||||
)
|
||||
assert docs[0].meta["file_path"] == os.path.basename(files[0])
|
||||
assert docs[1].meta == {"file_path": os.path.basename(bytestream.meta["file_path"]), "key": "value"}
|
||||
|
||||
def test_run_error_non_existent_file(self, caplog):
|
||||
sources = ["non_existing_file.pptx"]
|
||||
converter = PPTXToDocument()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
results = converter.run(sources=sources)
|
||||
assert "Could not read non_existing_file.pptx" in caplog.text
|
||||
assert results["documents"] == []
|
||||
|
||||
def test_run_error_wrong_file_type(self, caplog, test_files_path):
|
||||
sources = [str(test_files_path / "txt" / "doc_1.txt")]
|
||||
converter = PPTXToDocument()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
results = converter.run(sources=sources)
|
||||
assert "doc_1.txt and convert it" in caplog.text
|
||||
assert results["documents"] == []
|
||||
|
||||
def test_run_with_meta(self, test_files_path):
|
||||
bytestream = ByteStream.from_file_path(test_files_path / "pptx" / "sample_pptx.pptx")
|
||||
bytestream.meta["file_path"] = str(test_files_path / "pptx" / "sample_pptx.pptx")
|
||||
bytestream.meta["key"] = "value"
|
||||
|
||||
converter = PPTXToDocument()
|
||||
output = converter.run(sources=[bytestream], meta=[{"language": "it"}])
|
||||
document = output["documents"][0]
|
||||
|
||||
assert document.meta == {
|
||||
"file_path": os.path.basename(test_files_path / "pptx" / "sample_pptx.pptx"),
|
||||
"key": "value",
|
||||
"language": "it",
|
||||
}
|
||||
|
||||
def test_run_with_store_full_path_false(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly with store_full_path=False
|
||||
"""
|
||||
bytestream = ByteStream.from_file_path(test_files_path / "pptx" / "sample_pptx.pptx")
|
||||
bytestream.meta["file_path"] = str(test_files_path / "pptx" / "sample_pptx.pptx")
|
||||
bytestream.meta["key"] = "value"
|
||||
|
||||
converter = PPTXToDocument(store_full_path=False)
|
||||
output = converter.run(sources=[bytestream], meta=[{"language": "it"}])
|
||||
document = output["documents"][0]
|
||||
|
||||
assert document.meta == {"file_path": "sample_pptx.pptx", "key": "value", "language": "it"}
|
||||
|
||||
def test_to_dict(self):
|
||||
converter = PPTXToDocument(link_format="markdown", store_full_path=True)
|
||||
data = converter.to_dict()
|
||||
assert data == {
|
||||
"type": "haystack.components.converters.pptx.PPTXToDocument",
|
||||
"init_parameters": {"link_format": "markdown", "store_full_path": True},
|
||||
}
|
||||
|
||||
def test_to_dict_defaults(self):
|
||||
converter = PPTXToDocument()
|
||||
data = converter.to_dict()
|
||||
assert data == {
|
||||
"type": "haystack.components.converters.pptx.PPTXToDocument",
|
||||
"init_parameters": {"link_format": "none", "store_full_path": False},
|
||||
}
|
||||
|
||||
def test_link_format_invalid(self):
|
||||
with pytest.raises(ValueError, match="Unknown link format"):
|
||||
PPTXToDocument(link_format="invalid")
|
||||
|
||||
@pytest.mark.parametrize("link_format", ["markdown", "plain"])
|
||||
def test_link_extraction(self, test_files_path, link_format):
|
||||
converter = PPTXToDocument(link_format=link_format)
|
||||
paths = [test_files_path / "pptx" / "sample_pptx_with_link.pptx"]
|
||||
output = converter.run(sources=paths)
|
||||
content = output["documents"][0].content
|
||||
|
||||
if link_format == "markdown":
|
||||
assert "[Example](https://example.com)" in content
|
||||
else:
|
||||
assert "Example (https://example.com)" in content
|
||||
|
||||
def test_no_link_extraction(self, test_files_path):
|
||||
converter = PPTXToDocument()
|
||||
paths = [test_files_path / "pptx" / "sample_pptx_with_link.pptx"]
|
||||
output = converter.run(sources=paths)
|
||||
content = output["documents"][0].content
|
||||
|
||||
assert "https://example.com" not in content
|
||||
assert "Example" in content
|
||||
@@ -0,0 +1,238 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import logging
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack import Document
|
||||
from haystack.components.converters.pypdf import PyPDFExtractionMode, PyPDFToDocument
|
||||
from haystack.components.preprocessors import DocumentSplitter
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pypdf_component():
|
||||
return PyPDFToDocument()
|
||||
|
||||
|
||||
class TestPyPDFToDocument:
|
||||
def test_init(self, pypdf_component):
|
||||
assert pypdf_component.extraction_mode == PyPDFExtractionMode.PLAIN
|
||||
assert pypdf_component.plain_mode_orientations == (0, 90, 180, 270)
|
||||
assert pypdf_component.plain_mode_space_width == 200.0
|
||||
assert pypdf_component.layout_mode_space_vertically is True
|
||||
assert pypdf_component.layout_mode_scale_weight == 1.25
|
||||
assert pypdf_component.layout_mode_strip_rotated is True
|
||||
assert pypdf_component.layout_mode_font_height_weight == 1.0
|
||||
|
||||
def test_init_custom_params(self):
|
||||
pypdf_component = PyPDFToDocument(
|
||||
extraction_mode="layout",
|
||||
plain_mode_orientations=(0, 90),
|
||||
plain_mode_space_width=150.0,
|
||||
layout_mode_space_vertically=False,
|
||||
layout_mode_scale_weight=2.0,
|
||||
layout_mode_strip_rotated=False,
|
||||
layout_mode_font_height_weight=0.5,
|
||||
)
|
||||
|
||||
assert pypdf_component.extraction_mode == PyPDFExtractionMode.LAYOUT
|
||||
assert pypdf_component.plain_mode_orientations == (0, 90)
|
||||
assert pypdf_component.plain_mode_space_width == 150.0
|
||||
assert pypdf_component.layout_mode_space_vertically is False
|
||||
assert pypdf_component.layout_mode_scale_weight == 2.0
|
||||
assert pypdf_component.layout_mode_strip_rotated is False
|
||||
assert pypdf_component.layout_mode_font_height_weight == 0.5
|
||||
|
||||
def test_init_invalid_extraction_mode(self):
|
||||
with pytest.raises(ValueError):
|
||||
PyPDFToDocument(extraction_mode="invalid")
|
||||
|
||||
def test_to_dict(self, pypdf_component):
|
||||
data = pypdf_component.to_dict()
|
||||
assert data == {
|
||||
"type": "haystack.components.converters.pypdf.PyPDFToDocument",
|
||||
"init_parameters": {
|
||||
"extraction_mode": "plain",
|
||||
"plain_mode_orientations": (0, 90, 180, 270),
|
||||
"plain_mode_space_width": 200.0,
|
||||
"layout_mode_space_vertically": True,
|
||||
"layout_mode_scale_weight": 1.25,
|
||||
"layout_mode_strip_rotated": True,
|
||||
"layout_mode_font_height_weight": 1.0,
|
||||
"store_full_path": False,
|
||||
},
|
||||
}
|
||||
|
||||
def test_from_dict(self):
|
||||
data = {
|
||||
"type": "haystack.components.converters.pypdf.PyPDFToDocument",
|
||||
"init_parameters": {
|
||||
"extraction_mode": "plain",
|
||||
"plain_mode_orientations": (0, 90, 180, 270),
|
||||
"plain_mode_space_width": 200.0,
|
||||
"layout_mode_space_vertically": True,
|
||||
"layout_mode_scale_weight": 1.25,
|
||||
"layout_mode_strip_rotated": True,
|
||||
"layout_mode_font_height_weight": 1.0,
|
||||
},
|
||||
}
|
||||
|
||||
instance = PyPDFToDocument.from_dict(data)
|
||||
assert isinstance(instance, PyPDFToDocument)
|
||||
assert instance.extraction_mode == PyPDFExtractionMode.PLAIN
|
||||
assert instance.plain_mode_orientations == (0, 90, 180, 270)
|
||||
assert instance.plain_mode_space_width == 200.0
|
||||
assert instance.layout_mode_space_vertically is True
|
||||
assert instance.layout_mode_scale_weight == 1.25
|
||||
assert instance.layout_mode_strip_rotated is True
|
||||
assert instance.layout_mode_font_height_weight == 1.0
|
||||
|
||||
def test_from_dict_defaults(self):
|
||||
data = {"type": "haystack.components.converters.pypdf.PyPDFToDocument", "init_parameters": {}}
|
||||
instance = PyPDFToDocument.from_dict(data)
|
||||
assert isinstance(instance, PyPDFToDocument)
|
||||
assert instance.extraction_mode == PyPDFExtractionMode.PLAIN
|
||||
|
||||
def test_default_convert(self):
|
||||
mock_page1 = Mock()
|
||||
mock_page2 = Mock()
|
||||
mock_page1.extract_text.return_value = "Page 1 content"
|
||||
mock_page2.extract_text.return_value = "Page 2 content"
|
||||
mock_reader = Mock()
|
||||
mock_reader.pages = [mock_page1, mock_page2]
|
||||
|
||||
converter = PyPDFToDocument(
|
||||
extraction_mode="layout",
|
||||
plain_mode_orientations=(0, 90),
|
||||
plain_mode_space_width=150.0,
|
||||
layout_mode_space_vertically=False,
|
||||
layout_mode_scale_weight=2.0,
|
||||
layout_mode_strip_rotated=False,
|
||||
layout_mode_font_height_weight=1.5,
|
||||
)
|
||||
|
||||
text = converter._default_convert(mock_reader)
|
||||
assert text == "Page 1 content\fPage 2 content"
|
||||
|
||||
expected_params = {
|
||||
"extraction_mode": "layout",
|
||||
"orientations": (0, 90),
|
||||
"space_width": 150.0,
|
||||
"layout_mode_space_vertically": False,
|
||||
"layout_mode_scale_weight": 2.0,
|
||||
"layout_mode_strip_rotated": False,
|
||||
"layout_mode_font_height_weight": 1.5,
|
||||
}
|
||||
for mock_page in mock_reader.pages:
|
||||
mock_page.extract_text.assert_called_once_with(**expected_params)
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_run(self, test_files_path, pypdf_component):
|
||||
"""
|
||||
Test if the component runs correctly.
|
||||
"""
|
||||
paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
|
||||
output = pypdf_component.run(sources=paths)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 1
|
||||
assert "History" in docs[0].content
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_page_breaks_added(self, test_files_path, pypdf_component):
|
||||
paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
|
||||
output = pypdf_component.run(sources=paths)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 1
|
||||
assert docs[0].content.count("\f") == 3
|
||||
|
||||
def test_run_with_meta(self, test_files_path, pypdf_component):
|
||||
bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
|
||||
|
||||
with patch("haystack.components.converters.pypdf.PdfReader"):
|
||||
output = pypdf_component.run(
|
||||
sources=[bytestream, test_files_path / "pdf" / "sample_pdf_1.pdf"], meta={"language": "it"}
|
||||
)
|
||||
|
||||
# check that the metadata from the bytestream is merged with that from the meta parameter
|
||||
assert output["documents"][0].meta["author"] == "test_author"
|
||||
assert output["documents"][0].meta["language"] == "it"
|
||||
assert output["documents"][1].meta["language"] == "it"
|
||||
|
||||
def test_run_with_store_full_path_false(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly with store_full_path=False
|
||||
"""
|
||||
sources = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
|
||||
converter = PyPDFToDocument(store_full_path=True)
|
||||
results = converter.run(sources=sources)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert docs[0].meta["file_path"] == str(sources[0])
|
||||
|
||||
converter = PyPDFToDocument(store_full_path=False)
|
||||
results = converter.run(sources=sources)
|
||||
docs = results["documents"]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert docs[0].meta["file_path"] == "sample_pdf_1.pdf"
|
||||
|
||||
def test_run_error_handling(self, test_files_path, pypdf_component, caplog):
|
||||
"""
|
||||
Test if the component correctly handles errors.
|
||||
"""
|
||||
paths = ["non_existing_file.pdf"]
|
||||
with caplog.at_level(logging.WARNING):
|
||||
pypdf_component.run(sources=paths)
|
||||
assert "Could not read non_existing_file.pdf" in caplog.text
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_mixed_sources_run(self, test_files_path, pypdf_component):
|
||||
"""
|
||||
Test if the component runs correctly when mixed sources are provided.
|
||||
"""
|
||||
paths = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
|
||||
with open(test_files_path / "pdf" / "sample_pdf_1.pdf", "rb") as f:
|
||||
paths.append(ByteStream(f.read()))
|
||||
|
||||
output = pypdf_component.run(sources=paths)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 2
|
||||
assert "History and standardization" in docs[0].content
|
||||
assert "History and standardization" in docs[1].content
|
||||
|
||||
def test_run_empty_document(self, caplog, test_files_path):
|
||||
paths = [test_files_path / "pdf" / "non_text_searchable.pdf"]
|
||||
with caplog.at_level(logging.WARNING):
|
||||
output = PyPDFToDocument().run(sources=paths)
|
||||
assert "PyPDFToDocument could not extract text from the file" in caplog.text
|
||||
assert output["documents"][0].content == ""
|
||||
|
||||
# Check that meta is used when the returned document is initialized and thus when doc id is generated
|
||||
assert output["documents"][0].meta["file_path"] == "non_text_searchable.pdf"
|
||||
assert output["documents"][0].id != Document(content="").id
|
||||
|
||||
def test_run_detect_paragraphs_to_be_used_in_split_passage(self, test_files_path):
|
||||
converter = PyPDFToDocument(extraction_mode=PyPDFExtractionMode.LAYOUT)
|
||||
sources = [test_files_path / "pdf" / "sample_pdf_2.pdf"]
|
||||
pdf_doc = converter.run(sources=sources)
|
||||
splitter = DocumentSplitter(split_length=1, split_by="passage")
|
||||
docs = splitter.run(pdf_doc["documents"])
|
||||
|
||||
assert len(docs["documents"]) == 51
|
||||
|
||||
expected = (
|
||||
"A wiki (/ˈwɪki/ (About this soundlisten) WIK-ee) is a hypertext publication collaboratively\n"
|
||||
"edited and managed by its own audience directly using a web browser. A typical wiki\ncontains "
|
||||
"multiple pages for the subjects or scope of the project and may be either open\nto the public or "
|
||||
"limited to use within an organization for maintaining its internal knowledge\nbase. Wikis are "
|
||||
"enabled by wiki software, otherwise known as wiki engines. A wiki engine,\nbeing a form of a "
|
||||
"content management system, differs from other web-based systems\nsuch as blog software, in that "
|
||||
"the content is created without any defined owner or leader,\nand wikis have little inherent "
|
||||
"structure, allowing structure to emerge according to the\nneeds of the users.[1]\n\n"
|
||||
)
|
||||
assert docs["documents"][2].content == expected
|
||||
@@ -0,0 +1,87 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from haystack.components.converters.txt import TextFileToDocument
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
class TestTextfileToDocument:
|
||||
def test_run(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly.
|
||||
"""
|
||||
bytestream = ByteStream.from_file_path(test_files_path / "txt" / "doc_3.txt")
|
||||
bytestream.meta["file_path"] = str(test_files_path / "txt" / "doc_3.txt")
|
||||
bytestream.meta["key"] = "value"
|
||||
files = [str(test_files_path / "txt" / "doc_1.txt"), test_files_path / "txt" / "doc_2.txt", bytestream]
|
||||
converter = TextFileToDocument()
|
||||
output = converter.run(sources=files)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 3
|
||||
assert "Some text for testing." in docs[0].content
|
||||
assert "This is a test line." in docs[1].content
|
||||
assert "That's yet another file!" in docs[2].content
|
||||
assert docs[0].meta["file_path"] == os.path.basename(files[0])
|
||||
assert docs[1].meta["file_path"] == os.path.basename(files[1])
|
||||
assert docs[2].meta == {"file_path": os.path.basename(bytestream.meta["file_path"]), "key": "value"}
|
||||
|
||||
def test_run_with_store_full_path(self, test_files_path):
|
||||
"""
|
||||
Test if the component runs correctly with store_full_path= False.
|
||||
"""
|
||||
bytestream = ByteStream.from_file_path(test_files_path / "txt" / "doc_3.txt")
|
||||
bytestream.meta["file_path"] = str(test_files_path / "txt" / "doc_3.txt")
|
||||
bytestream.meta["key"] = "value"
|
||||
files = [str(test_files_path / "txt" / "doc_1.txt"), bytestream]
|
||||
converter = TextFileToDocument(store_full_path=False)
|
||||
output = converter.run(sources=files)
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 2
|
||||
assert "Some text for testing." in docs[0].content
|
||||
assert "That's yet another file!" in docs[1].content
|
||||
assert docs[0].meta["file_path"] == "doc_1.txt"
|
||||
assert docs[1].meta["file_path"] == "doc_3.txt"
|
||||
|
||||
def test_run_error_handling(self, test_files_path, caplog):
|
||||
"""
|
||||
Test if the component correctly handles errors.
|
||||
"""
|
||||
paths = [test_files_path / "txt" / "doc_1.txt", "non_existing_file.txt", test_files_path / "txt" / "doc_3.txt"]
|
||||
converter = TextFileToDocument()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
output = converter.run(sources=paths)
|
||||
assert "non_existing_file.txt" in caplog.text
|
||||
docs = output["documents"]
|
||||
assert len(docs) == 2
|
||||
assert docs[0].meta["file_path"] == os.path.basename(paths[0])
|
||||
assert docs[1].meta["file_path"] == os.path.basename(paths[2])
|
||||
|
||||
def test_encoding_override(self, test_files_path):
|
||||
"""
|
||||
Test if the encoding metadata field is used properly
|
||||
"""
|
||||
bytestream = ByteStream.from_file_path(test_files_path / "txt" / "doc_1.txt")
|
||||
bytestream.meta["key"] = "value"
|
||||
|
||||
converter = TextFileToDocument(encoding="utf-16")
|
||||
output = converter.run(sources=[bytestream])
|
||||
assert "Some text for testing." not in output["documents"][0].content
|
||||
|
||||
bytestream.meta["encoding"] = "utf-8"
|
||||
output = converter.run(sources=[bytestream])
|
||||
assert "Some text for testing." in output["documents"][0].content
|
||||
|
||||
def test_run_with_meta(self):
|
||||
bytestream = ByteStream(data=b"test", meta={"author": "test_author", "language": "en"})
|
||||
|
||||
converter = TextFileToDocument()
|
||||
|
||||
output = converter.run(sources=[bytestream], meta=[{"language": "it"}])
|
||||
document = output["documents"][0]
|
||||
|
||||
# check that the metadata from the bytestream is merged with that from the meta parameter
|
||||
assert document.meta == {"author": "test_author", "language": "it"}
|
||||
@@ -0,0 +1,72 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.converters.utils import get_bytestream_from_source, normalize_metadata
|
||||
from haystack.dataclasses import ByteStream
|
||||
|
||||
|
||||
def test_normalize_metadata_None():
|
||||
assert normalize_metadata(None, sources_count=1) == [{}]
|
||||
assert normalize_metadata(None, sources_count=3) == [{}, {}, {}]
|
||||
|
||||
|
||||
def test_normalize_metadata_single_dict():
|
||||
assert normalize_metadata({"a": 1}, sources_count=1) == [{"a": 1}]
|
||||
assert normalize_metadata({"a": 1}, sources_count=3) == [{"a": 1}, {"a": 1}, {"a": 1}]
|
||||
|
||||
|
||||
def test_normalize_metadata_list_of_right_size():
|
||||
assert normalize_metadata([{"a": 1}], sources_count=1) == [{"a": 1}]
|
||||
assert normalize_metadata([{"a": 1}, {"b": 2}, {"c": 3}], sources_count=3) == [{"a": 1}, {"b": 2}, {"c": 3}]
|
||||
|
||||
|
||||
def test_normalize_metadata_list_of_wrong_size():
|
||||
with pytest.raises(ValueError, match="The length of the metadata list must match the number of sources."):
|
||||
normalize_metadata([{"a": 1}], sources_count=3)
|
||||
with pytest.raises(ValueError, match="The length of the metadata list must match the number of sources."):
|
||||
assert normalize_metadata([{"a": 1}, {"b": 2}, {"c": 3}], sources_count=1)
|
||||
|
||||
|
||||
def test_normalize_metadata_other_type():
|
||||
with pytest.raises(ValueError, match="meta must be either None, a dictionary or a list of dictionaries."):
|
||||
normalize_metadata(({"a": 1},), sources_count=1)
|
||||
|
||||
|
||||
def test_get_bytestream_from_path_object(tmp_path):
|
||||
bytes_ = b"hello world"
|
||||
source = tmp_path / "test.txt"
|
||||
source.write_bytes(bytes_)
|
||||
|
||||
bs = get_bytestream_from_source(source, guess_mime_type=True)
|
||||
|
||||
assert isinstance(bs, ByteStream)
|
||||
assert bs.data == bytes_
|
||||
assert bs.mime_type == "text/plain"
|
||||
assert bs.meta["file_path"].endswith("test.txt")
|
||||
|
||||
|
||||
def test_get_bytestream_from_string_path(tmp_path):
|
||||
bytes_ = b"hello world"
|
||||
source = tmp_path / "test.txt"
|
||||
source.write_bytes(bytes_)
|
||||
|
||||
bs = get_bytestream_from_source(str(source), guess_mime_type=True)
|
||||
|
||||
assert isinstance(bs, ByteStream)
|
||||
assert bs.data == bytes_
|
||||
assert bs.mime_type == "text/plain"
|
||||
assert bs.meta["file_path"].endswith("test.txt")
|
||||
|
||||
|
||||
def test_get_bytestream_from_source_invalid_type():
|
||||
with pytest.raises(ValueError, match="Unsupported source type"):
|
||||
get_bytestream_from_source(123)
|
||||
|
||||
|
||||
def test_get_bytestream_from_source_bytestream_passthrough():
|
||||
bs = ByteStream(data=b"spam", mime_type="text/custom", meta={"spam": "eggs"})
|
||||
result = get_bytestream_from_source(bs)
|
||||
assert result is bs
|
||||
@@ -0,0 +1,170 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack.components.converters.xlsx import XLSXToDocument
|
||||
|
||||
|
||||
class TestXLSXToDocument:
|
||||
def test_init(self) -> None:
|
||||
converter = XLSXToDocument()
|
||||
assert converter.sheet_name is None
|
||||
assert converter.read_excel_kwargs == {}
|
||||
assert converter.table_format == "csv"
|
||||
assert converter.link_format == "none"
|
||||
assert converter.table_format_kwargs == {}
|
||||
|
||||
def test_run_basic_tables(self, test_files_path) -> None:
|
||||
converter = XLSXToDocument(store_full_path=True)
|
||||
paths = [test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"]
|
||||
results = converter.run(sources=paths, meta={"date_added": "2022-01-01T00:00:00"})
|
||||
documents = results["documents"]
|
||||
assert len(documents) == 2
|
||||
assert documents[0].content == ",A,B\n1,col_a,col_b\n2,1.5,test\n"
|
||||
assert documents[0].meta == {
|
||||
"date_added": "2022-01-01T00:00:00",
|
||||
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
|
||||
"xlsx": {"sheet_name": "Basic Table"},
|
||||
}
|
||||
assert documents[1].content == ",A,B\n1,col_c,col_d\n2,True,\n"
|
||||
assert documents[1].meta == {
|
||||
"date_added": "2022-01-01T00:00:00",
|
||||
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
|
||||
"xlsx": {"sheet_name": "Table Missing Value"},
|
||||
}
|
||||
|
||||
def test_run_table_empty_rows_and_columns(self, test_files_path) -> None:
|
||||
converter = XLSXToDocument(store_full_path=False)
|
||||
paths = [test_files_path / "xlsx" / "table_empty_rows_and_columns.xlsx"]
|
||||
results = converter.run(sources=paths, meta={"date_added": "2022-01-01T00:00:00"})
|
||||
documents = results["documents"]
|
||||
assert len(documents) == 1
|
||||
assert documents[0].content == ",A,B,C\n1,,,\n2,,,\n3,,,\n4,,col_a,col_b\n5,,1.5,test\n"
|
||||
assert documents[0].meta == {
|
||||
"date_added": "2022-01-01T00:00:00",
|
||||
"file_path": "table_empty_rows_and_columns.xlsx",
|
||||
"xlsx": {"sheet_name": "Sheet1"},
|
||||
}
|
||||
|
||||
def test_run_multiple_tables_in_one_sheet(self, test_files_path) -> None:
|
||||
converter = XLSXToDocument(store_full_path=True)
|
||||
paths = [test_files_path / "xlsx" / "multiple_tables.xlsx"]
|
||||
results = converter.run(sources=paths, meta={"date_added": "2022-01-01T00:00:00"})
|
||||
documents = results["documents"]
|
||||
assert len(documents) == 1
|
||||
assert (
|
||||
documents[0].content
|
||||
== ",A,B,C,D,E,F\n1,,,,,,\n2,,,,,,\n3,,col_a,col_b,,,\n4,,1.5,test,,col_c,col_d\n5,,,,,3,True\n"
|
||||
)
|
||||
assert documents[0].meta == {
|
||||
"date_added": "2022-01-01T00:00:00",
|
||||
"file_path": str(test_files_path / "xlsx" / "multiple_tables.xlsx"),
|
||||
"xlsx": {"sheet_name": "Sheet1"},
|
||||
}
|
||||
|
||||
def test_run_markdown(self, test_files_path) -> None:
|
||||
converter = XLSXToDocument(table_format="markdown", store_full_path=True)
|
||||
paths = [test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"]
|
||||
results = converter.run(sources=paths, meta={"date_added": "2022-01-01T00:00:00"})
|
||||
documents = results["documents"]
|
||||
assert len(documents) == 2
|
||||
assert (
|
||||
documents[0].content
|
||||
== "| | A | B |\n|---:|:------|:------|\n| 1 | col_a | col_b |\n| 2 | 1.5 | test |"
|
||||
)
|
||||
assert documents[0].meta == {
|
||||
"date_added": "2022-01-01T00:00:00",
|
||||
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
|
||||
"xlsx": {"sheet_name": "Basic Table"},
|
||||
}
|
||||
assert (
|
||||
documents[1].content
|
||||
== "| | A | B |\n|---:|:------|:------|\n| 1 | col_c | col_d |\n| 2 | True | nan |"
|
||||
)
|
||||
assert documents[1].meta == {
|
||||
"date_added": "2022-01-01T00:00:00",
|
||||
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
|
||||
"xlsx": {"sheet_name": "Table Missing Value"},
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sheet_name, expected_sheet_name, expected_content",
|
||||
[
|
||||
("Basic Table", "Basic Table", ",A,B\n1,col_a,col_b\n2,1.5,test\n"),
|
||||
("Table Missing Value", "Table Missing Value", ",A,B\n1,col_c,col_d\n2,True,\n"),
|
||||
(0, 0, ",A,B\n1,col_a,col_b\n2,1.5,test\n"),
|
||||
(1, 1, ",A,B\n1,col_c,col_d\n2,True,\n"),
|
||||
],
|
||||
)
|
||||
def test_run_sheet_name(
|
||||
self, sheet_name: int | str, expected_sheet_name: str, expected_content: str, test_files_path
|
||||
) -> None:
|
||||
converter = XLSXToDocument(sheet_name=sheet_name, store_full_path=True)
|
||||
paths = [test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"]
|
||||
results = converter.run(sources=paths)
|
||||
documents = results["documents"]
|
||||
assert len(documents) == 1
|
||||
assert documents[0].content == expected_content
|
||||
assert documents[0].meta == {
|
||||
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
|
||||
"xlsx": {"sheet_name": expected_sheet_name},
|
||||
}
|
||||
|
||||
def test_run_with_read_excel_kwargs(self, test_files_path) -> None:
|
||||
converter = XLSXToDocument(sheet_name="Basic Table", read_excel_kwargs={"skiprows": 1}, store_full_path=True)
|
||||
paths = [test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"]
|
||||
results = converter.run(sources=paths, meta={"date_added": "2022-01-01T00:00:00"})
|
||||
documents = results["documents"]
|
||||
assert len(documents) == 1
|
||||
assert documents[0].content == ",A,B\n1,1.5,test\n"
|
||||
assert documents[0].meta == {
|
||||
"date_added": "2022-01-01T00:00:00",
|
||||
"file_path": str(test_files_path / "xlsx" / "basic_tables_two_sheets.xlsx"),
|
||||
"xlsx": {"sheet_name": "Basic Table"},
|
||||
}
|
||||
|
||||
def test_run_error_wrong_file_type(self, caplog: pytest.LogCaptureFixture, test_files_path) -> None:
|
||||
converter = XLSXToDocument()
|
||||
sources = [test_files_path / "pdf" / "sample_pdf_1.pdf"]
|
||||
with caplog.at_level(logging.WARNING):
|
||||
results = converter.run(sources=sources)
|
||||
assert "sample_pdf_1.pdf and convert it" in caplog.text
|
||||
assert results["documents"] == []
|
||||
|
||||
def test_run_error_non_existent_file(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
converter = XLSXToDocument()
|
||||
paths = ["non_existing_file.docx"]
|
||||
with caplog.at_level(logging.WARNING):
|
||||
converter.run(sources=paths)
|
||||
assert "Could not read non_existing_file.docx" in caplog.text
|
||||
|
||||
def test_link_format_invalid(self) -> None:
|
||||
with pytest.raises(ValueError, match="Unknown link format"):
|
||||
XLSXToDocument(link_format="invalid")
|
||||
|
||||
@pytest.mark.parametrize("link_format", ["markdown", "plain"])
|
||||
def test_link_extraction(self, test_files_path, link_format) -> None:
|
||||
converter = XLSXToDocument(link_format=link_format)
|
||||
paths = [test_files_path / "xlsx" / "spreadsheet_with_links.xlsx"]
|
||||
results = converter.run(sources=paths)
|
||||
content = results["documents"][0].content
|
||||
|
||||
if link_format == "markdown":
|
||||
assert "[Click here](https://example.com)" in content
|
||||
assert "[Docs](https://python.org)" in content
|
||||
else:
|
||||
assert "Click here (https://example.com)" in content
|
||||
assert "Docs (https://python.org)" in content
|
||||
|
||||
def test_no_link_extraction(self, test_files_path) -> None:
|
||||
converter = XLSXToDocument()
|
||||
paths = [test_files_path / "xlsx" / "spreadsheet_with_links.xlsx"]
|
||||
results = converter.run(sources=paths)
|
||||
content = results["documents"][0].content
|
||||
|
||||
assert "https://example.com" not in content
|
||||
assert "Click here" in content
|
||||
Reference in New Issue
Block a user