libreoffice-haystack 1.0.0__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (17) hide show
  1. libreoffice_haystack-2.0.0/CHANGELOG.md +31 -0
  2. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/PKG-INFO +7 -4
  3. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/README.md +3 -0
  4. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/pyproject.toml +6 -3
  5. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/src/haystack_integrations/components/converters/libreoffice/converter.py +60 -10
  6. libreoffice_haystack-2.0.0/tests/conftest.py +75 -0
  7. libreoffice_haystack-2.0.0/tests/test_converter.py +239 -0
  8. libreoffice_haystack-1.0.0/tests/test_converter.py +0 -129
  9. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/.gitignore +0 -0
  10. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/LICENSE.txt +0 -0
  11. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/pydoc/config_docusaurus.yml +0 -0
  12. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/src/haystack_integrations/components/converters/libreoffice/__init__.py +0 -0
  13. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/src/haystack_integrations/components/converters/py.typed +0 -0
  14. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/tests/__init__.py +0 -0
  15. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/tests/test_files/doc/sample_doc.doc +0 -0
  16. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/tests/test_files/ppt/sample_ppt.ppt +0 -0
  17. {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/tests/test_files/xls/basic_tables_two_sheets.xls +0 -0
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## [integrations/libreoffice-v1.1.1] - 2026-08-25
4
+
5
+ ### 🐛 Bug Fixes
6
+
7
+ - Fix new issues raised by ruff 0.16.0 (#3670)
8
+ - LibreOffice - serialize output_file_type and increase unit tests coverage (#3793)
9
+
10
+ ### 🧪 Testing
11
+
12
+ - Track test coverage for all integrations (#3065)
13
+
14
+ ### 🧹 Chores
15
+
16
+ - Re-standardize READMEs (#3768)
17
+
18
+ ## [integrations/libreoffice-v1.1.0] - 2026-03-30
19
+
20
+ ### 🚀 Features
21
+
22
+ - Add mime_type to ByteStream in LibreOfficeFileConverter (#3057)
23
+
24
+
25
+ ## [integrations/libreoffice-v1.0.0] - 2026-03-25
26
+
27
+ ### 🚀 Features
28
+
29
+ - Add new `LibreOfficeFileConverter` integration (#3006)
30
+
31
+ <!-- generated by git-cliff -->
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: libreoffice-haystack
3
- Version: 1.0.0
3
+ Version: 2.0.0
4
4
  Summary: LibreOffice integration for Haystack
5
5
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/libreoffice#readme
6
6
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
@@ -12,14 +12,14 @@ License-File: LICENSE.txt
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: License :: OSI Approved :: Apache Software License
14
14
  Classifier: Programming Language :: Python
15
- Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
20
20
  Classifier: Programming Language :: Python :: Implementation :: CPython
21
21
  Classifier: Programming Language :: Python :: Implementation :: PyPy
22
- Requires-Python: >=3.10
22
+ Requires-Python: >=3.11
23
23
  Requires-Dist: haystack-ai
24
24
  Description-Content-Type: text/markdown
25
25
 
@@ -28,6 +28,7 @@ Description-Content-Type: text/markdown
28
28
  [![PyPI - Version](https://img.shields.io/pypi/v/libreoffice-haystack.svg)](https://pypi.org/project/libreoffice-haystack)
29
29
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/libreoffice-haystack.svg)](https://pypi.org/project/libreoffice-haystack)
30
30
 
31
+ - [Integration page](https://haystack.deepset.ai/integrations/libreoffice-file-converter)
31
32
  - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/libreoffice/CHANGELOG.md)
32
33
 
33
34
  ---
@@ -35,3 +36,5 @@ Description-Content-Type: text/markdown
35
36
  ## Contributing
36
37
 
37
38
  Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
39
+
40
+ To run integration tests locally, you need LibreOffice installed and available on your `PATH`.
@@ -3,6 +3,7 @@
3
3
  [![PyPI - Version](https://img.shields.io/pypi/v/libreoffice-haystack.svg)](https://pypi.org/project/libreoffice-haystack)
4
4
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/libreoffice-haystack.svg)](https://pypi.org/project/libreoffice-haystack)
5
5
 
6
+ - [Integration page](https://haystack.deepset.ai/integrations/libreoffice-file-converter)
6
7
  - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/libreoffice/CHANGELOG.md)
7
8
 
8
9
  ---
@@ -10,3 +11,5 @@
10
11
  ## Contributing
11
12
 
12
13
  Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
14
+
15
+ To run integration tests locally, you need LibreOffice installed and available on your `PATH`.
@@ -7,7 +7,7 @@ name = "libreoffice-haystack"
7
7
  dynamic = ["version"]
8
8
  description = "LibreOffice integration for Haystack"
9
9
  readme = "README.md"
10
- requires-python = ">=3.10"
10
+ requires-python = ">=3.11"
11
11
  license = "Apache-2.0"
12
12
  keywords = []
13
13
  authors = [{ name = "deepset GmbH", email = "info@deepset.ai" }, { name = "Max Swain" }]
@@ -15,11 +15,11 @@ classifiers = [
15
15
  "License :: OSI Approved :: Apache Software License",
16
16
  "Development Status :: 4 - Beta",
17
17
  "Programming Language :: Python",
18
- "Programming Language :: Python :: 3.10",
19
18
  "Programming Language :: Python :: 3.11",
20
19
  "Programming Language :: Python :: 3.12",
21
20
  "Programming Language :: Python :: 3.13",
22
21
  "Programming Language :: Python :: 3.14",
22
+ "Programming Language :: Python :: 3.15",
23
23
  "Programming Language :: Python :: Implementation :: CPython",
24
24
  "Programming Language :: Python :: Implementation :: PyPy",
25
25
  ]
@@ -64,7 +64,8 @@ dependencies = [
64
64
  unit = 'pytest -m "not integration" {args:tests}'
65
65
  integration = 'pytest -m "integration" {args:tests}'
66
66
  all = 'pytest {args:tests}'
67
- cov-retry = 'pytest --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x {args:tests}'
67
+ unit-cov-retry = 'pytest --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x -m "not integration" {args:tests}'
68
+ integration-cov-append-retry = 'pytest --cov=haystack_integrations --cov-append --reruns 3 --reruns-delay 30 -x -m "integration" {args:tests}'
68
69
  types = "mypy -p haystack_integrations.components.converters.libreoffice {args}"
69
70
 
70
71
  [tool.mypy]
@@ -126,6 +127,7 @@ ignore = [
126
127
  "PLR0912",
127
128
  "PLR0913",
128
129
  "PLR0915",
130
+ "PLR0917",
129
131
  # Allow `Any` type - used legitimately for dynamic types and SDK boundaries
130
132
  "ANN401",
131
133
  ]
@@ -143,6 +145,7 @@ ban-relative-imports = "parents"
143
145
  [tool.coverage.run]
144
146
  source = ["haystack_integrations"]
145
147
  branch = true
148
+ relative_files = true
146
149
  parallel = false
147
150
 
148
151
  [tool.coverage.report]
@@ -2,6 +2,7 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
+ import mimetypes
5
6
  import os
6
7
  import shutil
7
8
  import subprocess
@@ -9,11 +10,10 @@ from asyncio import create_subprocess_exec
9
10
  from collections.abc import Iterable
10
11
  from pathlib import Path
11
12
  from tempfile import TemporaryDirectory
12
- from typing import Any, ClassVar, Literal, TypedDict, get_args
13
+ from typing import Any, ClassVar, Literal, Self, TypedDict, get_args
13
14
 
14
15
  from haystack import component, default_from_dict, default_to_dict
15
16
  from haystack.dataclasses import ByteStream
16
- from typing_extensions import Self
17
17
 
18
18
  OUTPUT_FILE_TYPE = Literal[
19
19
  "pdf",
@@ -109,6 +109,26 @@ class LibreOfficeFileConverter:
109
109
  """A non-exhaustive mapping of supported conversion types by this component.
110
110
  See https://help.libreoffice.org/latest/en-GB/text/shared/guide/convertfilters.html for more information."""
111
111
 
112
+ MIME_TYPE_FALLBACKS: ClassVar[dict[str, str]] = {
113
+ "pdf": "application/pdf",
114
+ "doc": "application/msword",
115
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
116
+ "odt": "application/vnd.oasis.opendocument.text",
117
+ "rtf": "application/rtf",
118
+ "txt": "text/plain",
119
+ "html": "text/html",
120
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
121
+ "xls": "application/vnd.ms-excel",
122
+ "ods": "application/vnd.oasis.opendocument.spreadsheet",
123
+ "csv": "text/csv",
124
+ "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
125
+ "ppt": "application/vnd.ms-powerpoint",
126
+ "odp": "application/vnd.oasis.opendocument.presentation",
127
+ "epub": "application/epub+zip",
128
+ "png": "image/png",
129
+ "jpg": "image/jpeg",
130
+ }
131
+
112
132
  def __init__(
113
133
  self,
114
134
  output_file_type: OUTPUT_FILE_TYPE | None = None,
@@ -137,7 +157,7 @@ class LibreOfficeFileConverter:
137
157
  :returns:
138
158
  Dictionary with serialized data.
139
159
  """
140
- return default_to_dict(self)
160
+ return default_to_dict(self, output_file_type=self.output_file_type)
141
161
 
142
162
  @classmethod
143
163
  def from_dict(cls, data: dict[str, Any]) -> Self:
@@ -224,6 +244,12 @@ class LibreOfficeFileConverter:
224
244
  )
225
245
  raise ValueError(msg)
226
246
 
247
+ def _resolve_mime_type(self, output_path: Path, output_file_type: str) -> str | None:
248
+ mime_type, _ = mimetypes.guess_type(str(output_path))
249
+ if mime_type is None:
250
+ return self.MIME_TYPE_FALLBACKS.get(output_file_type)
251
+ return mime_type
252
+
227
253
  @component.output_types(output=list[ByteStream])
228
254
  def run(
229
255
  self,
@@ -251,6 +277,7 @@ class LibreOfficeFileConverter:
251
277
  :raises ValueError: If a source's file type is not in :attr:`SUPPORTED_TYPES`,
252
278
  or if `output_file_type` is not a valid conversion target for it,
253
279
  or if `output_file_type` has not been provided anywhere.
280
+ :raises subprocess.CalledProcessError: If soffice exits with a non-zero status.
254
281
  """
255
282
  resolved_output_file_type = output_file_type or self.output_file_type
256
283
  if resolved_output_file_type is None:
@@ -269,14 +296,24 @@ class LibreOfficeFileConverter:
269
296
  output_path, args = self._get_conversion_args(tmp_path, tmpdir, resolved_output_file_type)
270
297
 
271
298
  subprocess.run(args, check=True) # noqa: S603 - ruff doesn't know the arguments have been validated
272
- outputs.append(ByteStream(data=output_path.read_bytes()))
299
+ outputs.append(
300
+ ByteStream(
301
+ data=output_path.read_bytes(),
302
+ mime_type=self._resolve_mime_type(output_path, resolved_output_file_type),
303
+ )
304
+ )
273
305
  continue
274
306
 
275
307
  self._validate_args(resolved_output_file_type, str(source).split(".")[-1])
276
308
  output_path, args = self._get_conversion_args(source, tmpdir, resolved_output_file_type)
277
309
 
278
310
  subprocess.run(args, check=True) # noqa: S603
279
- outputs.append(ByteStream(data=output_path.read_bytes()))
311
+ outputs.append(
312
+ ByteStream(
313
+ data=output_path.read_bytes(),
314
+ mime_type=self._resolve_mime_type(output_path, resolved_output_file_type),
315
+ )
316
+ )
280
317
 
281
318
  return {"output": outputs}
282
319
 
@@ -309,6 +346,7 @@ class LibreOfficeFileConverter:
309
346
  :raises ValueError: If a source's file type is not in :attr:`SUPPORTED_TYPES`,
310
347
  or if `output_file_type` is not a valid conversion target for it,
311
348
  or if `output_file_type` has not been provided anywhere.
349
+ :raises subprocess.CalledProcessError: If soffice exits with a non-zero status.
312
350
  """
313
351
  resolved_output_file_type = output_file_type or self.output_file_type
314
352
  if resolved_output_file_type is None:
@@ -328,8 +366,14 @@ class LibreOfficeFileConverter:
328
366
 
329
367
  process = await create_subprocess_exec(*args)
330
368
  # Wait for process to complete as only one instance of soffice can occur at once
331
- await process.wait()
332
- outputs.append(ByteStream(data=output_path.read_bytes()))
369
+ if returncode := await process.wait():
370
+ raise subprocess.CalledProcessError(returncode, args)
371
+ outputs.append(
372
+ ByteStream(
373
+ data=output_path.read_bytes(),
374
+ mime_type=self._resolve_mime_type(output_path, resolved_output_file_type),
375
+ )
376
+ )
333
377
  continue
334
378
 
335
379
  self._validate_args(resolved_output_file_type, str(source).split(".")[-1])
@@ -337,8 +381,14 @@ class LibreOfficeFileConverter:
337
381
 
338
382
  process = await create_subprocess_exec(*args)
339
383
  # Wait for process to complete as only one instance of soffice can occur at once
340
- await process.wait()
341
-
342
- outputs.append(ByteStream(data=output_path.read_bytes()))
384
+ if returncode := await process.wait():
385
+ raise subprocess.CalledProcessError(returncode, args)
386
+
387
+ outputs.append(
388
+ ByteStream(
389
+ data=output_path.read_bytes(),
390
+ mime_type=self._resolve_mime_type(output_path, resolved_output_file_type),
391
+ )
392
+ )
343
393
 
344
394
  return {"output": outputs}
@@ -0,0 +1,75 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import subprocess
6
+ from collections.abc import Generator
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+ from unittest.mock import AsyncMock, patch
10
+
11
+ import pytest
12
+
13
+ from haystack_integrations.components.converters.libreoffice import LibreOfficeFileConverter
14
+
15
+ CONVERTER_MODULE = "haystack_integrations.components.converters.libreoffice.converter"
16
+
17
+
18
+ @pytest.fixture
19
+ def real_converter() -> LibreOfficeFileConverter:
20
+ """A converter backed by an actual LibreOffice installation, for the integration tests."""
21
+ return LibreOfficeFileConverter()
22
+
23
+
24
+ @pytest.fixture
25
+ def mock_converter() -> Generator[LibreOfficeFileConverter, None]:
26
+ """A converter that believes soffice is installed, so no LibreOffice binary is needed."""
27
+ with patch("shutil.which", return_value="/usr/bin/soffice"):
28
+ yield LibreOfficeFileConverter()
29
+
30
+
31
+ @pytest.fixture
32
+ def fake_soffice() -> Generator[SimpleNamespace, None]:
33
+ """
34
+ Stand in for soffice on both the sync and the async call, so no LibreOffice is needed.
35
+
36
+ Writes the file the real binary would have produced, deriving the output path from the
37
+ same argv the converter built, so the tests break if that contract changes. `calls`
38
+ records every argv; set `returncode` non-zero to report a failed conversion.
39
+
40
+ The sync side only raises on a non-zero `returncode` when the caller opted in with
41
+ `check=True`, exactly as `subprocess.run` does, so dropping that argument fails a test
42
+ instead of passing quietly.
43
+ """
44
+ fake = SimpleNamespace(calls=[], returncode=0)
45
+
46
+ def _convert(args: list[str]) -> None:
47
+ fake.calls.append(args)
48
+ if fake.returncode:
49
+ return
50
+ outdir = Path(args[args.index("--outdir") + 1])
51
+ suffix = args[args.index("--convert-to") + 1]
52
+ (outdir / Path(args[-1]).name).with_suffix(f".{suffix}").write_bytes(b"converted bytes")
53
+
54
+ def _run(args: list[str], *, check: bool = False, **_: object) -> None:
55
+ _convert(args)
56
+ if fake.returncode and check:
57
+ raise subprocess.CalledProcessError(fake.returncode, args)
58
+
59
+ async def _exec(*args: str) -> AsyncMock:
60
+ _convert(list(args))
61
+ process = AsyncMock()
62
+ process.wait = AsyncMock(return_value=fake.returncode)
63
+ return process
64
+
65
+ with (
66
+ patch(f"{CONVERTER_MODULE}.subprocess.run", side_effect=_run),
67
+ patch(f"{CONVERTER_MODULE}.create_subprocess_exec", side_effect=_exec),
68
+ ):
69
+ yield fake
70
+
71
+
72
+ @pytest.fixture
73
+ def test_files_path() -> Path:
74
+ """Directory holding the sample office documents, resolved relative to this file."""
75
+ return Path(__file__).parent / "test_files"
@@ -0,0 +1,239 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+ from typing import Any
9
+ from unittest.mock import patch
10
+
11
+ import pytest
12
+ from haystack.dataclasses import ByteStream
13
+
14
+ from haystack_integrations.components.converters.libreoffice import LibreOfficeFileConverter
15
+
16
+ TYPE = "haystack_integrations.components.converters.libreoffice.converter.LibreOfficeFileConverter"
17
+
18
+
19
+ def _converted_to(args: list[str]) -> str:
20
+ """The output file type the converter asked soffice for."""
21
+ return args[args.index("--convert-to") + 1]
22
+
23
+
24
+ async def _convert(converter: LibreOfficeFileConverter, is_async: bool, *args: Any, **kwargs: Any) -> Any:
25
+ """Call `run_async` or `run`, so one test body can cover both."""
26
+ return await converter.run_async(*args, **kwargs) if is_async else converter.run(*args, **kwargs)
27
+
28
+
29
+ def _configured_converter(output_file_type: str) -> LibreOfficeFileConverter:
30
+ """A converter with an `output_file_type` set at init time, needing no LibreOffice installation."""
31
+ with patch("shutil.which", return_value="/usr/bin/soffice"):
32
+ return LibreOfficeFileConverter(output_file_type=output_file_type)
33
+
34
+
35
+ class TestInit:
36
+ def test_init(self, mock_converter: LibreOfficeFileConverter) -> None:
37
+ assert isinstance(mock_converter.soffice_path, str)
38
+ assert mock_converter.output_file_type is None
39
+ assert _configured_converter("pdf").output_file_type == "pdf"
40
+
41
+ def test_raises_when_soffice_is_not_installed(self) -> None:
42
+ with patch("shutil.which", return_value=None):
43
+ with pytest.raises(FileNotFoundError, match="LibreOffice"):
44
+ LibreOfficeFileConverter()
45
+
46
+
47
+ class TestSerde:
48
+ def test_to_dict(self, mock_converter: LibreOfficeFileConverter) -> None:
49
+ assert mock_converter.to_dict() == {"type": TYPE, "init_parameters": {"output_file_type": None}}
50
+ assert _configured_converter("pdf").to_dict() == {"type": TYPE, "init_parameters": {"output_file_type": "pdf"}}
51
+
52
+ def test_from_dict(self) -> None:
53
+ with patch("shutil.which", return_value="/usr/bin/soffice"):
54
+ converter = LibreOfficeFileConverter.from_dict({"type": TYPE, "init_parameters": {}})
55
+ assert isinstance(converter.soffice_path, str)
56
+ # Dictionaries serialized before `output_file_type` was included still deserialize.
57
+ assert converter.output_file_type is None
58
+
59
+ def test_round_trip_keeps_converting_to_the_configured_type(
60
+ self, fake_soffice: SimpleNamespace, tmp_path: Path
61
+ ) -> None:
62
+ source = tmp_path / "sample.doc"
63
+ source.write_bytes(b"doc")
64
+
65
+ with patch("shutil.which", return_value="/usr/bin/soffice"):
66
+ deserialized = LibreOfficeFileConverter.from_dict(_configured_converter("pdf").to_dict())
67
+
68
+ # The converter that came back out of the round trip still needs no `output_file_type` argument.
69
+ assert deserialized.run([source])["output"][0].mime_type == "application/pdf"
70
+ assert _converted_to(fake_soffice.calls[-1]) == "pdf"
71
+
72
+
73
+ class TestGetConversionArgs:
74
+ def test_builds_the_soffice_command_line(self, mock_converter: LibreOfficeFileConverter, tmp_path: Path) -> None:
75
+ source = tmp_path / "sample.doc"
76
+ source.write_bytes(b"doc")
77
+
78
+ output_path, args = mock_converter._get_conversion_args(source, tmp_path, "pdf")
79
+
80
+ assert args == ["/usr/bin/soffice", "--headless", "--convert-to", "pdf", "--outdir", str(tmp_path), str(source)]
81
+ assert output_path == tmp_path / "sample.pdf"
82
+
83
+ def test_rejects_an_output_directory_that_does_not_exist(
84
+ self, mock_converter: LibreOfficeFileConverter, tmp_path: Path
85
+ ) -> None:
86
+ source = tmp_path / "sample.doc"
87
+ source.write_bytes(b"doc")
88
+
89
+ with pytest.raises(OSError, match="must exist and be writable"):
90
+ mock_converter._get_conversion_args(source, tmp_path / "missing", "pdf")
91
+
92
+
93
+ class TestValidateArgs:
94
+ @pytest.mark.parametrize(
95
+ ("output_file_type", "error"),
96
+ [
97
+ # .pdf is not a supported input type in SUPPORTED_TYPES
98
+ ("docx", "input_file_type"),
99
+ # .doc -> .png is not a valid conversion
100
+ ("png", "is not supported for"),
101
+ # .xyz is not one of the OUTPUT_FILE_TYPE values at all
102
+ ("xyz", "is not supported and must be one of type"),
103
+ ],
104
+ )
105
+ def test_rejects_unsupported_conversions(
106
+ self, mock_converter: LibreOfficeFileConverter, output_file_type: str, error: str
107
+ ) -> None:
108
+ source = "test_file.pdf" if output_file_type == "docx" else "test_file.doc"
109
+ with pytest.raises(ValueError, match=error):
110
+ mock_converter.run([source], output_file_type=output_file_type)
111
+
112
+
113
+ @pytest.mark.asyncio
114
+ @pytest.mark.parametrize("is_async", [False, True], ids=["run", "run_async"])
115
+ class TestConvert:
116
+ """`run` and `run_async` share their logic, so every case here covers both."""
117
+
118
+ async def test_converts_each_source_in_order(
119
+ self, mock_converter: LibreOfficeFileConverter, fake_soffice: SimpleNamespace, tmp_path: Path, is_async: bool
120
+ ) -> None:
121
+ first, second = tmp_path / "first.doc", tmp_path / "second.ppt"
122
+ first.write_bytes(b"doc")
123
+ second.write_bytes(b"ppt")
124
+
125
+ result = await _convert(mock_converter, is_async, [first, second], output_file_type="pdf")
126
+
127
+ assert [stream.data for stream in result["output"]] == [b"converted bytes"] * 2
128
+ assert [stream.mime_type for stream in result["output"]] == ["application/pdf"] * 2
129
+ assert len(fake_soffice.calls) == 2
130
+
131
+ async def test_writes_a_bytestream_to_a_temporary_file_before_converting(
132
+ self, mock_converter: LibreOfficeFileConverter, fake_soffice: SimpleNamespace, is_async: bool
133
+ ) -> None:
134
+ result = await _convert(mock_converter, is_async, [ByteStream(data=b"raw doc bytes")], output_file_type="pdf")
135
+
136
+ assert [stream.data for stream in result["output"]] == [b"converted bytes"]
137
+ # The source handed to soffice is the temporary file, not the original path.
138
+ assert Path(fake_soffice.calls[-1][-1]).name == "input"
139
+
140
+ async def test_rejects_a_source_that_does_not_exist(
141
+ self, mock_converter: LibreOfficeFileConverter, is_async: bool
142
+ ) -> None:
143
+ with pytest.raises(FileNotFoundError):
144
+ await _convert(mock_converter, is_async, ["nonexistent_file.doc"], output_file_type="docx")
145
+
146
+ async def test_uses_the_output_file_type_from_init(
147
+ self, fake_soffice: SimpleNamespace, tmp_path: Path, is_async: bool
148
+ ) -> None:
149
+ source = tmp_path / "sample.doc"
150
+ source.write_bytes(b"doc")
151
+
152
+ result = await _convert(_configured_converter("pdf"), is_async, [source])
153
+
154
+ assert result["output"][0].mime_type == "application/pdf"
155
+ assert _converted_to(fake_soffice.calls[-1]) == "pdf"
156
+
157
+ async def test_argument_overrides_the_output_file_type_from_init(
158
+ self, fake_soffice: SimpleNamespace, tmp_path: Path, is_async: bool
159
+ ) -> None:
160
+ source = tmp_path / "sample.doc"
161
+ source.write_bytes(b"doc")
162
+
163
+ await _convert(_configured_converter("docx"), is_async, [source], output_file_type="pdf")
164
+
165
+ assert _converted_to(fake_soffice.calls[-1]) == "pdf"
166
+
167
+ async def test_requires_an_output_file_type_somewhere(
168
+ self, mock_converter: LibreOfficeFileConverter, is_async: bool
169
+ ) -> None:
170
+ with pytest.raises(ValueError, match="output_file_type must be provided"):
171
+ await _convert(mock_converter, is_async, ["sample.doc"])
172
+
173
+ @pytest.mark.parametrize("as_bytestream", [False, True])
174
+ async def test_raises_when_soffice_exits_non_zero(
175
+ self,
176
+ mock_converter: LibreOfficeFileConverter,
177
+ fake_soffice: SimpleNamespace,
178
+ tmp_path: Path,
179
+ is_async: bool,
180
+ as_bytestream: bool,
181
+ ) -> None:
182
+ path = tmp_path / "sample.doc"
183
+ path.write_bytes(b"doc")
184
+ fake_soffice.returncode = 1
185
+ source = ByteStream(data=b"raw doc bytes") if as_bytestream else path
186
+
187
+ with pytest.raises(subprocess.CalledProcessError):
188
+ await _convert(mock_converter, is_async, [source], output_file_type="pdf")
189
+
190
+
191
+ class TestResolveMimeType:
192
+ @pytest.mark.parametrize(
193
+ ("guessed", "output_file_type", "expected"),
194
+ [
195
+ ("application/pdf", "pdf", "application/pdf"),
196
+ (None, "docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
197
+ (None, "unknown", None),
198
+ ],
199
+ )
200
+ def test_falls_back_to_the_mapping_when_the_guess_fails(
201
+ self,
202
+ mock_converter: LibreOfficeFileConverter,
203
+ guessed: str | None,
204
+ output_file_type: str,
205
+ expected: str | None,
206
+ ) -> None:
207
+ with patch("mimetypes.guess_type", return_value=(guessed, None)):
208
+ assert mock_converter._resolve_mime_type(Path(f"test.{output_file_type}"), output_file_type) == expected
209
+
210
+
211
+ @pytest.mark.integration
212
+ @pytest.mark.asyncio
213
+ @pytest.mark.parametrize("is_async", [False, True], ids=["run", "run_async"])
214
+ class TestRunIntegration:
215
+ """End-to-end against a real LibreOffice installation, for both `run` and `run_async`."""
216
+
217
+ @pytest.mark.parametrize("as_bytestream", [False, True])
218
+ async def test_converts_office_files_to_pdf(
219
+ self,
220
+ real_converter: LibreOfficeFileConverter,
221
+ test_files_path: Path,
222
+ is_async: bool,
223
+ as_bytestream: bool,
224
+ ) -> None:
225
+ paths = [
226
+ test_files_path / "doc" / "sample_doc.doc",
227
+ test_files_path / "ppt" / "sample_ppt.ppt",
228
+ test_files_path / "xls" / "basic_tables_two_sheets.xls",
229
+ ]
230
+ sources = [ByteStream(data=paths[0].read_bytes())] if as_bytestream else paths
231
+
232
+ result = await _convert(real_converter, is_async, sources, output_file_type="pdf")
233
+
234
+ output = result["output"]
235
+ assert len(output) == len(sources)
236
+ for stream in output:
237
+ assert isinstance(stream, ByteStream)
238
+ assert len(stream.data) > 0
239
+ assert stream.mime_type == "application/pdf"
@@ -1,129 +0,0 @@
1
- # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
- #
3
- # SPDX-License-Identifier: Apache-2.0
4
-
5
- from collections.abc import Generator
6
- from pathlib import Path
7
- from unittest.mock import patch
8
-
9
- import pytest
10
- from haystack.dataclasses import ByteStream
11
-
12
- from haystack_integrations.components.converters.libreoffice import LibreOfficeFileConverter
13
-
14
-
15
- @pytest.fixture
16
- def converter() -> LibreOfficeFileConverter:
17
- return LibreOfficeFileConverter()
18
-
19
-
20
- @pytest.fixture
21
- def mock_converter() -> Generator[LibreOfficeFileConverter, None]:
22
- with patch("shutil.which", return_value="/usr/bin/soffice"):
23
- yield LibreOfficeFileConverter()
24
-
25
-
26
- @pytest.fixture
27
- def test_files_path() -> Path:
28
- return Path("tests") / "test_files"
29
-
30
-
31
- class TestLibreOfficeFileConverter:
32
- def test_init(self, mock_converter: LibreOfficeFileConverter) -> None:
33
- assert isinstance(mock_converter, LibreOfficeFileConverter)
34
- assert isinstance(mock_converter.soffice_path, str)
35
-
36
- def test_init_raises_when_soffice_not_found(self) -> None:
37
- with patch("shutil.which", return_value=None):
38
- with pytest.raises(FileNotFoundError, match="LibreOffice"):
39
- LibreOfficeFileConverter()
40
-
41
- def test_to_dict(self, mock_converter: LibreOfficeFileConverter) -> None:
42
- data = mock_converter.to_dict()
43
- assert data == {
44
- "type": "haystack_integrations.components.converters.libreoffice.converter.LibreOfficeFileConverter",
45
- "init_parameters": {},
46
- }
47
-
48
- def test_from_dict(self) -> None:
49
- data = {
50
- "type": "haystack_integrations.components.converters.libreoffice.converter.LibreOfficeFileConverter",
51
- "init_parameters": {},
52
- }
53
- with patch("shutil.which", return_value="/usr/bin/soffice"):
54
- converter = LibreOfficeFileConverter.from_dict(data)
55
- assert isinstance(converter.soffice_path, str)
56
-
57
- def test_run_unsupported_input_type(self, mock_converter: LibreOfficeFileConverter) -> None:
58
- # .pdf is not a supported input type in SUPPORTED_TYPES
59
- with pytest.raises(ValueError):
60
- mock_converter.run(["test_file.pdf"], output_file_type="docx")
61
-
62
- def test_run_unsupported_output_type(self, mock_converter: LibreOfficeFileConverter) -> None:
63
- # .doc -> .png is not a valid conversion
64
- with pytest.raises(ValueError):
65
- mock_converter.run(["test_file.doc"], output_file_type="png")
66
-
67
- def test_run_no_file(self, mock_converter: LibreOfficeFileConverter) -> None:
68
- with pytest.raises(FileNotFoundError):
69
- mock_converter.run(["nonexistent_file.doc"], output_file_type="docx")
70
-
71
- @pytest.mark.integration
72
- def test_run(self, converter: LibreOfficeFileConverter, test_files_path: Path) -> None:
73
- paths = [
74
- test_files_path / "doc" / "sample_doc.doc",
75
- test_files_path / "ppt" / "sample_ppt.ppt",
76
- test_files_path / "xls" / "basic_tables_two_sheets.xls",
77
- ]
78
-
79
- results = converter.run(paths, output_file_type="pdf")
80
-
81
- output = results["output"]
82
- assert len(output) == 3
83
- for stream in output:
84
- assert isinstance(stream, ByteStream)
85
- assert len(stream.data) > 0
86
-
87
- @pytest.mark.integration
88
- def test_run_bytestream_source(self, converter: LibreOfficeFileConverter, test_files_path: Path) -> None:
89
- source_path = test_files_path / "doc" / "sample_doc.doc"
90
- bytestream = ByteStream(data=source_path.read_bytes())
91
-
92
- results = converter.run([bytestream], output_file_type="pdf")
93
-
94
- output = results["output"]
95
- assert len(output) == 1
96
- assert isinstance(output[0], ByteStream)
97
- assert len(output[0].data) > 0
98
-
99
- @pytest.mark.asyncio
100
- @pytest.mark.integration
101
- async def test_run_async(self, converter: LibreOfficeFileConverter, test_files_path: Path) -> None:
102
- paths = [
103
- test_files_path / "doc" / "sample_doc.doc",
104
- test_files_path / "ppt" / "sample_ppt.ppt",
105
- test_files_path / "xls" / "basic_tables_two_sheets.xls",
106
- ]
107
-
108
- results = await converter.run_async(paths, output_file_type="pdf")
109
-
110
- output = results["output"]
111
- assert len(output) == 3
112
- for stream in output:
113
- assert isinstance(stream, ByteStream)
114
- assert len(stream.data) > 0
115
-
116
- @pytest.mark.asyncio
117
- @pytest.mark.integration
118
- async def test_run_async_bytestream_source(
119
- self, converter: LibreOfficeFileConverter, test_files_path: Path
120
- ) -> None:
121
- source_path = test_files_path / "doc" / "sample_doc.doc"
122
- bytestream = ByteStream(data=source_path.read_bytes())
123
-
124
- results = await converter.run_async([bytestream], output_file_type="pdf")
125
-
126
- output = results["output"]
127
- assert len(output) == 1
128
- assert isinstance(output[0], ByteStream)
129
- assert len(output[0].data) > 0