Skip to content

Commit a8deacc

Browse files
refactor(docling): add meta parameter to run(); introduce sources; deprecate paths (#3103)
* fix(docling): add meta param to run() and rename paths to sources * feat(docling): accept ByteStream in sources and use normalize_metadata * style(docling): apply ruff formatting * fix(docling): fix mypy type error for deprecated paths parameter * fix(docling): address reviewer feedback on paths type, meta docstring and source.meta safety * fix(docling): reorder run() params, fix docstring order, move pytest import to top * style(docling): fix ruff import sort in test_converter.py
1 parent a0c797a commit a8deacc

2 files changed

Lines changed: 206 additions & 12 deletions

File tree

integrations/docling/src/haystack_integrations/components/converters/docling/converter.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
"""Docling Haystack converter module."""
22

33
import json
4+
import os
5+
import tempfile
6+
import warnings
47
from abc import ABC, abstractmethod
5-
from collections.abc import Iterable
68
from enum import Enum
79
from pathlib import Path
810
from typing import Any
911

1012
from haystack import Document, component
13+
from haystack.components.converters.utils import normalize_metadata
14+
from haystack.dataclasses import ByteStream
1115

1216
from docling.chunking import BaseChunk, BaseChunker, HybridChunker
1317
from docling.datamodel.document import DoclingDocument
@@ -98,46 +102,83 @@ def __init__(
98102
@component.output_types(documents=list[Document])
99103
def run(
100104
self,
101-
paths: Iterable[Path | str],
105+
paths: list[str | Path] | None = None,
106+
sources: list[str | Path | ByteStream] | None = None,
107+
meta: dict[str, Any] | list[dict[str, Any]] | None = None,
102108
) -> dict[str, list[Document]]:
103109
"""
104110
Run the DoclingConverter.
105111
106-
:param paths: The input document locations, either as local paths or URLs.
112+
:param paths: Deprecated. Use `sources` instead.
113+
:param sources: List of file paths, URLs, or ByteStream objects to convert.
114+
:param meta:
115+
Optional metadata to attach to the Documents.
116+
This value can be either a list of dictionaries or a single dictionary.
117+
If it's a single dictionary, its content is added to the metadata of all produced Documents.
118+
If it's a list, the length of the list must match the number of sources, because the two lists will
119+
be zipped.
120+
If a source is a ByteStream, its own metadata is also merged into the output.
107121
:returns:
108122
A dictionary with key `"documents"` containing the output Haystack Documents.
123+
:raises ValueError: If `meta` is a list whose length does not match the number of sources.
109124
:raises RuntimeError: If an unexpected `export_type` is encountered.
110125
"""
126+
if paths is not None:
127+
warnings.warn(
128+
"The 'paths' parameter is deprecated. Use 'sources' instead.",
129+
DeprecationWarning,
130+
stacklevel=2,
131+
)
132+
if sources is None:
133+
sources = list(paths) # type: ignore[arg-type]
134+
135+
if sources is None:
136+
msg = "Either 'sources' or the deprecated 'paths' parameter must be provided."
137+
raise ValueError(msg)
138+
139+
meta_list = normalize_metadata(meta=meta, sources_count=len(sources))
140+
111141
documents: list[Document] = []
112-
for filepath in paths:
113-
dl_doc = self._converter_instance.convert(
114-
source=filepath,
115-
**self.convert_kwargs,
116-
).document
142+
for source, source_meta in zip(sources, meta_list, strict=True):
143+
if isinstance(source, ByteStream):
144+
# docling requires a file path; write ByteStream data to a temp file
145+
with tempfile.NamedTemporaryFile(delete=False) as tmp:
146+
tmp.write(source.data)
147+
tmp_path = Path(tmp.name)
148+
try:
149+
dl_doc = self._converter_instance.convert(source=tmp_path, **self.convert_kwargs).document
150+
finally:
151+
os.unlink(tmp_path)
152+
# merge ByteStream meta (e.g. file_path, mime_type) with user-supplied meta
153+
merged_meta = {**(source.meta or {}), **source_meta}
154+
else:
155+
dl_doc = self._converter_instance.convert(source=source, **self.convert_kwargs).document
156+
merged_meta = source_meta
117157

118158
if self.export_type == ExportType.DOC_CHUNKS:
119159
chunk_iter = self._chunker_instance.chunk(dl_doc=dl_doc)
120160
hs_docs = [
121161
Document(
122162
content=self._chunker_instance.contextualize(chunk=chunk),
123-
meta=self._meta_extractor_instance.extract_chunk_meta(chunk=chunk),
163+
meta={**self._meta_extractor_instance.extract_chunk_meta(chunk=chunk), **merged_meta},
124164
)
125165
for chunk in chunk_iter
126166
]
127167
documents.extend(hs_docs)
128168
elif self.export_type == ExportType.MARKDOWN:
129169
hs_doc = Document(
130170
content=dl_doc.export_to_markdown(**self.md_export_kwargs),
131-
meta=self._meta_extractor_instance.extract_dl_doc_meta(dl_doc=dl_doc),
171+
meta={**self._meta_extractor_instance.extract_dl_doc_meta(dl_doc=dl_doc), **merged_meta},
132172
)
133173
documents.append(hs_doc)
134174
elif self.export_type == ExportType.JSON:
135175
hs_doc = Document(
136176
content=json.dumps(dl_doc.export_to_dict()),
137-
meta=self._meta_extractor_instance.extract_dl_doc_meta(dl_doc=dl_doc),
177+
meta={**self._meta_extractor_instance.extract_dl_doc_meta(dl_doc=dl_doc), **merged_meta},
138178
)
139179
documents.append(hs_doc)
140180
else:
141181
err_msg = f"Unexpected export type: {self.export_type}"
142182
raise RuntimeError(err_msg)
183+
143184
return {"documents": documents}

integrations/docling/tests/test_converter.py

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import json
2+
import warnings
23
from types import SimpleNamespace
34
from typing import Any
4-
from unittest.mock import MagicMock
5+
from unittest.mock import MagicMock, patch
56

7+
import pytest
68
from haystack.core.serialization import component_from_dict, component_to_dict
9+
from haystack.dataclasses import ByteStream
710

811
from haystack_integrations.components.converters.docling import DoclingConverter, ExportType
912

@@ -213,3 +216,153 @@ def test_component_from_dict_custom_params() -> None:
213216
assert restored.convert_kwargs == {"raises_on_error": False}
214217
assert restored.export_type == ExportType.JSON
215218
assert restored.md_export_kwargs == {"image_placeholder": "[img]"}
219+
220+
221+
def test_run_with_sources_parameter() -> None:
222+
converter_mock = MagicMock()
223+
chunker_mock = MagicMock()
224+
meta_extractor_mock = MagicMock()
225+
226+
converter_mock.convert.return_value = SimpleNamespace(document="dl-doc")
227+
chunker_mock.chunk.return_value = [SimpleNamespace(text="chunk-1")]
228+
chunker_mock.contextualize.return_value = "contextualized-chunk-1"
229+
meta_extractor_mock.extract_chunk_meta.return_value = {}
230+
231+
converter = DoclingConverter(
232+
converter=converter_mock,
233+
export_type=ExportType.DOC_CHUNKS,
234+
chunker=chunker_mock,
235+
meta_extractor=meta_extractor_mock,
236+
)
237+
238+
result = converter.run(sources=["file.pdf"])
239+
assert len(result["documents"]) == 1
240+
241+
242+
def test_run_paths_deprecated() -> None:
243+
converter_mock = MagicMock()
244+
chunker_mock = MagicMock()
245+
meta_extractor_mock = MagicMock()
246+
247+
converter_mock.convert.return_value = SimpleNamespace(document="dl-doc")
248+
chunker_mock.chunk.return_value = [SimpleNamespace(text="chunk-1")]
249+
chunker_mock.contextualize.return_value = "contextualized-chunk-1"
250+
meta_extractor_mock.extract_chunk_meta.return_value = {}
251+
252+
converter = DoclingConverter(
253+
converter=converter_mock,
254+
export_type=ExportType.DOC_CHUNKS,
255+
chunker=chunker_mock,
256+
meta_extractor=meta_extractor_mock,
257+
)
258+
259+
with warnings.catch_warnings(record=True) as caught:
260+
warnings.simplefilter("always")
261+
result = converter.run(paths=["file.pdf"])
262+
263+
assert len(result["documents"]) == 1
264+
assert any(issubclass(w.category, DeprecationWarning) and "paths" in str(w.message) for w in caught)
265+
266+
267+
def test_run_meta_single_dict_doc_chunks() -> None:
268+
converter_mock = MagicMock()
269+
chunker_mock = MagicMock()
270+
meta_extractor_mock = MagicMock()
271+
272+
converter_mock.convert.side_effect = [
273+
SimpleNamespace(document="dl-doc-a"),
274+
SimpleNamespace(document="dl-doc-b"),
275+
]
276+
chunker_mock.chunk.side_effect = lambda dl_doc: [SimpleNamespace(text=f"chunk-of-{dl_doc}")]
277+
chunker_mock.contextualize.side_effect = lambda chunk: chunk.text
278+
meta_extractor_mock.extract_chunk_meta.return_value = {"extractor_key": "extractor_val"}
279+
280+
converter = DoclingConverter(
281+
converter=converter_mock,
282+
export_type=ExportType.DOC_CHUNKS,
283+
chunker=chunker_mock,
284+
meta_extractor=meta_extractor_mock,
285+
)
286+
287+
result = converter.run(sources=["a.pdf", "b.pdf"], meta={"custom": "value"})
288+
documents = result["documents"]
289+
290+
assert len(documents) == 2
291+
for doc in documents:
292+
assert doc.meta["custom"] == "value"
293+
assert doc.meta["extractor_key"] == "extractor_val"
294+
295+
296+
def test_run_meta_list_of_dicts_markdown() -> None:
297+
converter_mock = MagicMock()
298+
meta_extractor_mock = MagicMock()
299+
300+
dl_doc_a = MagicMock()
301+
dl_doc_a.export_to_markdown.return_value = "markdown-a"
302+
dl_doc_b = MagicMock()
303+
dl_doc_b.export_to_markdown.return_value = "markdown-b"
304+
305+
converter_mock.convert.side_effect = [
306+
SimpleNamespace(document=dl_doc_a),
307+
SimpleNamespace(document=dl_doc_b),
308+
]
309+
meta_extractor_mock.extract_dl_doc_meta.return_value = {}
310+
311+
converter = DoclingConverter(
312+
converter=converter_mock,
313+
export_type=ExportType.MARKDOWN,
314+
meta_extractor=meta_extractor_mock,
315+
)
316+
317+
result = converter.run(
318+
sources=["a.pdf", "b.pdf"],
319+
meta=[{"source_id": "doc-a"}, {"source_id": "doc-b"}],
320+
)
321+
documents = result["documents"]
322+
323+
assert len(documents) == 2
324+
assert documents[0].meta["source_id"] == "doc-a"
325+
assert documents[1].meta["source_id"] == "doc-b"
326+
327+
328+
def test_run_meta_list_length_mismatch_raises() -> None:
329+
converter_mock = MagicMock()
330+
meta_extractor_mock = MagicMock()
331+
332+
converter = DoclingConverter(
333+
converter=converter_mock,
334+
export_type=ExportType.MARKDOWN,
335+
meta_extractor=meta_extractor_mock,
336+
)
337+
338+
with pytest.raises(ValueError):
339+
converter.run(sources=["a.pdf", "b.pdf"], meta=[{"x": 1}])
340+
341+
342+
def test_run_with_bytestream_source() -> None:
343+
converter_mock = MagicMock()
344+
meta_extractor_mock = MagicMock()
345+
346+
dl_doc = MagicMock()
347+
dl_doc.export_to_markdown.return_value = "markdown-content"
348+
converter_mock.convert.return_value = SimpleNamespace(document=dl_doc)
349+
meta_extractor_mock.extract_dl_doc_meta.return_value = {}
350+
351+
converter = DoclingConverter(
352+
converter=converter_mock,
353+
export_type=ExportType.MARKDOWN,
354+
meta_extractor=meta_extractor_mock,
355+
)
356+
357+
bytestream = ByteStream(data=b"%PDF-1.4 fake pdf content", meta={"file_path": "uploaded.pdf"})
358+
359+
with patch("os.unlink"):
360+
result = converter.run(sources=[bytestream])
361+
362+
documents = result["documents"]
363+
assert len(documents) == 1
364+
# ByteStream meta is merged into the output document
365+
assert documents[0].meta["file_path"] == "uploaded.pdf"
366+
# docling was called with a temp file path, not the ByteStream directly
367+
call_args = converter_mock.convert.call_args
368+
assert call_args.kwargs["source"] != bytestream

0 commit comments

Comments
 (0)