From 58bef80165ae690dab0cc088711172ba2c61703a Mon Sep 17 00:00:00 2001 From: sam-hokipoki Date: Sat, 12 Sep 2026 15:42:56 +0800 Subject: [PATCH] Find LibreOffice's bundled Python inside a macOS app bundle _soffice_program_dirs only ever looks in soffice's own directory. That holds on Windows and Linux, but a macOS app bundle keeps soffice in Contents/MacOS and the bundled Python in the sibling Contents/Resources, so discovery never finds it and --mode libreoffice fails with: LibreOffice runtime is unavailable: compatible Python runtime was not found. Homebrew compounds this: its soffice on PATH is a bash wrapper that execs into the app bundle, so shutil.which resolves to a Caskroom command-wrappers directory outside the bundle entirely. Hence also falling back to the standard bundle location. Note this fixes discovery only. On current macOS the bundled interpreter still cannot be spawned from outside LibreOffice -- it terminates with SIGKILL (Code Signature Invalid), namespace CODESIGNING, indicator "Launch Constraint Violation", with a valid signature and no quarantine attribute, on a freshly reinstalled 26.2.5. So the mode remains unavailable there unless a system Python with a working uno module is present, which a Homebrew install does not provide. Worth knowing before anyone spends time on the surrounding code path; happy to drop this if you would rather gate the mode off on darwin instead. --- src/exstruct/core/libreoffice.py | 15 ++++++ tests/core/test_libreoffice_macos_paths.py | 63 ++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 tests/core/test_libreoffice_macos_paths.py 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)