From 350b5436480610aa57e63c6e57752d1f53b199c8 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Thu, 4 Jun 2026 16:33:10 +0200 Subject: [PATCH] Make build-cpython-ghuser-components work on macOS/Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the componentizer as a direct subprocess (sys.executable) instead of through a shell, and on macOS force the Mono runtime and add the Homebrew library dirs to DYLD_LIBRARY_PATH. The componentizer loads GH_IO.dll via pythonnet, so no Rhino/Windows is required — only a .NET runtime. On macOS that's Mono, which needs the native libgdiplus to embed component icons. Two things broke this off Windows: - The Mono that pythonnet embeds doesn't search the Homebrew prefix the way the `mono` CLI does, so libgdiplus wasn't found. Setting DYLD_LIBRARY_PATH to $(brew --prefix)/lib (plus the standard /opt/homebrew and /usr/local prefixes) fixes resolution. - macOS SIP strips DYLD_* when a command crosses the protected /bin/sh, which is what invoke's ctx.run spawns. Calling the interpreter directly via subprocess.run keeps the variable alive. Windows and Linux behaviour is unchanged (env returned untouched). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ src/compas_invocations2/build.py | 43 +++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c4013..b57bed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* `build-cpython-ghuser-components` now runs the componentizer as a direct subprocess (using `sys.executable`) instead of through a shell, and on macOS forces the Mono runtime and adds the Homebrew library directories to `DYLD_LIBRARY_PATH`. This makes the task work on macOS (and Linux) without Rhino/Windows — previously the embedded Mono could not find `libgdiplus` to embed component icons, and macOS SIP stripped `DYLD_*` across the shell `ctx.run` used. + ### Removed diff --git a/src/compas_invocations2/build.py b/src/compas_invocations2/build.py index 62b448e..6041274 100644 --- a/src/compas_invocations2/build.py +++ b/src/compas_invocations2/build.py @@ -1,6 +1,9 @@ import glob import os +import platform import shutil +import subprocess +import sys import tempfile import invoke @@ -173,12 +176,44 @@ def build_cpython_ghuser_components(ctx, gh_io_folder=None, prefix=None): gh_io_folder = os.path.abspath(gh_io_folder) componentizer_script = os.path.join(action_dir, "componentize_cpy.py") - cmd = "{} {} {} {}".format("python", componentizer_script, source_dir, target_dir) - cmd += ' --ghio "{}"'.format(gh_io_folder) + cmd = [sys.executable, componentizer_script, source_dir, target_dir, "--ghio", gh_io_folder] if prefix: - cmd += ' --prefix "{}"'.format(prefix) + cmd += ["--prefix", prefix] - ctx.run(cmd) + # The componentizer loads GH_IO.dll through pythonnet. On macOS that means Mono, + # which needs the native libgdiplus to embed component icons. The embedded Mono + # does not search the Homebrew prefix the way the `mono` CLI does, so we point it + # there via DYLD_LIBRARY_PATH. We also run the interpreter directly instead of + # through `ctx.run` (which spawns a shell): macOS SIP strips DYLD_* across the + # protected /bin/sh, so otherwise the variable never reaches the subprocess. + subprocess.run(cmd, env=_componentizer_env(), check=True) + + +def _componentizer_env(): + """Return the environment for the componentizer subprocess. + + On macOS this forces the Mono runtime (pythonnet may otherwise default to a + .NET Core runtime that cannot load the net48 ``GH_IO.dll`` / ``System.Drawing``) + and adds the Homebrew library directories to ``DYLD_LIBRARY_PATH`` so Mono can + find ``libgdiplus``. On other platforms the current environment is returned + unchanged. + """ + env = dict(os.environ) + if platform.system() != "Darwin": + return env + + env.setdefault("PYTHONNET_RUNTIME", "mono") + + lib_dirs = ["/opt/homebrew/lib", "/usr/local/lib"] + try: + brew_prefix = subprocess.check_output(["brew", "--prefix"], text=True).strip() + lib_dirs.insert(0, os.path.join(brew_prefix, "lib")) + except (OSError, subprocess.CalledProcessError): + pass + if env.get("DYLD_LIBRARY_PATH"): + lib_dirs.append(env["DYLD_LIBRARY_PATH"]) + env["DYLD_LIBRARY_PATH"] = os.pathsep.join(dict.fromkeys(d for d in lib_dirs if d)) + return env @invoke.task