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.
- libreoffice_haystack-2.0.0/CHANGELOG.md +31 -0
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/PKG-INFO +7 -4
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/README.md +3 -0
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/pyproject.toml +6 -3
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/src/haystack_integrations/components/converters/libreoffice/converter.py +60 -10
- libreoffice_haystack-2.0.0/tests/conftest.py +75 -0
- libreoffice_haystack-2.0.0/tests/test_converter.py +239 -0
- libreoffice_haystack-1.0.0/tests/test_converter.py +0 -129
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/.gitignore +0 -0
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/LICENSE.txt +0 -0
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/pydoc/config_docusaurus.yml +0 -0
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/src/haystack_integrations/components/converters/libreoffice/__init__.py +0 -0
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/src/haystack_integrations/components/converters/py.typed +0 -0
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/tests/__init__.py +0 -0
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/tests/test_files/doc/sample_doc.doc +0 -0
- {libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/tests/test_files/ppt/sample_ppt.ppt +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: libreoffice-haystack
|
|
3
|
-
Version:
|
|
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.
|
|
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
|
[](https://pypi.org/project/libreoffice-haystack)
|
|
29
29
|
[](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
|
[](https://pypi.org/project/libreoffice-haystack)
|
|
4
4
|
[](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
|
+
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/tests/test_files/doc/sample_doc.doc
RENAMED
|
File without changes
|
{libreoffice_haystack-1.0.0 → libreoffice_haystack-2.0.0}/tests/test_files/ppt/sample_ppt.ppt
RENAMED
|
File without changes
|
|
File without changes
|