klayout/macbuild/bundle_qtconf.py

474 lines
16 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
File: "macbuild/bundle_qtconf.py"
Author: ChatGPT + Kazzz-S
Utilities to generate and embed a proper qt.conf into a macOS .app bundle
for KLayout (or any Qt-based app), supporting two strategies:
- ST/HW (Qt embedded in the bundle): relative qt.conf (Prefix=.., Plugins=PlugIns)
- LW (use system-wide Qt): absolute qt.conf (Plugins=<absolute path>)
Policy:
- The bundle creator ("macbuild/build4mac.py") decides the target stack at build time.
- The distributed app contains exactly ONE qt.conf (no scripts post-distribution).
Command-line test usage:
python bundle_qtconf.py --mode lw --stack macports --qt 5
python bundle_qtconf.py --app ./dist/klayout.app --mode st --plugins /opt/local/libexec/qt5/plugins
Typical usage:
from pathlib import Path
from bundle_qtconf import generate_qtconf, QtConfError
# 1) LW + MacPorts Qt5 (print-only)
try:
text = generate_qtconf(
mode="lw",
lw_stack="macports",
lw_qt_major=5,
)
print(text)
except QtConfError as e:
print(f"Failed: {e}")
# 2) LW + Homebrew Qt6 (write into app)
try:
text = generate_qtconf(
app_path="dist/klayout.app",
mode="lw",
lw_stack="homebrew",
lw_qt_major=6,
arch_hint="arm64", # "x86_64" for Intel; "auto" works too
)
print(text)
except QtConfError as e:
print(f"Failed: {e}")
# 3) LW + Anaconda (Automator-safe: pass explicit prefix if needed)
try:
text = generate_qtconf(
app_path=Path("dist/klayout.app"),
mode="lw",
lw_stack="anaconda",
conda_prefix="/opt/anaconda3",
)
print(text)
except QtConfError as e:
print(f"Failed: {e}")
# 4) ST/HW (Qt embedded in the bundle)
try:
text = generate_qtconf(
app_path="dist/klayout.app",
mode="st", # or "hw"
embedded_plugins_src="/opt/local/libexec/qt5/plugins",
validate=True,
)
print(text)
except QtConfError as e:
print(f"Failed: {e}")
"""
from __future__ import annotations
import os
import shutil
import subprocess
import argparse
from pathlib import Path
from typing import Iterable, Optional, Tuple, List, Union
class QtConfError(RuntimeError):
"""Raised when qt.conf generation or validation fails."""
# -----------------------------------------------------------------------------
# Utility helpers
# -----------------------------------------------------------------------------
def _app_paths(app_path: Path) -> Tuple[Path, Path, Path]:
"""Return (Resources, PlugIns, MacOS) directories for the .app bundle."""
app_path = app_path.resolve()
contents = app_path / "Contents"
resources = contents / "Resources"
plugins = contents / "PlugIns"
macos = contents / "MacOS"
return resources, plugins, macos
def _ensure_dir(p: Path) -> None:
p.mkdir(parents=True, exist_ok=True)
def choose_homebrew_root(arch_hint: str = "auto") -> Path:
"""Choose Homebrew prefix based on architecture hint."""
if arch_hint == "arm64" or (arch_hint == "auto" and Path("/opt/homebrew").is_dir()):
return Path("/opt/homebrew")
return Path("/usr/local")
def _is_executable(p: Path) -> bool:
try:
return p.is_file() and os.access(str(p), os.X_OK)
except Exception:
return False
def _expand_candidates_with_glob(candidates: List[Path]) -> List[Path]:
expanded: List[Path] = []
for c in candidates:
s = str(c)
if "*" in s or "?" in s or "[" in s:
try:
expanded.extend(Path(x) for x in sorted(map(str, c.parent.glob(c.name))))
except Exception:
pass
else:
expanded.append(c)
return expanded
def _first_existing_platforms_dir(candidates: List[Path]) -> Optional[Path]:
for c in _expand_candidates_with_glob(candidates):
if (c / "platforms").is_dir():
return c
return None
def _home_dir() -> Path:
"""Return user's home directory, safe for Automator/launchd environments."""
try:
h = os.environ.get("HOME")
if h:
return Path(h)
except Exception:
pass
return Path.home()
# -----------------------------------------------------------------------------
# LW plugin dir resolvers (MacPorts / Homebrew / Anaconda)
# -----------------------------------------------------------------------------
def find_plugins_dir_lw(
lw_stack: str,
lw_qt_major: Optional[int] = None,
arch_hint: str = "auto",
conda_prefix: Optional[Path] = None,
) -> Path:
"""Resolve the absolute Qt plugins directory for LW mode."""
stack = lw_stack.lower().strip()
# --- MacPorts ---
if stack == "macports":
if lw_qt_major not in (5, 6):
raise QtConfError("MacPorts requires lw_qt_major to be 5 or 6.")
return Path(f"/opt/local/libexec/qt{lw_qt_major}/plugins")
# --- Homebrew ---
if stack == "homebrew":
if lw_qt_major not in (5, 6):
raise QtConfError("Homebrew requires lw_qt_major to be 5 or 6.")
hb = choose_homebrew_root(arch_hint)
def _looks_like_qt6(p):
s = str(p)
return "/qt6/" in s or s.endswith("/share/qt/plugins") or "/qtbase/" in s
def _looks_like_qt5(p):
s = str(p)
return "/qt@5/" in s or "/qt5/" in s
candidates: List[Path] = []
if lw_qt_major == 6:
# Prefer qtpaths from qt or qtbase (Qt6 split)
for formula in ("qt", "qtbase"):
qtpaths_bin = hb / "opt" / formula / "bin" / "qtpaths"
if _is_executable(qtpaths_bin):
try:
out = subprocess.check_output([str(qtpaths_bin), "--plugin-dir"], text=True).strip()
p = Path(out)
if (p / "platforms").is_dir():
return p
except Exception:
pass
candidates += [
hb / "opt" / "qt" / "share" / "qt" / "plugins",
hb / "opt" / "qtbase" / "share" / "qt" / "plugins",
hb / "Cellar" / "qt" / "*" / "share" / "qt" / "plugins",
hb / "Cellar" / "qtbase" / "*" / "share" / "qt" / "plugins",
hb / "opt" / "qt" / "lib" / "qt6" / "plugins",
hb / "opt" / "qt" / "plugins",
]
found = _first_existing_platforms_dir(candidates)
if found and _looks_like_qt6(found):
return found
else:
qtpaths_bin = hb / "opt" / "qt@5" / "bin" / "qtpaths"
if _is_executable(qtpaths_bin):
try:
out = subprocess.check_output([str(qtpaths_bin), "--plugin-dir"], text=True).strip()
p = Path(out)
if (p / "platforms").is_dir() and _looks_like_qt5(p):
return p
except Exception:
pass
candidates += [
hb / "opt" / "qt@5" / "plugins",
hb / "opt" / "qt@5" / "lib" / "qt5" / "plugins",
hb / "Cellar" / "qt@5" / "*" / "plugins",
hb / "Cellar" / "qt@5" / "*" / "lib" / "qt5" / "plugins",
]
for c in _expand_candidates_with_glob(candidates):
if (c / "platforms").is_dir() and _looks_like_qt5(c):
return c
raise QtConfError(
f"Homebrew Qt{lw_qt_major} plugins not found under {hb}. Checked: "
+ ", ".join(str(p) for p in _expand_candidates_with_glob(candidates))
)
# --- Anaconda / Miniconda / Mambaforge / Miniforge ---
if stack == "anaconda":
def _env_plugins_candidates(env_root, qt_major):
if qt_major == 6:
return [Path(env_root) / "lib" / "qt6" / "plugins"]
else:
return [Path(env_root) / "plugins"]
def _base_preferred_envs(base_root, qt_major):
names = ["klayout-qt6"] if qt_major == 6 else ["klayout-qt5"]
env_roots = [Path(base_root) / "envs" / n for n in names]
cands = []
for er in env_roots:
cands.extend(_env_plugins_candidates(er, qt_major))
return cands
def _scan_all_envs(base_root, qt_major):
cands = []
envs_dir = Path(base_root) / "envs"
if envs_dir.is_dir():
for er in sorted(envs_dir.iterdir()):
if not er.is_dir():
continue
n = er.name.lower()
if qt_major == 6 and "qt6" in n:
cands.extend(_env_plugins_candidates(er, 6))
elif qt_major == 5 and "qt5" in n:
cands.extend(_env_plugins_candidates(er, 5))
for er in sorted(envs_dir.iterdir()):
if er.is_dir():
cands.extend(_env_plugins_candidates(er, qt_major))
return cands
def _base_generic_candidates(base_root):
return [
Path(base_root) / "plugins",
Path(base_root) / "lib" / "qt" / "plugins",
Path(base_root) / "lib" / "qt5" / "plugins",
Path(base_root) / "lib" / "qt6" / "plugins",
]
qt_major = lw_qt_major or 6
roots: List[Path] = []
if conda_prefix:
roots.append(Path(conda_prefix))
env_prefix = os.environ.get("CONDA_PREFIX", "")
if env_prefix:
roots.append(Path(env_prefix))
home = _home_dir()
roots += [
Path("/opt/anaconda3"),
Path("/usr/local/anaconda3"),
home / "opt" / "anaconda3",
home / "anaconda3",
Path("/opt/miniconda3"),
Path("/usr/local/miniconda3"),
home / "miniconda3",
Path("/opt/mambaforge"),
home / "mambaforge",
Path("/opt/miniforge3"),
home / "miniforge3",
Path("/Applications/anaconda3"),
Path("/Applications/miniconda3"),
Path("/Applications/mambaforge"),
Path("/Applications/miniforge3"),
]
plugin_candidates: List[Path] = []
if conda_prefix:
cp = Path(conda_prefix)
if (cp / "conda-meta").is_dir() and not (cp / "envs").is_dir():
plugin_candidates.extend(_env_plugins_candidates(cp, qt_major))
for base in roots:
b = Path(base)
try:
b = b.resolve()
except Exception:
pass
plugin_candidates.extend(_base_preferred_envs(b, qt_major))
plugin_candidates.extend(_scan_all_envs(b, qt_major))
plugin_candidates.extend(_base_generic_candidates(b))
# Highest priority: Intel GUI installer layout
apps_direct = Path("/Applications/anaconda3/plugins")
if apps_direct.exists():
plugin_candidates.insert(0, apps_direct)
found = _first_existing_platforms_dir(plugin_candidates)
if found:
return found
raise QtConfError(
"Anaconda plugins not found. Checked: "
+ ", ".join(str(p) for p in _expand_candidates_with_glob(plugin_candidates))
)
raise QtConfError(f"Unknown lw_stack: {lw_stack}")
# -----------------------------------------------------------------------------
# Core functions
# -----------------------------------------------------------------------------
def _validate_libqcocoa(plugins_dir: Path) -> None:
"""Ensure libqcocoa.dylib exists under <plugins_dir>/platforms."""
lib = plugins_dir / "platforms" / "libqcocoa.dylib"
if not lib.is_file():
raise QtConfError(f"libqcocoa.dylib not found: {lib}")
def copy_embedded_plugins(
embedded_plugins_src: Path,
bundle_plugins_dir: Path,
subdirs: Iterable[str] = ("platforms",),
overwrite: bool = True,
) -> None:
"""Copy selected plugin subdirectories into the bundle."""
embedded_plugins_src = embedded_plugins_src.resolve()
bundle_plugins_dir = bundle_plugins_dir.resolve()
_ensure_dir(bundle_plugins_dir)
for d in subdirs:
src = embedded_plugins_src / d
dst = bundle_plugins_dir / d
if not src.is_dir():
raise QtConfError(f"Missing plugin subdir at source: {src}")
if dst.exists() and overwrite:
shutil.rmtree(dst)
shutil.copytree(src, dst)
def make_qtconf_text_relative() -> str:
"""Return relative qt.conf text for ST/HW bundles."""
return (
"[Paths]\n"
"Prefix=..\n"
"Plugins=PlugIns\n"
"# Uncomment if QML is embedded:\n"
"# Imports=Resources/qml\n"
"# Qml2Imports=Resources/qml\n"
)
def make_qtconf_text_absolute(plugins_dir: Path) -> str:
"""Return absolute qt.conf text for LW bundles."""
return f"[Paths]\nPlugins={plugins_dir}\n"
def generate_qtconf(
app_path: Optional[Union[str, Path]] = None,
*,
mode: str,
embedded_plugins_src: Optional[Union[str, Path]] = None,
lw_stack: Optional[str] = None,
lw_qt_major: Optional[int] = None,
arch_hint: str = "auto",
conda_prefix: Optional[Union[str, Path]] = None,
validate: bool = True,
) -> str:
"""Generate qt.conf content (and optionally write it to the bundle)."""
app_path_p: Optional[Path] = Path(app_path).resolve() if app_path else None
qtconf_text: str
if mode in ("st", "hw"):
qtconf_text = make_qtconf_text_relative()
if app_path_p:
resources, plugins, _macos = _app_paths(app_path_p)
_ensure_dir(resources)
if embedded_plugins_src:
copy_embedded_plugins(Path(embedded_plugins_src), plugins)
if validate:
_validate_libqcocoa(plugins)
(resources / "qt.conf").write_text(qtconf_text, encoding="utf-8")
elif mode == "lw":
if lw_stack is None:
raise QtConfError("lw_stack is required for LW mode (macports|homebrew|anaconda).")
plugins_dir = find_plugins_dir_lw(
lw_stack=lw_stack,
lw_qt_major=lw_qt_major,
arch_hint=arch_hint,
conda_prefix=Path(conda_prefix) if conda_prefix else None,
)
if validate:
_validate_libqcocoa(plugins_dir)
qtconf_text = make_qtconf_text_absolute(plugins_dir)
if app_path_p:
resources, _, _ = _app_paths(app_path_p)
_ensure_dir(resources)
(resources / "qt.conf").write_text(qtconf_text, encoding="utf-8")
else:
raise QtConfError(f"Unknown mode: {mode}")
return qtconf_text
# -----------------------------------------------------------------------------
# CLI for testing
# -----------------------------------------------------------------------------
def main() -> None:
"""Standalone CLI for testing or dry-run output."""
parser = argparse.ArgumentParser(description="Generate qt.conf or print its content.")
parser.add_argument("--app", help="Path to the .app bundle (optional; if omitted, only print the content)")
parser.add_argument("--mode", choices=["st", "hw", "lw"], required=True, help="Bundle mode")
parser.add_argument("--stack", choices=["macports", "homebrew", "anaconda"], help="LW: Qt stack type")
parser.add_argument("--qt", type=int, choices=[5, 6], help="LW: Qt major version (5 or 6)")
parser.add_argument("--arch", default="auto", choices=["auto", "arm64", "x86_64"], help="LW: arch hint for Homebrew")
parser.add_argument("--plugins", help="ST/HW: source path of Qt plugins")
parser.add_argument("--no-validate", action="store_true", help="Skip validation of libqcocoa.dylib existence")
parser.add_argument("--conda-prefix", help="LW(Anaconda) only: explicit CONDA_PREFIX to use")
args = parser.parse_args()
try:
qtconf_text = generate_qtconf(
app_path=args.app,
mode=args.mode,
embedded_plugins_src=args.plugins,
lw_stack=args.stack,
lw_qt_major=args.qt,
arch_hint=args.arch,
conda_prefix=args.conda_prefix,
validate=not args.no_validate,
)
if args.app:
print(f"[OK] qt.conf written to bundle: {args.app}")
print("----- qt.conf content -----")
print(qtconf_text.strip())
print("---------------------------")
except QtConfError as e:
print(f"[ERROR] {e}")
raise SystemExit(1)
if __name__ == "__main__":
main()