diff --git a/src/exstruct/core/libreoffice.py b/src/exstruct/core/libreoffice.py index 82c3176..f697396 100644 --- a/src/exstruct/core/libreoffice.py +++ b/src/exstruct/core/libreoffice.py @@ -800,6 +800,9 @@ def _resolve_python_path(soffice_path: Path) -> Path | None: return None +_MACOS_BUNDLE_MACOS_DIR = Path("/Applications/LibreOffice.app/Contents/MacOS") + + def _soffice_program_dirs(soffice_path: Path) -> tuple[Path, ...]: """Return candidate LibreOffice program directories for the given ``soffice`` path.""" @@ -810,6 +813,18 @@ def _soffice_program_dirs(soffice_path: Path) -> tuple[Path, ...]: return tuple(program_dirs) if resolved_parent not in program_dirs: program_dirs.append(resolved_parent) + # macOS keeps soffice in Contents/MacOS but the bundled Python in the sibling + # Contents/Resources, so the executable's own dir never finds it. Homebrew + # compounds this by putting a bash wrapper on PATH that execs into the bundle, + # leaving the resolved path outside the bundle entirely -- hence the fixed + # bundle location as a last resort. + if sys.platform == "darwin": + for parent in (*program_dirs, _MACOS_BUNDLE_MACOS_DIR): + if parent.name != "MacOS": + continue + resources_dir = parent.parent / "Resources" + if resources_dir.is_dir() and resources_dir not in program_dirs: + program_dirs.append(resources_dir) return tuple(program_dirs) diff --git a/tests/core/test_libreoffice_macos_paths.py b/tests/core/test_libreoffice_macos_paths.py new file mode 100644 index 0000000..df6fba1 --- /dev/null +++ b/tests/core/test_libreoffice_macos_paths.py @@ -0,0 +1,63 @@ +"""macOS keeps LibreOffice's bundled Python outside the soffice executable's dir.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from exstruct.core import libreoffice + + +def _fake_bundle(root: Path) -> tuple[Path, Path]: + """Build a minimal LibreOffice.app layout and return (soffice, python).""" + macos_dir = root / "LibreOffice.app" / "Contents" / "MacOS" + resources_dir = root / "LibreOffice.app" / "Contents" / "Resources" + macos_dir.mkdir(parents=True) + resources_dir.mkdir(parents=True) + soffice = macos_dir / "soffice" + python = resources_dir / "python" + soffice.write_text("#!/bin/sh\n") + python.write_text("#!/bin/sh\n") + return soffice, python + + +def test_program_dirs_include_the_sibling_resources_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(libreoffice.sys, "platform", "darwin") + soffice, python = _fake_bundle(tmp_path) + + dirs = libreoffice._soffice_program_dirs(soffice) + + assert python.parent in dirs + candidates = [c for d in dirs for c in libreoffice._bundled_python_candidates(d)] + assert python in candidates + + +def test_program_dirs_fall_back_to_the_bundle_for_a_homebrew_wrapper( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Homebrew puts a bash wrapper on PATH, so the resolved path leaves the bundle.""" + monkeypatch.setattr(libreoffice.sys, "platform", "darwin") + _, python = _fake_bundle(tmp_path) + monkeypatch.setattr( + libreoffice, "_MACOS_BUNDLE_MACOS_DIR", python.parent.parent / "MacOS" + ) + wrapper_dir = tmp_path / "command-wrappers" + wrapper_dir.mkdir() + wrapper = wrapper_dir / "soffice" + wrapper.write_text("#!/bin/bash\n") + + dirs = libreoffice._soffice_program_dirs(wrapper) + + assert python.parent in dirs + + +def test_program_dirs_stay_unchanged_off_macos( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(libreoffice.sys, "platform", "linux") + soffice, python = _fake_bundle(tmp_path) + + assert python.parent not in libreoffice._soffice_program_dirs(soffice)