Skip to content

Commit abdc9d5

Browse files
authored
Merge pull request #172 from pharmaverse/custom-libreoffice-path
2 parents b56b4ab + c6c0de4 commit abdc9d5

File tree

6 files changed

+187
-35
lines changed

6 files changed

+187
-35
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
## rtflite 2.4.0
4+
5+
### Breaking changes
6+
7+
- Removed the `executable_path` argument from `RTFDocument.write_docx`; pass
8+
`converter=LibreOfficeConverter(executable_path=...)` instead (#172).
9+
10+
### Improvements
11+
12+
- `RTFDocument.write_docx` now accepts a `converter=` instance to configure
13+
LibreOffice invocation (including custom executable paths) and to enable
14+
reusing a converter across multiple conversions (#172).
15+
16+
### Converters
17+
18+
- `LibreOfficeConverter(executable_path=...)` now accepts `Path` objects
19+
and resolves executable names via `PATH` when given
20+
(for example, `"soffice"`) (#172).
21+
322
## rtflite 2.3.1
423

524
### New features

docs/changelog.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
## rtflite 2.4.0
4+
5+
### Breaking changes
6+
7+
- Removed the `executable_path` argument from `RTFDocument.write_docx`; pass
8+
`converter=LibreOfficeConverter(executable_path=...)` instead (#172).
9+
10+
### Improvements
11+
12+
- `RTFDocument.write_docx` now accepts a `converter=` instance to configure
13+
LibreOffice invocation (including custom executable paths) and to enable
14+
reusing a converter across multiple conversions (#172).
15+
16+
### Converters
17+
18+
- `LibreOfficeConverter(executable_path=...)` now accepts `Path` objects
19+
and resolves executable names via `PATH` when given
20+
(for example, `"soffice"`) (#172).
21+
322
## rtflite 2.3.1
423

524
### New features

src/rtflite/convert.py

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import platform
33
import re
4+
import shutil
45
import subprocess
56
from collections.abc import Sequence
67
from pathlib import Path
@@ -17,7 +18,7 @@ class LibreOfficeConverter:
1718
using LibreOffice in headless mode.
1819
1920
Requirements:
20-
- LibreOffice 7.3 or later must be installed.
21+
- LibreOffice 7.1 or later must be installed.
2122
- Automatically finds LibreOffice in standard installation paths.
2223
- For custom installations, provide `executable_path` parameter.
2324
@@ -26,39 +27,72 @@ class LibreOfficeConverter:
2627
This makes it suitable for server environments and automated workflows.
2728
"""
2829

29-
def __init__(self, executable_path: str | None = None):
30+
def __init__(self, executable_path: str | Path | None = None):
3031
"""Initialize converter with optional executable path.
3132
3233
Args:
33-
executable_path: Path to LibreOffice executable. If None, searches
34-
standard installation locations for each platform.
34+
executable_path: Path (or executable name) to LibreOffice. If None,
35+
searches standard installation locations for each platform.
3536
3637
Raises:
3738
FileNotFoundError: If LibreOffice executable cannot be found.
3839
ValueError: If LibreOffice version is below minimum requirement.
3940
"""
40-
self.executable_path = executable_path or self._find_executable()
41-
if not self.executable_path:
42-
raise FileNotFoundError("Can't find LibreOffice executable.")
41+
self.executable_path = self._resolve_executable_path(executable_path)
4342

4443
self._verify_version()
4544

46-
def _find_executable(self) -> str | None:
45+
def _resolve_executable_path(self, executable_path: str | Path | None) -> Path:
46+
"""Resolve the LibreOffice executable path."""
47+
if executable_path is None:
48+
found_executable = self._find_executable()
49+
if found_executable is None:
50+
raise FileNotFoundError("Can't find LibreOffice executable.")
51+
return found_executable
52+
53+
executable = os.fspath(executable_path)
54+
expanded = os.path.expanduser(executable)
55+
candidate = Path(expanded)
56+
candidate_str = str(candidate)
57+
looks_like_path = (
58+
candidate.is_absolute()
59+
or os.sep in candidate_str
60+
or (os.altsep is not None and os.altsep in candidate_str)
61+
)
62+
if looks_like_path:
63+
if candidate.is_file():
64+
return candidate
65+
raise FileNotFoundError(
66+
f"LibreOffice executable not found at: {candidate}."
67+
)
68+
69+
resolved_executable = shutil.which(executable)
70+
if resolved_executable is None:
71+
raise FileNotFoundError(f"Can't find LibreOffice executable: {executable}.")
72+
return Path(resolved_executable)
73+
74+
def _find_executable(self) -> Path | None:
4775
"""Find LibreOffice executable in default locations."""
76+
for name in ("soffice", "libreoffice"):
77+
resolved = shutil.which(name)
78+
if resolved is not None:
79+
return Path(resolved)
80+
4881
system = platform.system()
4982
if system not in DEFAULT_PATHS:
5083
raise RuntimeError(f"Unsupported operating system: {system}.")
5184

5285
for path in DEFAULT_PATHS[system]:
53-
if os.path.isfile(path):
54-
return path
86+
candidate = Path(path)
87+
if candidate.is_file():
88+
return candidate
5589
return None
5690

5791
def _verify_version(self):
5892
"""Verify LibreOffice version meets minimum requirement."""
5993
try:
6094
result = subprocess.run(
61-
[self.executable_path, "--version"],
95+
[str(self.executable_path), "--version"],
6296
capture_output=True,
6397
text=True,
6498
check=True,
@@ -178,10 +212,8 @@ def _convert_single_file(
178212
"Use overwrite=True to force."
179213
)
180214

181-
# executable_path is guaranteed to be non-None after __init__
182-
assert self.executable_path is not None
183215
cmd = [
184-
self.executable_path,
216+
str(self.executable_path),
185217
"--invisible",
186218
"--headless",
187219
"--nologo",

src/rtflite/encode.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,10 @@ def write_rtf(self, file_path: str | Path) -> None:
447447
target_path.write_text(rtf_code, encoding="utf-8")
448448

449449
def write_docx(
450-
self, file_path: str | Path, executable_path: str | None = None
450+
self,
451+
file_path: str | Path,
452+
*,
453+
converter: LibreOfficeConverter | None = None,
451454
) -> None:
452455
"""Write the document as a DOCX file.
453456
@@ -460,27 +463,39 @@ def write_docx(
460463
file_path: Destination path for the DOCX file.
461464
Accepts string or Path input. Can be absolute or relative.
462465
Directories are created if they do not already exist.
463-
executable_path: Optional path to the LibreOffice executable.
464-
If not provided, the default system path will be used.
466+
converter: Optional LibreOffice converter instance.
467+
Pass a configured instance (for example with a custom
468+
`executable_path`) to control how LibreOffice is invoked and to
469+
avoid re-initializing and re-verifying the executable path across
470+
multiple conversions. Note that each call to ``convert()`` still
471+
starts a new LibreOffice process in headless mode; the process is
472+
not kept alive between conversions.
465473
466474
Examples:
467475
```python
468476
doc = RTFDocument(df=data, rtf_title=RTFTitle(text="Report"))
469477
doc.write_docx("output/report.docx")
470478
```
471479
480+
Custom LibreOffice executable:
481+
```python
482+
converter = LibreOfficeConverter(executable_path="/custom/path/to/soffice")
483+
doc.write_docx("output/report.docx", converter=converter)
484+
```
485+
472486
Note:
473487
The method prints the file path to stdout for confirmation.
474488
"""
475489
target_path = Path(file_path).expanduser()
476490
target_path.parent.mkdir(parents=True, exist_ok=True)
477491

492+
if converter is None:
493+
converter = LibreOfficeConverter()
478494
with tempfile.TemporaryDirectory() as tmpdir:
479495
rtf_path = Path(tmpdir) / f"{target_path.stem}.rtf"
480496
rtf_code = self.rtf_encode()
481497
rtf_path.write_text(rtf_code, encoding="utf-8")
482498

483-
converter = LibreOfficeConverter(executable_path=executable_path)
484499
with tempfile.TemporaryDirectory() as convert_tmpdir:
485500
docx_path = converter.convert(
486501
input_files=rtf_path,
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
from rtflite.convert import LibreOfficeConverter
8+
9+
10+
def test_init_accepts_executable_path_as_path(tmp_path, monkeypatch):
11+
dummy_executable = tmp_path / "soffice"
12+
dummy_executable.write_text("")
13+
14+
monkeypatch.setattr(LibreOfficeConverter, "_verify_version", lambda self: None)
15+
converter = LibreOfficeConverter(executable_path=dummy_executable)
16+
17+
assert converter.executable_path == dummy_executable
18+
19+
20+
def test_init_resolves_executable_name_via_which(tmp_path, monkeypatch):
21+
dummy_executable = tmp_path / "soffice"
22+
dummy_executable.write_text("")
23+
24+
monkeypatch.setattr(LibreOfficeConverter, "_verify_version", lambda self: None)
25+
with patch("rtflite.convert.shutil.which", return_value=str(dummy_executable)):
26+
converter = LibreOfficeConverter(executable_path="soffice")
27+
28+
assert converter.executable_path == dummy_executable
29+
30+
31+
def test_init_raises_for_missing_explicit_path(tmp_path, monkeypatch):
32+
monkeypatch.setattr(LibreOfficeConverter, "_verify_version", lambda self: None)
33+
with pytest.raises(FileNotFoundError):
34+
LibreOfficeConverter(executable_path=tmp_path / "missing")
35+
36+
37+
def test_init_raises_for_missing_command(monkeypatch):
38+
monkeypatch.setattr(LibreOfficeConverter, "_verify_version", lambda self: None)
39+
with (
40+
patch("rtflite.convert.shutil.which", return_value=None),
41+
pytest.raises(FileNotFoundError),
42+
):
43+
LibreOfficeConverter(executable_path="soffice")

tests/test_write_docx_args.py

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from pathlib import Path
2-
from unittest.mock import patch
2+
from unittest.mock import ANY, MagicMock, patch
33

44
import polars as pl
55
import pytest
@@ -20,28 +20,52 @@ def sample_document() -> RTFDocument:
2020

2121

2222
@patch("rtflite.encode.LibreOfficeConverter")
23-
def test_write_docx_passes_executable_path(
23+
def test_write_docx_uses_provided_converter(
2424
mock_converter_cls, sample_document, tmp_path
2525
):
26-
"""Verify that executable_path is correctly passed to LibreOfficeConverter."""
27-
# Setup mock
26+
"""Verify that `write_docx` uses a provided converter instance."""
27+
converter = MagicMock()
28+
converter.convert.return_value = Path("dummy.docx")
29+
30+
output_path = tmp_path / "output.docx"
31+
32+
with patch("rtflite.encode.shutil.move"):
33+
sample_document.write_docx(output_path, converter=converter)
34+
35+
mock_converter_cls.assert_not_called()
36+
converter.convert.assert_called_once_with(
37+
input_files=ANY,
38+
output_dir=ANY,
39+
format="docx",
40+
overwrite=True,
41+
)
42+
kwargs = converter.convert.call_args.kwargs
43+
assert isinstance(kwargs["output_dir"], Path)
44+
assert isinstance(kwargs["input_files"], Path)
45+
assert kwargs["input_files"].name == f"{output_path.stem}.rtf"
46+
47+
48+
@patch("rtflite.encode.LibreOfficeConverter")
49+
def test_write_docx_creates_default_converter(
50+
mock_converter_cls, sample_document, tmp_path
51+
):
52+
"""Verify that `write_docx` creates a default converter when omitted."""
2853
mock_instance = mock_converter_cls.return_value
2954
mock_instance.convert.return_value = Path("dummy.docx")
3055

31-
# Create dummy docx file that would be moved
3256
output_path = tmp_path / "output.docx"
3357

34-
# We need to mock shutil.move as well since the converter is mocked
35-
# and won't actually create files
36-
with patch("shutil.move"):
37-
sample_document.write_docx(
38-
output_path, executable_path="/custom/path/to/soffice"
39-
)
58+
with patch("rtflite.encode.shutil.move"):
59+
sample_document.write_docx(output_path)
4060

41-
# Verify LibreOfficeConverter was initialized with the correct path
42-
mock_converter_cls.assert_called_once_with(
43-
executable_path="/custom/path/to/soffice"
61+
mock_converter_cls.assert_called_once_with()
62+
mock_instance.convert.assert_called_once_with(
63+
input_files=ANY,
64+
output_dir=ANY,
65+
format="docx",
66+
overwrite=True,
4467
)
45-
46-
# Verify convert was called
47-
mock_instance.convert.assert_called_once()
68+
kwargs = mock_instance.convert.call_args.kwargs
69+
assert isinstance(kwargs["output_dir"], Path)
70+
assert isinstance(kwargs["input_files"], Path)
71+
assert kwargs["input_files"].name == f"{output_path.stem}.rtf"

0 commit comments

Comments
 (0)